diff --git a/README.md b/README.md index 5532cb7..5259d9f 100644 --- a/README.md +++ b/README.md @@ -13,18 +13,18 @@ This repository is a **[VitePress](https://vitepress.dev/)** project: guides, AP ### Core sections on docs site -- `State` -- `Elements` -- `Browser` -- `Sensors` -- `Network` -- `Animation` -- `Component` -- `Watch` -- `Reactivity` -- `Array` -- `Time` -- `Utilities` +- [State](https://usereact.org/functions/state) +- [Elements](https://usereact.org/functions/elements) +- [Browser](https://usereact.org/functions/browser) +- [Sensors](https://usereact.org/functions/sensors) +- [Network](https://usereact.org/functions/network) +- [Animation](https://usereact.org/functions/animation) +- [Component](https://usereact.org/functions/component) +- [Watch](https://usereact.org/functions/watch) +- [Reactivity](https://usereact.org/functions/reactivity) +- [Array](https://usereact.org/functions/array) +- [Time](https://usereact.org/functions/time) +- [Utilities](https://usereact.org/functions/utilities) ## Requirements diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index bf2b108..a374915 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -1,8 +1,55 @@ import { defineConfig } from 'vitepress' import type { PageData } from 'vitepress' +import path from 'node:path' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' import vueJsx from '@vitejs/plugin-vue-jsx' import { buildCoreFunctionsSidebarGroup } from './data/hookCatalog' import { transformHead as seoTransformHead } from './seo/transformHead' +import { SOCIAL_X_URL } from './seo/social' + +// use-react-docs/ (this file is docs/.vitepress/config.mts) +const DOCS_PROJECT_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..') + +// Monorepo: use sibling `../use-react` so docs don’t use a **stale** Vite prebundle of `node_modules/@dedalik/use-react`. +const USE_REACT_DEV_ENTRY = path.resolve(DOCS_PROJECT_ROOT, '../use-react/src/index.ts') +const USE_REACT_ALIASED = existsSync(USE_REACT_DEV_ENTRY) +/** Resolves `import x from '@dedalik/use-react/useX'` to source files when developing against local `../use-react`. */ +const USE_REACT_HOOKS_SRC = path.resolve(DOCS_PROJECT_ROOT, '../use-react/src/hooks') + +// Keep paths posix-ish: Vite optimizeDeps works reliably with forward slashes in this repo. +const REACT_DEMO_BASENAMES = [ + 'useAsyncState.basic.ts', + 'useCounter.basic.ts', + 'useDebouncedRefHistory.basic.ts', + 'useDebounce.basic.ts', + 'useEventCallback.basic.ts', + 'useLastChanged.basic.ts', + 'useLatest.basic.ts', + 'useManualRefHistory.basic.ts', + 'useOnMount.basic.ts', + 'usePrevious.basic.ts', + 'useRefHistory.basic.ts', + 'useStorageAsync.basic.ts', + 'useStorage.basic.ts', + 'useThrottledRefHistory.basic.ts', + 'useThrottle.basic.ts', + 'useToggle.basic.ts', +] as const + +const optimizeDepsInclude = [ + 'react', + 'react/jsx-runtime', + 'react/jsx-dev-runtime', + 'react-dom', + 'react-dom/client', + 'shikiji', + 'shikiji/wasm', + 'shikiji-core', + 'tslib', + ...(!USE_REACT_ALIASED ? (['@dedalik/use-react'] as const) : []), + ...REACT_DEMO_BASENAMES.map((file) => path.posix.join('docs/.vitepress/theme/react-demos', file)), +] function editUrlForPage(page: PageData): string { const m = page.relativePath.match(/^functions\/(.+)\.md$/) @@ -29,6 +76,41 @@ export default defineConfig({ }) }, plugins: [vueJsx()], + vite: { + resolve: { + // Prevent multiple React copies (breaks hooks when @dedalik/use-react resolves a different instance). + dedupe: ['react', 'react-dom', 'react-dom/client'], + // Hard pin React to a single on-disk module in dev (Vite prebundles can still split without this). + // Subpath `import from '@dedalik/use-react/useX'` must resolve before the barrel alias; order matters. + alias: [ + ...(USE_REACT_ALIASED + ? ([ + { + find: /^@dedalik\/use-react\/(.+)$/, + replacement: `${USE_REACT_HOOKS_SRC.replace(/\\/g, '/')}/$1.tsx`, + }, + { find: '@dedalik/use-react', replacement: USE_REACT_DEV_ENTRY }, + ] as const) + : []), + { find: 'react', replacement: path.join(DOCS_PROJECT_ROOT, 'node_modules/react') }, + { find: 'react/jsx-runtime', replacement: path.join(DOCS_PROJECT_ROOT, 'node_modules/react/jsx-runtime.js') }, + { + find: 'react/jsx-dev-runtime', + replacement: path.join(DOCS_PROJECT_ROOT, 'node_modules/react/jsx-dev-runtime.js'), + }, + { find: 'react-dom', replacement: path.join(DOCS_PROJECT_ROOT, 'node_modules/react-dom') }, + { find: 'react-dom/client', replacement: path.join(DOCS_PROJECT_ROOT, 'node_modules/react-dom/client.js') }, + ], + }, + ssr: { + noExternal: ['@dedalik/use-react'], + }, + optimizeDeps: { + include: optimizeDepsInclude, + // Local `use-react` must not be served from a cached `node_modules/.vite/deps` snapshot. + exclude: USE_REACT_ALIASED ? (['@dedalik/use-react'] as const) : [], + }, + }, markdown: { image: { // image lazy loading is disabled by default @@ -53,7 +135,7 @@ export default defineConfig({ }, ], ['meta', { name: 'twitter:card', content: 'summary_large_image' }], - ['meta', { name: 'twitter:creator', content: '@antfu7' }], + ['meta', { name: 'twitter:creator', content: SOCIAL_X_URL }], ['meta', { name: 'twitter:image', content: 'https://usereact.org/logo.png' }], [ 'meta', @@ -86,23 +168,6 @@ export default defineConfig({ href: 'https://fonts.googleapis.com/css2?family=Fira+Code&display=swap', }, ], - - // Google tag (gtag.js) - G-FBEWF72TFF - [ - 'script', - { - async: '', - src: 'https://www.googletagmanager.com/gtag/js?id=G-FBEWF72TFF', - }, - ], - [ - 'script', - {}, - `window.dataLayer = window.dataLayer || []; -function gtag(){dataLayer.push(arguments);} -gtag('js', new Date()); -gtag('config', 'G-FBEWF72TFF');`, - ], ], themeConfig: { diff --git a/docs/.vitepress/data/homeStateDemos.ts b/docs/.vitepress/data/homeStateDemos.ts new file mode 100644 index 0000000..8c99e6a --- /dev/null +++ b/docs/.vitepress/data/homeStateDemos.ts @@ -0,0 +1,19 @@ +/** Live demos for the home “State” section (same order as /functions/state, plus any extra state demos with bundles). */ +export const homeStateDemos: ReadonlyArray<{ demo: string; title: string }> = [ + { demo: 'useToggle/basic', title: 'useToggle' }, + { demo: 'useCounter/basic', title: 'useCounter' }, + { demo: 'useDebounce/basic', title: 'useDebounce' }, + { demo: 'usePrevious/basic', title: 'usePrevious' }, + { demo: 'useLatest/basic', title: 'useLatest' }, + { demo: 'useThrottle/basic', title: 'useThrottle' }, + { demo: 'useAsyncState/basic', title: 'useAsyncState' }, + { demo: 'useStorage/basic', title: 'useStorage' }, + { demo: 'useStorageAsync/basic', title: 'useStorageAsync' }, + { demo: 'useLastChanged/basic', title: 'useLastChanged' }, + { demo: 'useRefHistory/basic', title: 'useRefHistory' }, + { demo: 'useManualRefHistory/basic', title: 'useManualRefHistory' }, + { demo: 'useDebouncedRefHistory/basic', title: 'useDebouncedRefHistory' }, + { demo: 'useThrottledRefHistory/basic', title: 'useThrottledRefHistory' }, + { demo: 'useEventCallback/basic', title: 'useEventCallback' }, + { demo: 'useOnMount/basic', title: 'useOnMount' }, +] diff --git a/docs/.vitepress/data/hookDemoSubtitles.ts b/docs/.vitepress/data/hookDemoSubtitles.ts new file mode 100644 index 0000000..e76c800 --- /dev/null +++ b/docs/.vitepress/data/hookDemoSubtitles.ts @@ -0,0 +1,32 @@ +/** + * One-line copy for the HookLiveDemo block (replaces the generic “React mounts here…” line). + * Keys are `useX/basic` as passed to the `demo` prop. + */ +export const hookDemoSubtitles: Record = { + 'useToggle/basic': + 'Boolean (or set) state with a flip/toggle, optional custom setters, and a stable toggler function.', + 'useCounter/basic': 'Increment, decrement, or set a number with optional min/max so values stay in range.', + 'useDebounce/basic': 'Debounce a value: the output updates only after the input has settled for a chosen delay.', + 'usePrevious/basic': + 'Read the value from the previous render - handy for diffs, animations, and simple undo of values.', + 'useLatest/basic': + 'A ref that always points at the latest value, so callbacks and effects can read fresh data without re-subscribing.', + 'useThrottle/basic': 'Throttle a value so it can change at most once per interval while the input keeps moving.', + 'useAsyncState/basic': 'Async loadable state: loading / ready / error, optional data, and cancel for stale work.', + 'useStorage/basic': 'Sync React state with `localStorage` (or any `Storage`) with JSON and optional `remove`.', + 'useStorageAsync/basic': + 'Async storage: load with `getItem` after mount, then persist with a loading flag and serializers.', + 'useLastChanged/basic': + 'Record when a value last changed, expose a time difference, and reset the timer when needed.', + 'useRefHistory/basic': 'Every change becomes a history snapshot: undo, redo, clear, and cap length with capacity.', + 'useManualRefHistory/basic': + 'Ref-based history, but you commit snapshots explicitly for frequent or batched updates.', + 'useDebouncedRefHistory/basic': + 'Ref history: record a new snapshot only after the value has been still for a debounce period.', + 'useThrottledRefHistory/basic': + 'Ref history: record at most one snapshot per throttle window while the value changes fast.', + 'useEventCallback/basic': 'Event handler with a stable identity that always calls the latest implementation.', + 'useOnMount/basic': 'Run a function once on mount, optionally with a returned cleanup on unmount.', +} + +export const defaultHookLiveDemoFallback = 'Live preview: React mounts here and runs the hook in your browser.' diff --git a/docs/.vitepress/seo/social.ts b/docs/.vitepress/seo/social.ts new file mode 100644 index 0000000..bbe310d --- /dev/null +++ b/docs/.vitepress/seo/social.ts @@ -0,0 +1,2 @@ +/** Public X (Twitter) profile URL for meta tags and JSON-LD. */ +export const SOCIAL_X_URL = 'https://x.com/KiploksEngine' as const diff --git a/docs/.vitepress/seo/transformHead.ts b/docs/.vitepress/seo/transformHead.ts index e8d6a0f..89501f2 100644 --- a/docs/.vitepress/seo/transformHead.ts +++ b/docs/.vitepress/seo/transformHead.ts @@ -1,4 +1,5 @@ import type { HeadConfig, PageData, SiteData } from 'vitepress' +import { SOCIAL_X_URL } from './social' const SITE = (process.env.SITE_URL || 'https://usereact.org').replace(/\/$/, '') const AUTHOR_NAME = 'Radiks Alijevs' @@ -31,6 +32,7 @@ function buildJsonLd(args: { canonical: string; title: string; description: stri '@id': `${SITE}/#organization`, name: PUBLISHER_NAME, url: `${SITE}/`, + sameAs: [SOCIAL_X_URL], logo: { '@type': 'ImageObject', url: `${SITE}/logo.png`, diff --git a/docs/.vitepress/theme/components/HomeBottomCta.vue b/docs/.vitepress/theme/components/HomeBottomCta.vue new file mode 100644 index 0000000..1304f47 --- /dev/null +++ b/docs/.vitepress/theme/components/HomeBottomCta.vue @@ -0,0 +1,369 @@ + + + + + diff --git a/docs/.vitepress/theme/components/HomeHeroStats.vue b/docs/.vitepress/theme/components/HomeHeroStats.vue index c52c363..bc9552c 100644 --- a/docs/.vitepress/theme/components/HomeHeroStats.vue +++ b/docs/.vitepress/theme/components/HomeHeroStats.vue @@ -4,13 +4,16 @@ import { totalHooks } from '../../data/hookCatalog' @@ -20,52 +23,66 @@ import { totalHooks } from '../../data/hookCatalog' margin: 1rem auto 2rem; max-width: 1152px; padding: 0 24px; +} + +.home-hero-stats__bar { display: flex; flex-wrap: wrap; - align-items: center; - gap: 0.75rem; + align-items: stretch; + max-width: 42rem; + border: 1px solid var(--vp-c-divider); + border-radius: 6px; + background: var(--vp-c-bg-soft); } -.home-hero-stats__badge { - display: inline-flex; +.home-hero-stats__cell { + flex: 1 1 160px; + display: flex; flex-direction: column; justify-content: center; - min-width: 170px; - padding: 0.75rem 1rem; - border-radius: 14px; - border: 1px solid color-mix(in srgb, var(--vp-c-divider) 72%, transparent); - background: linear-gradient( - 140deg, - color-mix(in srgb, var(--vp-c-bg-soft) 92%, var(--vp-c-brand-1)) 0%, - color-mix(in srgb, var(--vp-c-bg-soft) 96%, var(--vp-c-brand-soft)) 100% - ); - box-shadow: 0 14px 30px -24px color-mix(in srgb, var(--vp-c-brand-1) 48%, transparent); + gap: 0.2rem; + padding: 0.85rem 1.15rem; + min-width: 0; } -.home-hero-stats__badge--primary { - border-color: color-mix(in srgb, var(--vp-c-brand-1) 35%, var(--vp-c-divider)); +.home-hero-stats__sep { + flex: 0 0 1px; + width: 1px; + margin: 0.65rem 0; + align-self: stretch; + background: var(--vp-c-divider); +} + +@media (max-width: 520px) { + .home-hero-stats__sep { + flex: 1 1 100%; + width: auto; + height: 1px; + margin: 0 0.85rem; + align-self: stretch; + } } .home-hero-stats__value { - display: block; - font-size: clamp(1.35rem, 2.2vw, 1.8rem); - font-weight: 800; - line-height: 1.05; - letter-spacing: -0.03em; + font-size: clamp(1.25rem, 2vw, 1.5rem); + font-weight: 600; + line-height: 1.15; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; color: var(--vp-c-text-1); } -.home-hero-stats__value--small { - font-size: clamp(1rem, 1.8vw, 1.2rem); - letter-spacing: -0.01em; +.home-hero-stats__value--text { + font-size: clamp(1rem, 1.65vw, 1.2rem); + font-weight: 600; + letter-spacing: -0.015em; } .home-hero-stats__label { - margin-top: 0.2rem; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.35; + letter-spacing: 0.01em; color: var(--vp-c-text-2); } diff --git a/docs/.vitepress/theme/components/HomeHookShowcase.vue b/docs/.vitepress/theme/components/HomeHookShowcase.vue index 4047849..11d10e1 100644 --- a/docs/.vitepress/theme/components/HomeHookShowcase.vue +++ b/docs/.vitepress/theme/components/HomeHookShowcase.vue @@ -1,47 +1,164 @@ @@ -52,7 +169,16 @@ import { hookCategoriesCatalog } from '../../data/hookCatalog' margin: 0 auto; max-width: 1160px; padding: 3rem 1.5rem 4rem; + scroll-margin-top: calc(var(--vp-nav-height, 64px) + 0.75rem); +} + +/* Clip glow so transform/inset never widen the page (horizontal scrollbar). Sticky stays on .inner. */ +.home-showcase__glow-clip { + position: absolute; + inset: 0; + z-index: 0; overflow: hidden; + pointer-events: none; } .home-showcase__glow { @@ -81,101 +207,6 @@ import { hookCategoriesCatalog } from '../../data/hookCatalog' z-index: 1; } -.home-showcase__hero-badges { - position: relative; - z-index: 1; - display: flex; - flex-wrap: wrap; - align-items: stretch; - gap: 0.75rem; - margin-bottom: 1.25rem; -} - -.home-showcase__hero-badge { - display: inline-flex; - flex-direction: column; - justify-content: center; - min-width: 170px; - padding: 0.75rem 1rem; - border-radius: 14px; - border: 1px solid color-mix(in srgb, var(--vp-c-divider) 72%, transparent); - background: linear-gradient( - 140deg, - color-mix(in srgb, var(--vp-c-bg-soft) 92%, var(--vp-c-brand-1)) 0%, - color-mix(in srgb, var(--vp-c-bg-soft) 96%, var(--vp-c-brand-soft)) 100% - ); - box-shadow: 0 14px 30px -24px color-mix(in srgb, var(--vp-c-brand-1) 48%, transparent); -} - -.home-showcase__hero-badge--primary { - border-color: color-mix(in srgb, var(--vp-c-brand-1) 35%, var(--vp-c-divider)); -} - -.home-showcase__hero-value { - display: block; - font-size: clamp(1.35rem, 2.2vw, 1.8rem); - font-weight: 800; - line-height: 1.05; - letter-spacing: -0.03em; - color: var(--vp-c-text-1); -} - -.home-showcase__hero-value--small { - font-size: clamp(1rem, 1.8vw, 1.2rem); - letter-spacing: -0.01em; -} - -.home-showcase__hero-label { - margin-top: 0.2rem; - font-size: 0.78rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--vp-c-text-2); -} - -.home-showcase__eyebrow { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.35rem 0.5rem; - font-size: 0.875rem; - font-weight: 600; - letter-spacing: 0.02em; - color: var(--vp-c-brand-1); - margin: 0 0 1rem; -} - -.home-showcase__eyebrow-sep { - color: var(--vp-c-text-3); - font-weight: 400; -} - -.home-showcase__pulse { - display: inline-block; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--vp-c-brand-1); - box-shadow: 0 0 0 0 color-mix(in srgb, var(--vp-c-brand-1) 45%, transparent); - animation: home-pulse 2.4s ease-out infinite; -} - -@keyframes home-pulse { - 0% { - transform: scale(1); - box-shadow: 0 0 0 0 color-mix(in srgb, var(--vp-c-brand-1) 45%, transparent); - } - 70% { - transform: scale(1.05); - box-shadow: 0 0 0 10px transparent; - } - 100% { - transform: scale(1); - box-shadow: 0 0 0 0 transparent; - } -} - .home-showcase__title { font-size: clamp(1.65rem, 2.5vw, 2.1rem); font-weight: 700; @@ -194,7 +225,7 @@ import { hookCategoriesCatalog } from '../../data/hookCatalog' background-clip: text; } -.home-showcase__lead { +.home-showcase__intro { max-width: 52rem; margin: 0 0 2.25rem; font-size: 1.05rem; @@ -202,170 +233,175 @@ import { hookCategoriesCatalog } from '../../data/hookCatalog' color: var(--vp-c-text-2); } -.home-showcase__grid { - display: grid; - gap: 1.25rem; - grid-template-columns: 1fr; +.home-showcase__section { + margin: 0; + padding: 0; + border: none; + background: transparent; } -@media (min-width: 640px) { - .home-showcase__grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } +.home-showcase__section-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.5rem; } -@media (min-width: 1100px) { - .home-showcase__grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } +.home-showcase__toggle-sticky { + position: sticky; + top: calc(var(--vp-nav-height, 64px) + 0.5rem); + z-index: 30; + align-self: flex-start; + margin-left: auto; } -.home-card { - --stagger: 0; - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 1.35rem 1.35rem 1.15rem; - border-radius: 16px; - border: 1px solid color-mix(in srgb, var(--vp-c-divider) 80%, transparent); - background: linear-gradient( - 155deg, - color-mix(in srgb, var(--vp-c-bg-soft) 92%, var(--vp-c-brand-1)) 0%, - var(--vp-c-bg-soft) 48%, - var(--vp-c-bg-alt) 100% - ); - box-shadow: 0 18px 40px -28px color-mix(in srgb, var(--vp-c-brand-1) 35%, transparent); - opacity: 0; - transform: translate3d(0, 14px, 0); - animation: home-card-in 0.65s cubic-bezier(0.22, 1, 0.36, 1) forwards; - animation-delay: calc(0.06s + (var(--stagger) * 0.045s)); +.home-showcase__section-title { + margin: 0; + font-size: clamp(1.2rem, 1.6vw, 1.4rem); + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.2; + color: var(--vp-c-text-1); +} + +.home-showcase__toggle { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.45rem; + padding: 0.45rem 1rem 0.45rem 0.8rem; + font-size: 0.8rem; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1.25; + color: var(--vp-c-text-1); + background: color-mix(in srgb, var(--vp-c-bg-soft) 88%, var(--vp-c-bg-alt)); + border: 1px solid color-mix(in srgb, var(--vp-c-divider) 78%, transparent); + border-radius: 999px; + cursor: pointer; transition: - transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), - border-color 0.25s ease, - box-shadow 0.35s ease; + color 0.2s ease, + border-color 0.2s ease, + background 0.2s ease; } -.home-card:hover { - transform: translate3d(0, -4px, 0); - border-color: color-mix(in srgb, var(--vp-c-brand-1) 35%, var(--vp-c-divider)); - box-shadow: 0 22px 48px -22px color-mix(in srgb, var(--vp-c-brand-1) 42%, transparent); +.home-showcase__toggle-label { + line-height: 1.2; + padding: 0.02em 0 0; } -@keyframes home-card-in { - to { - opacity: 1; - transform: translate3d(0, 0, 0); - } +.home-showcase__toggle:hover { + color: var(--vp-c-brand-1); + border-color: color-mix(in srgb, var(--vp-c-brand-1) 40%, var(--vp-c-divider)); + background: color-mix(in srgb, var(--vp-c-brand-1) 8%, var(--vp-c-bg-soft)); } -.home-card__head { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 0.75rem; +.home-showcase__toggle:focus-visible { + outline: 2px solid var(--vp-c-brand-1); + outline-offset: 2px; } -.home-card__title { +.home-showcase__toggle-icon { + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 0.9rem; + height: 0.9rem; margin: 0; - font-size: 1.15rem; - font-weight: 700; - letter-spacing: -0.02em; + color: currentColor; + opacity: 0.9; } -.home-card__title-link { - color: var(--vp-c-text-1); - text-decoration: none; - transition: color 0.2s ease; +.home-showcase__toggle-svg { + display: block; + width: 100%; + height: 100%; + overflow: visible; } -.home-card__title-link:hover { - color: var(--vp-c-brand-1); +.home-showcase__section-lead { + max-width: 52rem; + margin: 0 0 1.5rem; + font-size: 0.95rem; + line-height: 1.6; + color: var(--vp-c-text-2); } -.home-card__count { - flex-shrink: 0; - font-size: 0.72rem; +.home-showcase__state-link { + display: inline; font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - padding: 0.2rem 0.55rem; - border-radius: 999px; - background: color-mix(in srgb, var(--vp-c-brand-1) 14%, transparent); color: var(--vp-c-brand-1); + text-decoration: none; + margin-left: 0.25rem; + white-space: nowrap; } -.home-card__count[data-empty='true'] { - background: color-mix(in srgb, var(--vp-c-text-3) 22%, transparent); - color: var(--vp-c-text-2); +.home-showcase__state-link:hover { + text-decoration: underline; } -.home-card__desc { - margin: 0; - font-size: 0.9rem; - line-height: 1.55; - color: var(--vp-c-text-2); - flex: 1; +/* Two demos per row from tablet up; one column on narrow viewports. */ +.home-state-demos { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; } -.home-card__hooks { - list-style: none; - margin: 0.15rem 0 0; - padding: 0; +@media (min-width: 900px) { + .home-state-demos { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.home-state-demos__item { + --stagger: 0; display: flex; - flex-wrap: wrap; - gap: 0.45rem; + flex-direction: column; + gap: 0.5rem; + min-width: 0; + opacity: 0; + transform: translate3d(0, 10px, 0); + animation: home-state-in 0.55s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation-delay: calc(0.04s + (var(--stagger) * 0.02s)); + border-radius: 16px; + outline-offset: 2px; } -.home-chip { - display: inline-flex; - align-items: center; - padding: 0.28rem 0.55rem; - border-radius: 10px; - font-size: 0.78rem; - text-decoration: none; - color: var(--vp-c-text-code); - background: color-mix(in srgb, var(--vp-c-bg-alt) 88%, var(--vp-c-brand-1)); - border: 1px solid color-mix(in srgb, var(--vp-c-divider) 70%, transparent); - transition: - background 0.2s ease, - border-color 0.2s ease, - transform 0.2s ease; +.home-state-demos__item:not(.home-state-demos__item--expanded) { + cursor: pointer; } -.home-chip:hover { - border-color: color-mix(in srgb, var(--vp-c-brand-1) 45%, var(--vp-c-divider)); - transform: translateY(-1px); +.home-state-demos__item:focus-visible { + outline: 2px solid var(--vp-c-brand-1); } -.home-chip code { - font-family: var(--vp-font-family-mono); - font-size: 0.78rem; +.home-state-demos__item :deep(.hook-live-demo) { + margin: 0; } -.home-card__empty { - margin: 0.15rem 0 0; - font-size: 0.85rem; - font-style: italic; - color: var(--vp-c-text-3); +/* Collapsed: header only; expanded card shows preview + source as usual. */ +.home-state-demos__item:not(.home-state-demos__item--expanded) :deep(.hook-live-demo__preview), +.home-state-demos__item:not(.home-state-demos__item--expanded) :deep(.hook-live-demo__source) { + display: none !important; } -.home-card__cta { - margin-top: 0.15rem; - font-size: 0.82rem; - font-weight: 600; - color: var(--vp-c-brand-1); - text-decoration: none; - align-self: flex-start; +.home-state-demos__item:not(.home-state-demos__item--expanded) :deep(.hook-live-demo__header) { + border-bottom: none; } -.home-card__cta:hover { - text-decoration: underline; +@keyframes home-state-in { + to { + opacity: 1; + transform: translate3d(0, 0, 0); + } } @media (prefers-reduced-motion: reduce) { .home-showcase__glow, - .home-showcase__pulse, - .home-card { + .home-state-demos__item { animation: none !important; } @@ -373,13 +409,9 @@ import { hookCategoriesCatalog } from '../../data/hookCatalog' opacity: 0.4; } - .home-card { + .home-state-demos__item { opacity: 1; transform: none; } - - .home-card:hover { - transform: none; - } } diff --git a/docs/.vitepress/theme/components/HookLiveDemo.vue b/docs/.vitepress/theme/components/HookLiveDemo.vue new file mode 100644 index 0000000..a972d18 --- /dev/null +++ b/docs/.vitepress/theme/components/HookLiveDemo.vue @@ -0,0 +1,241 @@ + + + diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index 10ed0e4..a1f4bde 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -1,12 +1,11 @@ import DefaultTheme from 'vitepress/theme' import { h, nextTick, onMounted, watch } from 'vue' import { useRoute } from 'vitepress' -import CookieConsentBanner from './components/CookieConsentBanner.vue' -import { applyConsentFromStorage } from './analytics' -// import Test from "./components/Note.vue"; import PackageData from './components/PackageData.vue' import HomeHookShowcase from './components/HomeHookShowcase.vue' +import HomeBottomCta from './components/HomeBottomCta.vue' import HomeHeroStats from './components/HomeHeroStats.vue' +import HookLiveDemo from './components/HookLiveDemo.vue' import './styles/tailwind.css' import './styles/styles.css' @@ -78,7 +77,6 @@ export default { Layout: () => h(DefaultTheme.Layout!, null, { 'home-hero-after': () => h(HomeHeroStats), - 'layout-bottom': () => h(CookieConsentBanner), }), setup() { const route = useRoute() @@ -99,9 +97,7 @@ export default { const { app } = ctx app.component('PackageData', PackageData) app.component('HomeHookShowcase', HomeHookShowcase) - // VitePress passes a minimal router (no vue-router isReady); defer to microtask after app boot. - if (typeof window !== 'undefined') { - queueMicrotask(() => applyConsentFromStorage()) - } + app.component('HomeBottomCta', HomeBottomCta) + app.component('HookLiveDemo', HookLiveDemo) }, } diff --git a/docs/.vitepress/theme/react-demos/useAsyncState.basic.ts b/docs/.vitepress/theme/react-demos/useAsyncState.basic.ts new file mode 100644 index 0000000..c853196 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useAsyncState.basic.ts @@ -0,0 +1,285 @@ +import React, { useCallback, useState } from 'react' +import useAsyncState from '@dedalik/use-react/useAsyncState' + +type InnerProps = { + delay: number + fail: boolean + immediate: boolean + resetOnExecute: boolean + initialState: string +} + +function AsyncStateInner({ delay, fail, immediate, resetOnExecute, initialState }: InnerProps) { + const producer = useCallback(async () => { + await new Promise((r) => setTimeout(r, delay)) + if (fail) { + throw new Error('Simulated request failure') + } + return `OK · ${new Date().toLocaleTimeString()}` + }, [delay, fail]) + + const { state, loading, error, execute } = useAsyncState(producer, { + immediate, + resetOnExecute, + initialState: initialState || undefined, + }) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useAsyncState(producer, { immediate, resetOnExecute, initialState }) - execute() runs the producer; loading / error / state are managed.', + ), + React.createElement( + 'div', + { style: { display: 'grid', gap: '0.45rem' } }, + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.875rem' } }, + 'state: ', + React.createElement('code', null, state === undefined ? 'undefined' : String(state)), + ), + React.createElement('p', { style: { margin: 0, fontSize: '0.875rem' } }, 'loading: ', String(loading)), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.875rem' } }, + 'error: ', + error == null + ? React.createElement('span', null, 'null') + : React.createElement( + 'code', + { className: 'hook-live-demo__status--error' }, + String((error as Error).message || error), + ), + ), + ), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: () => void execute() }, 'execute()'), + ), + ) +} + +function AsyncStateDemo() { + const [delay, setDelay] = useState(450) + const [fail, setFail] = useState(false) + const [immediate, setImmediate] = useState(false) + const [resetOnExecute, setResetOnExecute] = useState(false) + const [initialState, setInitialState] = useState('(initial)') + const [k, setK] = useState(0) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar-stack' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: '1fr' } }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `delay (${delay} ms)`), + React.createElement('input', { + type: 'range', + min: 0, + max: 5000, + step: 50, + value: delay, + onChange: (e) => setDelay(Number(e.target.value) || 0), + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'initial state (seed)'), + React.createElement('input', { + type: 'text', + value: initialState, + onChange: (e) => setInitialState(e.target.value), + }), + ), + React.createElement( + 'button', + { + type: 'button', + className: 'hook-demo-primary-action', + style: { marginTop: '0.15rem' }, + onClick: () => setK((c) => c + 1), + }, + 'Remount (apply settings)', + ), + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'options'), + React.createElement( + 'div', + { className: 'hook-demo-segment', role: 'group', 'aria-label': 'Async state options' }, + React.createElement( + 'button', + { + type: 'button', + className: immediate ? 'is-active' : undefined, + 'aria-pressed': immediate, + onClick: () => setImmediate((v) => !v), + }, + 'immediate', + ), + React.createElement( + 'button', + { + type: 'button', + className: resetOnExecute ? 'is-active' : undefined, + 'aria-pressed': resetOnExecute, + onClick: () => setResetOnExecute((v) => !v), + }, + 'resetOnExecute', + ), + React.createElement( + 'button', + { + type: 'button', + className: fail ? 'is-active' : undefined, + 'aria-pressed': fail, + onClick: () => setFail((v) => !v), + }, + 'force error', + ), + ), + ), + ), + ), + React.createElement(AsyncStateInner, { + key: k, + delay, + fail, + immediate, + resetOnExecute, + initialState, + }), + ) +} + +export const sourceJsx = `import { useCallback, useState } from 'react' +import useAsyncState from '@dedalik/use-react/useAsyncState' + +function AsyncStateInner({ delay, fail, immediate, resetOnExecute, initialState }) { + const producer = useCallback(async () => { + await new Promise((r) => setTimeout(r, delay)) + if (fail) throw new Error('Simulated request failure') + return \`OK · \${new Date().toLocaleTimeString()}\` + }, [delay, fail]) + + const { state, loading, error, execute } = useAsyncState(producer, { + immediate, + resetOnExecute, + initialState: initialState || undefined, + }) + + return ( +
+

execute() starts producer and updates loading/error/state.

+

state: {String(state)}

+

loading: {String(loading)}

+

+ error:{' '} + {error == null + ? 'null' + : {String(error?.message ?? error)}} +

+ +
+ ) +} + +export default function AsyncStateDemo() { + const [delay, setDelay] = useState(450) + const [fail, setFail] = useState(false) + const [immediate, setImmediate] = useState(false) + const [resetOnExecute, setResetOnExecute] = useState(false) + const [initialState, setInitialState] = useState('(initial)') + const [k, setK] = useState(0) + + return ( +
+
+
+
+

{'delay (' + delay + ' ms)'}

+ setDelay(Number(e.target.value) || 0)} + /> +
+
+

initial state (seed)

+ setInitialState(e.target.value)} /> +
+ +
+ +
+
+

options

+
+ + + +
+
+
+
+ +
+ ) +}` + +export default AsyncStateDemo diff --git a/docs/.vitepress/theme/react-demos/useCounter.basic.ts b/docs/.vitepress/theme/react-demos/useCounter.basic.ts new file mode 100644 index 0000000..c3dfb69 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useCounter.basic.ts @@ -0,0 +1,286 @@ +import React, { useRef, useState } from 'react' +import useCounter from '@dedalik/use-react/useCounter' + +function clamp(n: number, min: number, max: number) { + return Math.min(max, Math.max(min, n)) +} + +function CounterCore({ initial, min, max }: { initial: number; min: number; max: number }) { + const { count, inc, dec, set, reset } = useCounter(initial, { min, max }) + const [target, setTarget] = useState(String(initial)) + const stepRef = useRef(null) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useCounter(initial, { min, max }) - every update is clamped by min/max. Configure all numeric options with draggable sliders above, then remount to apply.', + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.9rem' } }, + 'count: ', + React.createElement('strong', null, String(count)), + ), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem', alignItems: 'center' } }, + React.createElement('button', { type: 'button', onClick: () => dec(1) }, 'dec(1)'), + React.createElement('button', { type: 'button', onClick: () => inc(1) }, 'inc(1)'), + React.createElement( + 'label', + { className: 'hook-demo-row' }, + 'Step ', + React.createElement('input', { + ref: stepRef, + type: 'number', + min: 1, + defaultValue: 3, + }), + React.createElement( + 'button', + { + type: 'button', + onClick: () => { + const s = stepRef.current ? Math.max(1, Number(stepRef.current.value) || 1) : 1 + dec(s) + }, + }, + 'dec(step)', + ), + React.createElement( + 'button', + { + type: 'button', + onClick: () => { + const s = stepRef.current ? Math.max(1, Number(stepRef.current.value) || 1) : 1 + inc(s) + }, + }, + 'inc(step)', + ), + ), + ), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem', alignItems: 'center' } }, + React.createElement('input', { + type: 'number', + value: target, + onChange: (e) => setTarget(e.target.value), + 'aria-label': 'set to value', + }), + React.createElement( + 'button', + { + type: 'button', + onClick: () => { + const n = Number(target) + if (Number.isFinite(n)) set(n) + }, + }, + 'set(value)', + ), + React.createElement('button', { type: 'button', onClick: () => void reset() }, 'reset()'), + ), + ) +} + +function CounterDemo() { + const [initial, setInitial] = useState(5) + const [min, setMin] = useState(0) + const [max, setMax] = useState(20) + const [k, setK] = useState(0) + + const initialSafe = Math.min(Math.max(initial, min), max) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar-stack' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar hook-demo-toolbar--even-3' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'initial'), + React.createElement('p', { style: { margin: 0, fontSize: '0.85rem' } }, String(initialSafe)), + React.createElement('input', { + type: 'range', + min, + max, + value: initialSafe, + onChange: (e) => setInitial(clamp(Number(e.target.value), min, max)), + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'min'), + React.createElement('p', { style: { margin: 0, fontSize: '0.85rem' } }, String(min)), + React.createElement('input', { + type: 'range', + min: -20, + max: max - 1, + value: Math.min(min, max - 1), + onChange: (e) => { + const nextMin = Math.min(Number(e.target.value), max - 1) + setMin(nextMin) + setInitial((prev) => clamp(prev, nextMin, max)) + }, + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'max'), + React.createElement('p', { style: { margin: 0, fontSize: '0.85rem' } }, String(max)), + React.createElement('input', { + type: 'range', + min: min + 1, + max: 60, + value: Math.max(max, min + 1), + onChange: (e) => { + const nextMax = Math.max(Number(e.target.value), min + 1) + setMax(nextMax) + setInitial((prev) => clamp(prev, min, nextMax)) + }, + }), + ), + ), + React.createElement( + 'button', + { + type: 'button', + className: 'hook-demo-primary-action', + onClick: () => setK((c) => c + 1), + }, + 'Apply & remount', + ), + ), + React.createElement(CounterCore, { key: k, initial: initialSafe, min, max }), + ) +} + +export const sourceJsx = `import { useRef, useState } from 'react' +import useCounter from '@dedalik/use-react/useCounter' + +function clamp(n, min, max) { + return Math.min(max, Math.max(min, n)) +} + +function CounterCore({ initial, min, max }) { + const { count, inc, dec, set, reset } = useCounter(initial, { min, max }) + const [target, setTarget] = useState(String(count)) + const stepRef = useRef(null) + + return ( +
+

+ useCounter(initial, { min, max }) - clamped numeric updates. Sliders above let you tweak + bounds and initial quickly. +

+

+ count: {count} +

+
+ + + + + +
+
+ setTarget(e.target.value)} + /> + + +
+
+ ) +} + +export default function CounterDemo() { + const [initial, setInitial] = useState(5) + const [min, setMin] = useState(0) + const [max, setMax] = useState(20) + const [k, setK] = useState(0) + const initialSafe = Math.min(Math.max(initial, min), max) + return ( +
+
+
+
+

initial

+

{initialSafe}

+ setInitial(clamp(Number(e.target.value), min, max))} + /> +
+
+

min

+

{min}

+ { + const nextMin = Math.min(Number(e.target.value), max - 1) + setMin(nextMin) + setInitial((prev) => clamp(prev, nextMin, max)) + }} + /> +
+
+

max

+

{max}

+ { + const nextMax = Math.max(Number(e.target.value), min + 1) + setMax(nextMax) + setInitial((prev) => clamp(prev, min, nextMax)) + }} + /> +
+
+ +
+ +
+ ) +}` + +export default CounterDemo diff --git a/docs/.vitepress/theme/react-demos/useDebounce.basic.ts b/docs/.vitepress/theme/react-demos/useDebounce.basic.ts new file mode 100644 index 0000000..976459e --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useDebounce.basic.ts @@ -0,0 +1,93 @@ +import React, { useState } from 'react' +import useDebounce from '@dedalik/use-react/useDebounce' + +function DebounceDemo() { + const [query, setQuery] = useState('') + const [delay, setDelay] = useState(400) + const debounced = useDebounce(query, delay) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useDebounce(value, delay) - updates the returned value only after `delay` ms without changes (leading edge not emitted). Default delay: 500.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field', style: { gridColumn: '1 / -1' } }, + React.createElement('p', { className: 'hook-demo-label' }, `delay (${delay} ms)`), + React.createElement('input', { + type: 'range', + min: 0, + max: 2000, + step: 50, + value: delay, + onChange: (e) => setDelay(Number(e.target.value)), + }), + ), + ), + React.createElement('input', { + type: 'search', + value: query, + placeholder: 'Type quickly - live updates at once, debounced value lags…', + onChange: (e) => setQuery(e.target.value), + }), + React.createElement('p', { style: { margin: 0 } }, 'Live: ', React.createElement('strong', null, query || '-')), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'Debounced: ', + React.createElement('strong', null, debounced || '-'), + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useDebounce from '@dedalik/use-react/useDebounce' + +export default function DebounceDemo() { + const [query, setQuery] = useState('') + const [delay, setDelay] = useState(400) + const debounced = useDebounce(query, delay) + + return ( +
+

+ useDebounce(value, delay) - the returned value updates only after the delay with no further + changes. Default delay: 500. +

+
+
+

{'delay (' + delay + ' ms)'}

+ setDelay(Number(e.target.value))} + /> +
+
+ setQuery(e.target.value)} + /> +

+ Live: {query || '-'} +

+

+ Debounced: {debounced || '-'} +

+
+ ) +}` + +export default DebounceDemo diff --git a/docs/.vitepress/theme/react-demos/useDebouncedRefHistory.basic.ts b/docs/.vitepress/theme/react-demos/useDebouncedRefHistory.basic.ts new file mode 100644 index 0000000..4bc9e3b --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useDebouncedRefHistory.basic.ts @@ -0,0 +1,116 @@ +import React, { useState } from 'react' +import useDebouncedRefHistory from '@dedalik/use-react/useDebouncedRefHistory' + +function DebouncedRefHistoryDemo() { + const [delay, setDelay] = useState(500) + const [capacity, setCapacity] = useState(10) + const { value, set, undo, redo, clear, canUndo, canRedo, history, pointer } = useDebouncedRefHistory('Type here', { + delay, + capacity, + }) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useDebouncedRefHistory(initial, { delay, capacity }) - after you pause typing for `delay` ms, a snapshot is recorded. undo/redo restore into `value`; clear() collapses to the current pointer.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `delay (${delay} ms)`), + React.createElement('input', { + type: 'range', + min: 0, + max: 2000, + step: 50, + value: delay, + onChange: (e) => setDelay(Number(e.target.value)), + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `capacity (${capacity})`), + React.createElement('input', { + type: 'range', + min: 1, + max: 20, + value: capacity, + onChange: (e) => setCapacity(Number(e.target.value) || 1), + }), + ), + ), + React.createElement('input', { type: 'text', value, onChange: (e) => set(e.target.value) }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: undo, disabled: !canUndo }, 'undo()'), + React.createElement('button', { type: 'button', onClick: redo, disabled: !canRedo }, 'redo()'), + React.createElement('button', { type: 'button', onClick: () => void clear() }, 'clear()'), + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.85rem' } }, + 'pointer: ', + pointer, + ' | snapshots: ', + history.length, + ), + React.createElement('p', { className: 'hook-demo-mono' }, JSON.stringify(history, null, 0)), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useDebouncedRefHistory from '@dedalik/use-react/useDebouncedRefHistory' + +export default function DebouncedRefHistoryDemo() { + const [delay, setDelay] = useState(500) + const [capacity, setCapacity] = useState(10) + const { value, set, undo, redo, clear, canUndo, canRedo, history, pointer } = useDebouncedRefHistory( + 'Type here', + { delay, capacity }, + ) + return ( +
+

Debounced snapshot recording + capacity cap.

+
+
+

{'delay (' + delay + ' ms)'}

+ setDelay(Number(e.target.value))} + /> +
+
+

{'capacity (' + capacity + ')'}

+ setCapacity(Number(e.target.value) || 1)} + /> +
+
+ set(e.target.value)} /> +
+ + + +
+

{JSON.stringify(history)}

+
+ ) +}` + +export default DebouncedRefHistoryDemo diff --git a/docs/.vitepress/theme/react-demos/useEventCallback.basic.ts b/docs/.vitepress/theme/react-demos/useEventCallback.basic.ts new file mode 100644 index 0000000..98b814e --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useEventCallback.basic.ts @@ -0,0 +1,43 @@ +import React, { useState } from 'react' +import useEventCallback from '@dedalik/use-react/useEventCallback' +function EventCallbackDemo() { + const [step, setStep] = useState(1) + const [count, setCount] = useState(0) + const increment = useEventCallback(() => setCount((c) => c + step)) + + return React.createElement( + 'div', + { style: { display: 'grid', gap: '0.75rem' } }, + React.createElement('input', { + type: 'number', + value: step, + min: 1, + onChange: (e) => setStep(Number(e.target.value || 1)), + }), + React.createElement('button', { type: 'button', onClick: increment }, `Increment by ${step}`), + React.createElement('p', { style: { margin: 0 } }, `Count: ${count}`), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useEventCallback from '@dedalik/use-react/useEventCallback' +export default function EventCallbackDemo() { + const [step, setStep] = useState(1) + const [count, setCount] = useState(0) + const increment = useEventCallback(() => setCount((c) => c + step)) + + return ( +
+ setStep(Number(e.target.value || 1))} + /> + +

Count: {count}

+
+ ) +}` + +export default EventCallbackDemo diff --git a/docs/.vitepress/theme/react-demos/useLastChanged.basic.ts b/docs/.vitepress/theme/react-demos/useLastChanged.basic.ts new file mode 100644 index 0000000..57db0d4 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useLastChanged.basic.ts @@ -0,0 +1,133 @@ +import React, { useState } from 'react' +import useLastChanged from '@dedalik/use-react/useLastChanged' + +function LastChangedDemo() { + const [mode, setMode] = useState<'string' | 'object'>('string') + const [str, setStr] = useState('alpha') + const [obj, setObj] = useState({ id: 1 }) + const value = mode === 'string' ? str : obj + const lastChanged = useLastChanged(value) + const timeStr = new Date(lastChanged).toLocaleTimeString() + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useLastChanged(value) - updates a timestamp when `value` is not `Object.is` to the previous one. Same object reference (no re-render with new value) will not update.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'value kind'), + React.createElement( + 'div', + { className: 'hook-demo-segment', role: 'group', 'aria-label': 'Value kind' }, + React.createElement( + 'button', + { + type: 'button', + className: mode === 'string' ? 'is-active' : undefined, + 'aria-pressed': mode === 'string', + onClick: () => setMode('string'), + }, + 'string (primitive)', + ), + React.createElement( + 'button', + { + type: 'button', + className: mode === 'object' ? 'is-active' : undefined, + 'aria-pressed': mode === 'object', + onClick: () => setMode('object'), + }, + 'object (new identity on bump)', + ), + ), + ), + ), + mode === 'string' + ? React.createElement('input', { type: 'text', value: str, onChange: (e) => setStr(e.target.value) }) + : React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' } }, + React.createElement( + 'button', + { type: 'button', onClick: () => setObj((o) => ({ id: o.id + 1 })) }, + 'new object (id+1)', + ), + React.createElement('code', { style: { padding: '0.35rem 0.5rem' } }, JSON.stringify(obj)), + ), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'Last change at: ', + React.createElement('strong', null, timeStr), + ' (', + String(lastChanged), + ' ms)', + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useLastChanged from '@dedalik/use-react/useLastChanged' + +export default function LastChangedDemo() { + const [mode, setMode] = useState('string') + const [str, setStr] = useState('alpha') + const [obj, setObj] = useState({ id: 1 }) + const value = mode === 'string' ? str : obj + const lastChanged = useLastChanged(value) + const timeStr = new Date(lastChanged).toLocaleTimeString() + + return ( +
+

+ useLastChanged(value) - timestamp updates when the value is not Object.is to the previous one. +

+
+
+

value kind

+
+ + +
+
+
+ {mode === 'string' ? ( + setStr(e.target.value)} /> + ) : ( +
+ + {JSON.stringify(obj)} +
+ )} +

+ Last change at: {timeStr} ({String(lastChanged)} ms) +

+
+ ) +}` + +export default LastChangedDemo diff --git a/docs/.vitepress/theme/react-demos/useLatest.basic.ts b/docs/.vitepress/theme/react-demos/useLatest.basic.ts new file mode 100644 index 0000000..13691f8 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useLatest.basic.ts @@ -0,0 +1,121 @@ +import React, { useEffect, useState } from 'react' +import useLatest from '@dedalik/use-react/useLatest' + +function LatestDemo() { + const [count, setCount] = useState(0) + const [intervalMs, setIntervalMs] = useState(500) + const latest = useLatest(count) + const [fromClosure, setFromClosure] = useState(0) + const [fromRef, setFromRef] = useState(0) + + // Deliberately omit `count` from deps: the interval keeps a stale `count` from + // when the effect was last created; `latest.current` still tracks the live value. + useEffect(() => { + const id = window.setInterval(() => { + setFromClosure(count) + setFromRef(latest.current) + }, intervalMs) + return () => window.clearInterval(id) + }, [intervalMs, latest]) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + "useLatest(value) - a stable ref with .current always equal to the latest value. The effect does not list `count` in deps, so the callback's `count` is stale; `latest.current` is not.", + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field', style: { gridColumn: '1 / -1' } }, + React.createElement('p', { className: 'hook-demo-label' }, `poll every ${intervalMs} ms`), + React.createElement('input', { + type: 'range', + min: 200, + max: 2000, + step: 100, + value: intervalMs, + onChange: (e) => setIntervalMs(Number(e.target.value)), + }), + ), + ), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: () => setCount((c) => c - 1) }, 'count −1'), + React.createElement('button', { type: 'button', onClick: () => setCount((c) => c + 1) }, 'count +1'), + ), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'count (state): ', + React.createElement('strong', null, String(count)), + ), + React.createElement( + 'p', + { className: 'hook-demo-mono' }, + 'Interval snapshot - stale `count` in closure: ' + + String(fromClosure) + + ' | `latest.current`: ' + + String(fromRef), + ), + ) +} + +export const sourceJsx = `import { useEffect, useState } from 'react' +import useLatest from '@dedalik/use-react/useLatest' + +export default function LatestDemo() { + const [count, setCount] = useState(0) + const [intervalMs, setIntervalMs] = useState(500) + const latest = useLatest(count) + const [fromClosure, setFromClosure] = useState(0) + const [fromRef, setFromRef] = useState(0) + + // Omit \`count\` from deps so the interval closure keeps a stale \`count\`. + useEffect(() => { + const id = setInterval(() => { + setFromClosure(count) + setFromRef(latest.current) + }, intervalMs) + return () => clearInterval(id) + }, [intervalMs, latest]) + + return ( +
+

+ The effect omits count from the dependency list so the callback sees a stale + count, while latest.current always matches the latest render. +

+
+
+

{'poll every ' + intervalMs + ' ms'}

+ setIntervalMs(Number(e.target.value))} + /> +
+
+
+ + +
+

+ count (state): {count} +

+

+ {\`captured: count = \${fromClosure} | latest.current = \${fromRef}\`} +

+
+ ) +}` + +export default LatestDemo diff --git a/docs/.vitepress/theme/react-demos/useManualRefHistory.basic.ts b/docs/.vitepress/theme/react-demos/useManualRefHistory.basic.ts new file mode 100644 index 0000000..46d71e7 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useManualRefHistory.basic.ts @@ -0,0 +1,99 @@ +import React, { useState } from 'react' +import useManualRefHistory from '@dedalik/use-react/useManualRefHistory' + +function ManualRefHistoryDemo() { + const [capacity, setCapacity] = useState(8) + const { value, set, commit, undo, redo, clear, canUndo, canRedo, history, pointer } = useManualRefHistory('Draft', { + capacity, + }) + const clearAll = () => { + set('') + // Defer to next tick so `clear()` uses the updated value. + window.setTimeout(() => clear(), 0) + } + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useManualRefHistory(initial, { capacity }) - edits update `value` but history updates only on commit(). clear() now empties input and collapses history.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field', style: { gridColumn: '1 / -1' } }, + React.createElement('p', { className: 'hook-demo-label' }, `capacity (${capacity})`), + React.createElement('input', { + type: 'range', + min: 1, + max: 20, + value: capacity, + onChange: (e) => setCapacity(Number(e.target.value) || 1), + }), + ), + ), + React.createElement('input', { type: 'text', value, onChange: (e) => set(e.target.value) }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: () => void commit() }, 'commit()'), + React.createElement('button', { type: 'button', onClick: undo, disabled: !canUndo }, 'undo()'), + React.createElement('button', { type: 'button', onClick: redo, disabled: !canRedo }, 'redo()'), + React.createElement('button', { type: 'button', onClick: clearAll }, 'clear()'), + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.85rem' } }, + 'pointer: ', + pointer, + ' | snapshots: ', + history.length, + ), + React.createElement('p', { className: 'hook-demo-mono' }, JSON.stringify(history, null, 0)), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useManualRefHistory from '@dedalik/use-react/useManualRefHistory' + +export default function ManualRefHistoryDemo() { + const [capacity, setCapacity] = useState(8) + const { value, set, commit, undo, redo, clear, canUndo, canRedo, history, pointer } = + useManualRefHistory('Draft', { capacity }) + const clearAll = () => { + set('') + setTimeout(() => clear(), 0) + } + return ( +
+

commit() records; undo/redo; clear() now empties input and collapses history.

+
+
+

{'capacity (' + capacity + ')'}

+ setCapacity(Number(e.target.value) || 1)} + /> +
+
+ set(e.target.value)} /> +
+ + + + +
+

pointer {pointer}

+

{JSON.stringify(history)}

+
+ ) +}` + +export default ManualRefHistoryDemo diff --git a/docs/.vitepress/theme/react-demos/useOnMount.basic.ts b/docs/.vitepress/theme/react-demos/useOnMount.basic.ts new file mode 100644 index 0000000..3458344 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useOnMount.basic.ts @@ -0,0 +1,35 @@ +import React, { useCallback, useState } from 'react' +import useOnMount from '@dedalik/use-react/useOnMount' +function OnMountDemo() { + const [count, setCount] = useState(0) + useOnMount( + useCallback(() => { + setCount((c) => c + 1) + }, []), + ) + + return React.createElement( + 'div', + { style: { display: 'grid', gap: '0.75rem' } }, + React.createElement('p', { style: { margin: 0 } }, `Mount callback runs: ${count}`), + ) +} + +export const sourceJsx = `import { useState, useCallback } from 'react' +import useOnMount from '@dedalik/use-react/useOnMount' +export default function OnMountDemo() { + const [count, setCount] = useState(0) + useOnMount( + useCallback(() => { + setCount((c) => c + 1) + }, []), + ) + + return ( +
+

Mount callback runs: {count}

+
+ ) +}` + +export default OnMountDemo diff --git a/docs/.vitepress/theme/react-demos/usePrevious.basic.ts b/docs/.vitepress/theme/react-demos/usePrevious.basic.ts new file mode 100644 index 0000000..7619319 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/usePrevious.basic.ts @@ -0,0 +1,139 @@ +import React, { useState } from 'react' +import usePrevious from '@dedalik/use-react/usePrevious' + +function PreviousDemo() { + const [mode, setMode] = useState<'number' | 'text'>('number') + const [count, setCount] = useState(0) + const [text, setText] = useState('v1') + const current = mode === 'number' ? count : text + const previous = usePrevious(current) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'usePrevious(value) - returns the value from the previous render (first render: undefined). The hook only tracks the last value, not an arbitrary history.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'tracked value'), + React.createElement( + 'div', + { className: 'hook-demo-segment', role: 'group', 'aria-label': 'Tracked value type' }, + React.createElement( + 'button', + { + type: 'button', + className: mode === 'number' ? 'is-active' : undefined, + 'aria-pressed': mode === 'number', + onClick: () => setMode('number'), + }, + 'number (steppers)', + ), + React.createElement( + 'button', + { + type: 'button', + className: mode === 'text' ? 'is-active' : undefined, + 'aria-pressed': mode === 'text', + onClick: () => setMode('text'), + }, + 'string (input)', + ), + ), + ), + ), + mode === 'number' + ? React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.5rem' } }, + React.createElement('button', { type: 'button', onClick: () => setCount((c) => c - 1) }, '−1'), + React.createElement('button', { type: 'button', onClick: () => setCount((c) => c + 1) }, '+1'), + ) + : React.createElement('input', { + type: 'text', + value: text, + onChange: (e) => setText(e.target.value), + }), + React.createElement( + 'div', + { style: { display: 'grid', gap: '0.35rem' } }, + React.createElement( + 'p', + { style: { margin: 0 } }, + 'Current: ', + React.createElement('code', null, String(current)), + ), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'Previous: ', + React.createElement('code', null, previous === undefined ? 'undefined' : String(previous)), + ), + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import usePrevious from '@dedalik/use-react/usePrevious' + +export default function PreviousDemo() { + const [mode, setMode] = useState('number') + const [count, setCount] = useState(0) + const [text, setText] = useState('v1') + const current = mode === 'number' ? count : text + const previous = usePrevious(current) + + return ( +
+

+ usePrevious(value) - value from the previous render (first render: undefined). +

+
+
+

tracked value

+
+ + +
+
+
+ {mode === 'number' ? ( +
+ + +
+ ) : ( + setText(e.target.value)} /> + )} +

+ Current: {String(current)} +

+

+ Previous: {previous === undefined ? 'undefined' : String(previous)} +

+
+ ) +}` + +export default PreviousDemo diff --git a/docs/.vitepress/theme/react-demos/useRefHistory.basic.ts b/docs/.vitepress/theme/react-demos/useRefHistory.basic.ts new file mode 100644 index 0000000..e87a9d0 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useRefHistory.basic.ts @@ -0,0 +1,108 @@ +import React, { useState } from 'react' +import useRefHistory from '@dedalik/use-react/useRefHistory' + +function RefHistoryDemo() { + const [capacity, setCapacity] = useState(6) + const { value, set, undo, redo, clear, canUndo, canRedo, pointer, history } = useRefHistory('Draft', { capacity }) + const clearAll = () => { + set('') + // Let state apply first, then collapse history to the empty snapshot. + window.setTimeout(() => clear(), 0) + } + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useRefHistory(initial, { capacity }) - every set() appends a snapshot; capacity trims the oldest entries. undo / redo move the pointer; clear() empties input and collapses history.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field', style: { gridColumn: '1 / -1' } }, + React.createElement('p', { className: 'hook-demo-label' }, `capacity (${capacity})`), + React.createElement('input', { + type: 'range', + min: 1, + max: 20, + value: capacity, + onChange: (e) => setCapacity(Number(e.target.value) || 1), + }), + ), + ), + React.createElement('input', { type: 'text', value, onChange: (e) => set(e.target.value) }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: undo, disabled: !canUndo }, 'undo()'), + React.createElement('button', { type: 'button', onClick: redo, disabled: !canRedo }, 'redo()'), + React.createElement('button', { type: 'button', onClick: clearAll }, 'clear()'), + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.85rem' } }, + 'pointer: ', + pointer, + ' | length: ', + history.length, + ' | canUndo: ', + String(canUndo), + ' | canRedo: ', + String(canRedo), + ), + React.createElement('p', { className: 'hook-demo-mono' }, JSON.stringify(history, null, 0)), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useRefHistory from '@dedalik/use-react/useRefHistory' + +export default function RefHistoryDemo() { + const [capacity, setCapacity] = useState(6) + const { value, set, undo, redo, clear, canUndo, canRedo, pointer, history } = useRefHistory( + 'Draft', + { capacity }, + ) + const clearAll = () => { + set('') + setTimeout(() => clear(), 0) + } + + return ( +
+

+ useRefHistory(initial, { capacity }) - snapshots on each set; clear() empties input and collapses history. +

+
+
+

{'capacity (' + capacity + ')'}

+ setCapacity(Number(e.target.value) || 1)} + /> +
+
+ set(e.target.value)} /> +
+ + + +
+

pointer {pointer}

+

{JSON.stringify(history)}

+
+ ) +}` + +export default RefHistoryDemo diff --git a/docs/.vitepress/theme/react-demos/useStorage.basic.ts b/docs/.vitepress/theme/react-demos/useStorage.basic.ts new file mode 100644 index 0000000..13542ae --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useStorage.basic.ts @@ -0,0 +1,290 @@ +import React, { useEffect, useState } from 'react' +import useStorage from '@dedalik/use-react/useStorage' + +const PREFIX = 'ur-docs.state-demo.' +/** Old default slot (pre–two-step Apply demo). Cleared on mount; do not use the same suffix as the current default `key` or we wipe the live row. */ +const LEGACY_DEFAULT_FULL_KEY = `${PREFIX}prefs` +/** Default `key` suffix is not `prefs` so it is distinct from `LEGACY_DEFAULT_FULL_KEY`. */ +const DEFAULT_KEY_SUFFIX = 'demo' + +function StorageInner({ + storageKey, + useSession, + initial, +}: { + storageKey: string + useSession: boolean + initial: string +}) { + const storage = useSession ? window.sessionStorage : window.localStorage + const fullKey = PREFIX + storageKey + const [value, set] = useStorage(fullKey, initial, { storage }) + const safeValue = typeof value === 'string' ? value : value == null ? '' : String(value) + /* do not use getItem() here: persist runs in useStorage’s effect, so a render with value already + * updated would still read the previous `getItem` with no re-render. Show the default serializer + * output, same as the hook. */ + const rawLine = JSON.stringify(value) as string + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + "useStorage(key, initial, { serializer, parser, storage }) - default JSON.stringify / JSON.parse; storage defaults to localStorage. set('') stores an empty string (key stays).", + ), + React.createElement( + 'div', + { style: { display: 'grid', gap: '0.45rem' } }, + React.createElement('p', { style: { margin: 0 } }, 'Hook value: ', React.createElement('code', null, safeValue)), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.8rem' } }, + 'Storage key: ', + React.createElement('code', null, fullKey), + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.8rem' } }, + 'Serialized (default JSON — same as written to storage):', + ), + React.createElement('p', { className: 'hook-demo-mono' }, rawLine), + ), + React.createElement('input', { type: 'text', value: safeValue, onChange: (e) => set(e.target.value) }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: () => set('') }, "set('') - clear value"), + ), + ) +} + +type Applied = { useSession: boolean; key: string; initial: string } + +function StorageDemo() { + const [useSession, setUseSession] = useState(false) + const [key, setKey] = useState(DEFAULT_KEY_SUFFIX) + const [initial, setInitial] = useState('hello') + const [applied, setApplied] = useState({ + useSession: false, + key: DEFAULT_KEY_SUFFIX, + initial: 'hello', + }) + const [k, setK] = useState(0) + /** Do not mount `useStorage` until the user applies once — avoids writing on first page load. */ + const [liveStarted, setLiveStarted] = useState(false) + + useEffect(() => { + if (typeof window === 'undefined') return + try { + window.localStorage.removeItem(LEGACY_DEFAULT_FULL_KEY) + } catch { + /* ignore */ + } + try { + window.sessionStorage.removeItem(LEGACY_DEFAULT_FULL_KEY) + } catch { + /* ignore */ + } + }, []) + + const apply = () => { + /* see sourceJsx copy: clear slot before remount so `initial` wins over stale storage */ + if (typeof window !== 'undefined') { + const st = useSession ? window.sessionStorage : window.localStorage + const full = PREFIX + key + try { + st.removeItem(full) + } catch { + /* ignore */ + } + } + setApplied({ useSession, key, initial }) + setK((c) => c + 1) + setLiveStarted(true) + } + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar-stack' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar hook-demo-toolbar--even-3' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'storage'), + React.createElement( + 'div', + { className: 'hook-demo-segment', role: 'group', 'aria-label': 'Storage backend' }, + React.createElement( + 'button', + { + type: 'button', + className: useSession ? undefined : 'is-active', + 'aria-pressed': !useSession, + onClick: () => setUseSession(false), + }, + 'localStorage', + ), + React.createElement( + 'button', + { + type: 'button', + className: useSession ? 'is-active' : undefined, + 'aria-pressed': useSession, + onClick: () => setUseSession(true), + }, + 'sessionStorage', + ), + ), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'key suffix'), + React.createElement('input', { type: 'text', value: key, onChange: (e) => setKey(e.target.value) }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'initial (on remount)'), + React.createElement('input', { type: 'text', value: initial, onChange: (e) => setInitial(e.target.value) }), + ), + ), + React.createElement( + 'button', + { + type: 'button', + className: 'hook-demo-primary-action', + onClick: apply, + }, + 'Apply / remount', + ), + React.createElement( + 'p', + { className: 'hook-demo-hint', style: { margin: 0, fontSize: '0.78rem' } }, + 'Each “Apply / remount” clears the current storage key, then remounts so the Initial (on remount) value is used. Nothing is written until the first Apply.', + ), + ), + liveStarted + ? React.createElement(StorageInner, { + key: k, + storageKey: applied.key, + useSession: applied.useSession, + initial: applied.initial, + }) + : React.createElement( + 'p', + { + className: 'hook-demo-hint', + style: { margin: '0.75rem 0 0', fontSize: '0.9rem' }, + }, + 'Click “Apply / remount” to start the live demo and allow reads/writes under the chosen key.', + ), + ) +} + +export const sourceJsx = `import { useEffect, useState } from 'react' +import useStorage from '@dedalik/use-react/useStorage' + +const PREFIX = 'ur-docs.state-demo.' +const LEGACY_DEFAULT_FULL_KEY = 'ur-docs.state-demo.prefs' +const DEFAULT_KEY_SUFFIX = 'demo' + +function StorageInner({ storageKey, useSession, initial }) { + const storage = useSession ? sessionStorage : localStorage + const fullKey = PREFIX + storageKey + const [value, set] = useStorage(fullKey, initial, { storage }) + const safeValue = typeof value === 'string' ? value : value == null ? '' : String(value) + const rawLine = JSON.stringify(value) + return ( +
+

useStorage(key, initial, {'{'} storage {'}'}) - JSON.stringify / JSON.parse; set('') keeps the key with an empty string.

+

Value: {safeValue}

+

Serialized (default JSON — same as written to storage):

+

{rawLine}

+ set(e.target.value)} /> + +
+ ) +} + +export default function StorageDemo() { + const [useSession, setUseSession] = useState(false) + const [key, setKey] = useState(DEFAULT_KEY_SUFFIX) + const [initial, setInitial] = useState('hello') + const [applied, setApplied] = useState({ useSession: false, key: DEFAULT_KEY_SUFFIX, initial: 'hello' }) + const [k, setK] = useState(0) + const [liveStarted, setLiveStarted] = useState(false) + + useEffect(() => { + try { localStorage.removeItem(LEGACY_DEFAULT_FULL_KEY) } catch { /* ignore */ } + try { sessionStorage.removeItem(LEGACY_DEFAULT_FULL_KEY) } catch { /* ignore */ } + }, []) + + const apply = () => { + try { + const st = useSession ? sessionStorage : localStorage + st.removeItem(PREFIX + key) + } catch { /* ignore */ } + setApplied({ useSession, key, initial }) + setK((c) => c + 1) + setLiveStarted(true) + } + return ( +
+
+
+
+

storage

+
+ + +
+
+
+

key

+ setKey(e.target.value)} /> +
+
+

initial (remount)

+ setInitial(e.target.value)} /> +
+
+ +

+ Each "Apply / remount" clears the current key, then remounts so Initial (on remount) wins. Nothing is written until the first Apply. +

+
+ {liveStarted ? ( + + ) : ( +

+ Click "Apply / remount" to start the live demo and allow reads/writes under the chosen key. +

+ )} +
+ ) +}` + +export default StorageDemo diff --git a/docs/.vitepress/theme/react-demos/useStorageAsync.basic.ts b/docs/.vitepress/theme/react-demos/useStorageAsync.basic.ts new file mode 100644 index 0000000..756220f --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useStorageAsync.basic.ts @@ -0,0 +1,230 @@ +import React, { useMemo, useRef, useState } from 'react' +import useStorageAsync from '@dedalik/use-react/useStorageAsync' + +function createLatentMemoryStorage(latencyRef: { current: number }) { + const data = new Map() + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + return { + getItem: async (key: string) => { + await sleep(latencyRef.current) + return data.get(key) ?? null + }, + setItem: async (key: string, value: string) => { + await sleep(latencyRef.current) + data.set(key, value) + }, + removeItem: async (key: string) => { + await sleep(latencyRef.current) + data.delete(key) + }, + /** Sync delete so the next mount’s getItem sees `null` and matches `initial` (avoids hello → '' flash after set('') + remount). */ + clearKeySync(key: string) { + data.delete(key) + }, + } +} + +function StorageAsyncInner({ + storageKey, + initial, + storage, +}: { + storageKey: string + initial: string + storage: ReturnType +}) { + const [value, loading, set] = useStorageAsync(storage, storageKey, initial, { + serializer: JSON.stringify, + parser: JSON.parse, + }) + const safeValue = typeof value === 'string' ? value : value == null ? '' : String(value) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useStorageAsync(storage, key, initial, { serializer, parser }) - async load mirrors localStorage: first paint uses `initial`, then stored value arrives. Apply / remount clears the in-memory key first so load matches `initial` (no flash).', + ), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'loading: ', + React.createElement('strong', null, String(loading)), + ), + React.createElement('p', { style: { margin: 0 } }, 'value: ', React.createElement('code', null, safeValue)), + React.createElement('input', { + type: 'text', + value: safeValue, + disabled: loading, + onChange: (e) => void set(e.target.value), + }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement( + 'button', + { type: 'button', disabled: loading, onClick: () => void set('') }, + "set('') - clear value", + ), + ), + ) +} + +function StorageAsyncDemo() { + const [key, setKey] = useState('demo-item') + const [initial, setInitial] = useState('hello') + const [latency, setLatency] = useState(500) + const latencyRef = useRef(latency) + latencyRef.current = latency + const storage = useMemo(() => createLatentMemoryStorage(latencyRef), []) + const [k, setK] = useState(0) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar-stack' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: '1fr' } }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `I/O delay (ms) · ${latency}`), + React.createElement('input', { + type: 'range', + min: 0, + max: 3000, + step: 50, + value: latency, + onChange: (e) => setLatency(Number(e.target.value) || 0), + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'key'), + React.createElement('input', { type: 'text', value: key, onChange: (e) => setKey(e.target.value) }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'initial (remount)'), + React.createElement('input', { type: 'text', value: initial, onChange: (e) => setInitial(e.target.value) }), + ), + React.createElement( + 'button', + { + type: 'button', + className: 'hook-demo-primary-action', + style: { marginTop: '0.15rem' }, + onClick: () => { + storage.clearKeySync(key) + setK((c) => c + 1) + }, + }, + 'Apply / remount', + ), + ), + ), + React.createElement(StorageAsyncInner, { key: k, storageKey: key, initial, storage }), + ) +} + +export const sourceJsx = `import { useMemo, useRef, useState } from 'react' +import useStorageAsync from '@dedalik/use-react/useStorageAsync' + +function createLatentMemoryStorage(latencyRef) { + const data = new Map() + const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + return { + getItem: async (key) => { + await sleep(latencyRef.current) + return data.get(key) ?? null + }, + setItem: async (key, value) => { + await sleep(latencyRef.current) + data.set(key, value) + }, + removeItem: async (key) => { + await sleep(latencyRef.current) + data.delete(key) + }, + clearKeySync(key) { + data.delete(key) + }, + } +} + +function StorageAsyncInner({ storageKey, initial, storage }) { + const [value, loading, set] = useStorageAsync(storage, storageKey, initial, { + serializer: JSON.stringify, + parser: JSON.parse, + }) + const safeValue = typeof value === 'string' ? value : value == null ? '' : String(value) + + return ( +
+

Apply / remount clears the in-memory key so the next load matches \`initial\`.

+

loading: {String(loading)}

+

value: {safeValue}

+ void set(e.target.value)} /> + +
+ ) +} + +export default function StorageAsyncDemo() { + const [key, setKey] = useState('demo-item') + const [initial, setInitial] = useState('hello') + const [latency, setLatency] = useState(500) + const latencyRef = useRef(latency) + latencyRef.current = latency + const storage = useMemo(() => createLatentMemoryStorage(latencyRef), []) + const [k, setK] = useState(0) + + return ( +
+
+
+
+

{'I/O delay (ms) · ' + latency}

+ setLatency(Number(e.target.value) || 0)} + /> +
+
+

key

+ setKey(e.target.value)} /> +
+
+

initial (remount)

+ setInitial(e.target.value)} /> +
+ +
+
+ +
+ ) +}` + +export default StorageAsyncDemo diff --git a/docs/.vitepress/theme/react-demos/useThrottle.basic.ts b/docs/.vitepress/theme/react-demos/useThrottle.basic.ts new file mode 100644 index 0000000..6104737 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useThrottle.basic.ts @@ -0,0 +1,92 @@ +import React, { useState } from 'react' +import useThrottle from '@dedalik/use-react/useThrottle' + +function ThrottleDemo() { + const [query, setQuery] = useState('') + const [delay, setDelay] = useState(400) + const throttled = useThrottle(query, delay) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useThrottle(value, delay) - coalesces updates to at most one per `delay` ms (trailing update scheduled if value changed inside the window). Default delay: 500.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field', style: { gridColumn: '1 / -1' } }, + React.createElement('p', { className: 'hook-demo-label' }, `throttle window (${delay} ms)`), + React.createElement('input', { + type: 'range', + min: 0, + max: 2000, + step: 50, + value: delay, + onChange: (e) => setDelay(Number(e.target.value)), + }), + ), + ), + React.createElement('input', { + type: 'text', + value: query, + placeholder: 'Type quickly - throttled value follows with rate limit…', + onChange: (e) => setQuery(e.target.value), + }), + React.createElement('p', { style: { margin: 0 } }, 'Live: ', React.createElement('strong', null, query || '-')), + React.createElement( + 'p', + { style: { margin: 0 } }, + 'Throttled: ', + React.createElement('strong', null, throttled || '-'), + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useThrottle from '@dedalik/use-react/useThrottle' + +export default function ThrottleDemo() { + const [query, setQuery] = useState('') + const [delay, setDelay] = useState(400) + const throttled = useThrottle(query, delay) + + return ( +
+

+ useThrottle(value, delay) - at most one update per delay window. Default delay: 500. +

+
+
+

{'throttle window (' + delay + ' ms)'}

+ setDelay(Number(e.target.value))} + /> +
+
+ setQuery(e.target.value)} + /> +

+ Live: {query || '-'} +

+

+ Throttled: {throttled || '-'} +

+
+ ) +}` + +export default ThrottleDemo diff --git a/docs/.vitepress/theme/react-demos/useThrottledRefHistory.basic.ts b/docs/.vitepress/theme/react-demos/useThrottledRefHistory.basic.ts new file mode 100644 index 0000000..21f1d57 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useThrottledRefHistory.basic.ts @@ -0,0 +1,116 @@ +import React, { useState } from 'react' +import useThrottledRefHistory from '@dedalik/use-react/useThrottledRefHistory' + +function ThrottledRefHistoryDemo() { + const [delay, setDelay] = useState(400) + const [capacity, setCapacity] = useState(10) + const { value, set, undo, redo, clear, canUndo, canRedo, history, pointer } = useThrottledRefHistory('Type here', { + delay, + capacity, + }) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'useThrottledRefHistory(initial, { delay, capacity }) - at most one snapshot per `delay` ms while the value is changing. undo / redo / clear match the other history hooks.', + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `throttle (${delay} ms)`), + React.createElement('input', { + type: 'range', + min: 0, + max: 2000, + step: 50, + value: delay, + onChange: (e) => setDelay(Number(e.target.value)), + }), + ), + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, `capacity (${capacity})`), + React.createElement('input', { + type: 'range', + min: 1, + max: 20, + value: capacity, + onChange: (e) => setCapacity(Number(e.target.value) || 1), + }), + ), + ), + React.createElement('input', { type: 'text', value, onChange: (e) => set(e.target.value) }), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: undo, disabled: !canUndo }, 'undo()'), + React.createElement('button', { type: 'button', onClick: redo, disabled: !canRedo }, 'redo()'), + React.createElement('button', { type: 'button', onClick: () => void clear() }, 'clear()'), + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.85rem' } }, + 'pointer: ', + pointer, + ' | snapshots: ', + history.length, + ), + React.createElement('p', { className: 'hook-demo-mono' }, JSON.stringify(history, null, 0)), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useThrottledRefHistory from '@dedalik/use-react/useThrottledRefHistory' + +export default function ThrottledRefHistoryDemo() { + const [delay, setDelay] = useState(400) + const [capacity, setCapacity] = useState(10) + const { value, set, undo, redo, clear, canUndo, canRedo, history, pointer } = useThrottledRefHistory( + 'Type here', + { delay, capacity }, + ) + return ( +
+

Throttled snapshot rate + capacity.

+
+
+

{'throttle (' + delay + ' ms)'}

+ setDelay(Number(e.target.value))} + /> +
+
+

{'capacity (' + capacity + ')'}

+ setCapacity(Number(e.target.value) || 1)} + /> +
+
+ set(e.target.value)} /> +
+ + + +
+

{JSON.stringify(history)}

+
+ ) +}` + +export default ThrottledRefHistoryDemo diff --git a/docs/.vitepress/theme/react-demos/useToggle.basic.ts b/docs/.vitepress/theme/react-demos/useToggle.basic.ts new file mode 100644 index 0000000..9c96920 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useToggle.basic.ts @@ -0,0 +1,140 @@ +import React, { useId, useState } from 'react' +import useToggle from '@dedalik/use-react/useToggle' + +function ToggleCore({ initial }: { initial: boolean }) { + const [on, toggle, set] = useToggle(initial) + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { style: { display: 'grid', gap: '0.5rem' } }, + React.createElement( + 'p', + { className: 'hook-live-demo__status' }, + 'API: [value, toggle, set] - value is the boolean, toggle inverts, set sets explicitly.', + ), + React.createElement( + 'p', + { style: { margin: 0, fontSize: '0.9rem' } }, + `value: `, + React.createElement('strong', null, String(on)), + ), + React.createElement( + 'div', + { style: { display: 'flex', flexWrap: 'wrap', gap: '0.45rem' } }, + React.createElement('button', { type: 'button', onClick: toggle }, 'toggle()'), + React.createElement('button', { type: 'button', onClick: () => set(true) }, 'set(true)'), + React.createElement('button', { type: 'button', onClick: () => set(false) }, 'set(false)'), + ), + ), + on + ? React.createElement( + 'div', + { + style: { + border: '1px solid var(--vp-c-divider)', + borderRadius: '8px', + padding: '0.75rem', + background: 'var(--vp-c-bg-soft)', + }, + }, + 'Content visible while value is true.', + ) + : null, + ) +} + +function ToggleDemo() { + const rid = useId().replace(/:/g, '') + const idStart = `${rid}-start-true` + const [startTrue, setStartTrue] = useState(false) + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'div', + { className: 'hook-demo-toolbar' }, + React.createElement( + 'div', + { className: 'hook-demo-field' }, + React.createElement('p', { className: 'hook-demo-label' }, 'useToggle(initial)'), + React.createElement( + 'label', + { className: 'hook-demo-check', htmlFor: idStart }, + React.createElement('input', { + id: idStart, + type: 'checkbox', + checked: startTrue, + onChange: (e) => setStartTrue(e.target.checked), + }), + React.createElement('span', null, 'Start as true (remounts hook)'), + ), + ), + ), + React.createElement(ToggleCore, { key: String(startTrue), initial: startTrue }), + ) +} + +export const sourceJsx = `import { useId, useState } from 'react' +import useToggle from '@dedalik/use-react/useToggle' + +function ToggleCore({ initial }) { + const [on, toggle, set] = useToggle(initial) + return ( +
+
+

+ API: [value, toggle, set] - value is the boolean, toggle inverts, set sets explicitly. +

+

+ value: {String(on)} +

+
+ + + +
+
+ {on ? ( +
+ Content visible while value is true. +
+ ) : null} +
+ ) +} + +export default function ToggleDemo() { + const rid = useId().replace(/:/g, '') + const idStart = rid + '-start-true' + const [startTrue, setStartTrue] = useState(false) + return ( +
+
+
+

useToggle(initial)

+ +
+
+ +
+ ) +}` + +export default ToggleDemo diff --git a/docs/.vitepress/theme/styles/overrides.css b/docs/.vitepress/theme/styles/overrides.css index ed1ae01..272800b 100644 --- a/docs/.vitepress/theme/styles/overrides.css +++ b/docs/.vitepress/theme/styles/overrides.css @@ -93,3 +93,616 @@ .VPSidebarItem.core-functions-locked > .item .caret { display: none !important; } + +/* Live hook demo (React mount + Shiki source) */ +.hook-live-demo { + --hook-demo-radius: 14px; + --hook-demo-border: color-mix(in srgb, var(--vp-c-divider) 85%, transparent); + margin: 1.25rem 0 1.5rem; + border-radius: var(--hook-demo-radius); + border: 1px solid var(--hook-demo-border); + background: + linear-gradient(145deg, color-mix(in srgb, var(--vp-c-brand-1) 10%, transparent), transparent 55%), + var(--vp-c-bg-soft); + overflow: hidden; +} + +.hook-live-demo__header { + padding: 1rem 1.1rem 0.9rem; + border-bottom: 1px solid var(--hook-demo-border); + background: color-mix(in srgb, var(--vp-c-bg) 55%, transparent); + backdrop-filter: blur(10px); +} + +.hook-live-demo__header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.hook-live-demo__title { + margin: 0; + font-size: 1rem; + font-weight: 650; + letter-spacing: -0.02em; + color: var(--vp-c-text-1); +} + +.hook-live-demo__title-link { + color: inherit; + text-decoration: none; +} + +.hook-live-demo__title-link:hover { + color: var(--vp-c-brand-1); + text-decoration: underline; +} + +.hook-live-demo__subtitle { + margin: 0.45rem 0 0; + font-size: 0.8125rem; + line-height: 1.45; + color: var(--vp-c-text-2); +} + +.hook-live-demo__pill { + flex-shrink: 0; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + padding: 0.28rem 0.55rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--vp-c-brand-1) 45%, var(--vp-c-divider)); + color: var(--vp-c-brand-1); + background: color-mix(in srgb, var(--vp-c-brand-1) 12%, transparent); +} + +.hook-live-demo__preview { + min-height: 200px; + padding: 1.1rem 1.15rem; + background: var(--vp-c-bg); +} + +.hook-live-demo__mount { + border-radius: 10px; + padding: 1rem; + border: 1px solid var(--hook-demo-border); + background: linear-gradient(180deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%); + min-height: 2.5rem; +} + +.hook-live-demo__mount button { + border: 1px solid var(--vp-c-divider); + border-radius: 9px; + padding: 0.45rem 0.85rem; + background: var(--vp-c-bg-elv); + color: var(--vp-c-text-1); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + transform 0.12s ease; +} + +.hook-live-demo__mount button:hover { + border-color: color-mix(in srgb, var(--vp-c-brand-1) 35%, var(--vp-c-divider)); + background: color-mix(in srgb, var(--vp-c-brand-1) 8%, var(--vp-c-bg-elv)); +} + +.hook-live-demo__mount button:active { + transform: scale(0.98); +} + +.hook-live-demo__mount input[type='search'], +.hook-live-demo__mount input[type='text'] { + width: 100%; + max-width: 22rem; + padding: 0.55rem 0.75rem; + border-radius: 10px; + border: 1px solid var(--vp-c-divider); + background: var(--vp-c-bg); + color: var(--vp-c-text-1); + font-size: 0.875rem; + outline: none; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.hook-live-demo__mount input:focus-visible { + border-color: color-mix(in srgb, var(--vp-c-brand-1) 55%, var(--vp-c-divider)); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--vp-c-brand-1) 22%, transparent); +} + +/* State hook live demos: settings + readouts */ +.hook-demo-surface { + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.hook-demo-toolbar { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 10.5rem), 1fr)); + gap: 0.85rem 1.15rem; + align-items: start; + padding: 0.75rem 0.85rem; + border-radius: 10px; + border: 1px solid var(--vp-c-divider); + background: color-mix(in srgb, var(--vp-c-bg) 60%, var(--vp-c-bg-soft)); +} + +/* Toolbar + full-width action (e.g. Remount) */ +.hook-demo-toolbar-stack { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.hook-demo-toolbar--even-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +@media (max-width: 640px) { + .hook-demo-toolbar--even-3 { + grid-template-columns: 1fr; + } +} + +.hook-live-demo__mount button.hook-demo-primary-action { + width: 100%; + max-width: none; + justify-content: center; + text-align: center; + padding: 0.55rem 1rem; + border-radius: 10px; + border: 1px solid color-mix(in srgb, var(--vp-c-brand-1) 40%, var(--vp-c-divider)); + background: color-mix(in srgb, var(--vp-c-brand-1) 10%, var(--vp-c-bg-elv)); + color: var(--vp-c-text-1); + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.hook-live-demo__mount button.hook-demo-primary-action:hover { + border-color: color-mix(in srgb, var(--vp-c-brand-1) 55%, var(--vp-c-divider)); + background: color-mix(in srgb, var(--vp-c-brand-1) 16%, var(--vp-c-bg-elv)); +} + +.hook-demo-field { + display: flex; + flex-direction: column; + gap: 0.35rem; + min-width: 0; +} + +/* Inputs in toolbar: same width per column (override mount number max-width) */ +.hook-demo-toolbar .hook-demo-field > input[type='text'], +.hook-demo-toolbar .hook-demo-field > input[type='search'], +.hook-demo-toolbar .hook-demo-field > input[type='number'], +.hook-demo-toolbar .hook-demo-field > select { + width: 100%; + max-width: none; + box-sizing: border-box; +} + +.hook-demo-label { + margin: 0; + min-height: 1.35em; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--vp-c-text-2); +} + +.hook-demo-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem 0.65rem; + font-size: 0.8125rem; + color: var(--vp-c-text-1); +} + +.hook-demo-checklist { + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.hook-demo-check { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0; + font-size: 0.8125rem; + line-height: 1.65; + color: var(--vp-c-text-1); + cursor: pointer; +} + +.hook-demo-check input { + flex-shrink: 0; + width: 1rem; + height: 1rem; + margin: 0; + accent-color: var(--vp-c-brand-1); +} + +/* Single-line toolbar control (e.g. useToggle): center box with label text */ +.hook-demo-field > .hook-demo-check { + display: grid; + grid-template-columns: auto 1fr; + column-gap: 0.5rem; + align-items: center; +} + +.hook-demo-field > .hook-demo-check input { + margin: 0; +} + +/* useAsyncState options: long wrapping labels - keep box on first line */ +.hook-demo-checklist .hook-demo-check { + align-items: flex-start; +} + +.hook-demo-checklist .hook-demo-check input { + margin-top: 0.26rem; + margin-top: max(0.2rem, calc(0.5lh - 0.5rem)); +} + +.hook-demo-check span { + flex: 1; + min-width: 0; +} + +/* Few options (2–4): segmented control instead of