diff --git a/.gitignore b/.gitignore index d74f56f..98b77b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .idea .vscode -.cursor node_modules docs/.vitepress/cache/ docs/.vitepress/**/deps_temp_*/ @@ -9,4 +8,17 @@ dist .temp .env .env.* +.envrc +.direnv/ *.log + +# Local deployment / tooling artifacts +.vercel/ +.netlify/ +.turbo/ +.cache/ +.npmrc.local + +# Local keys / certificates +*.pem +*.key diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a374915..3cf431f 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -63,10 +63,22 @@ function editUrlForPage(page: PageData): string { export default defineConfig({ lang: 'en-US', title: 'useReact', - titleTemplate: ':title | useReact', + titleTemplate: false, description: 'Collection of React Hooks', cleanUrls: true, lastUpdated: true, + transformPageData(pageData) { + const rawTitle = String(pageData.title || '').trim() + if (!rawTitle) return + + const hookMatch = pageData.relativePath.match(/^functions\/(use[A-Za-z0-9_]+)\.md$/) + if (hookMatch) { + pageData.title = `${rawTitle} | ${hookMatch[1]}() | use react` + return + } + + pageData.title = `${rawTitle} | use react` + }, async transformHead(ctx) { return seoTransformHead({ siteData: ctx.siteData, @@ -123,8 +135,8 @@ export default defineConfig({ }, head: [ ['meta', { name: 'theme-color', content: '#ffffff' }], - ['link', { rel: 'icon', href: '/favicon-32x32.png', type: 'image/png' }], ['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }], + ['link', { rel: 'shortcut icon', href: '/favicon.svg' }], ['meta', { property: 'og:title', content: 'UseReact' }], ['meta', { property: 'og:image', content: 'https://usereact.org/logo.png' }], [ diff --git a/docs/.vitepress/data/homeBrowserDemos.ts b/docs/.vitepress/data/homeBrowserDemos.ts new file mode 100644 index 0000000..201eb3e --- /dev/null +++ b/docs/.vitepress/data/homeBrowserDemos.ts @@ -0,0 +1,44 @@ +/** Live demos for the home "Browser" section (same order as /functions/browser). */ +export const homeBrowserDemos: ReadonlyArray<{ demo: string; title: string }> = [ + { demo: 'useHash/basic', title: 'useHash' }, + { demo: 'useBrowserLocation/basic', title: 'useBrowserLocation' }, + { demo: 'useBroadcastChannel/basic', title: 'useBroadcastChannel' }, + { demo: 'useFavicon/basic', title: 'useFavicon' }, + { demo: 'useFileDialog/basic', title: 'useFileDialog' }, + { demo: 'useFileSystemAccess/basic', title: 'useFileSystemAccess' }, + { demo: 'useFullscreen/basic', title: 'useFullscreen' }, + { demo: 'useMediaQuery/basic', title: 'useMediaQuery' }, + { demo: 'useMediaControls/basic', title: 'useMediaControls' }, + { demo: 'useMemory/basic', title: 'useMemory' }, + { demo: 'useCssSupports/basic', title: 'useCssSupports' }, + { demo: 'useCssVar/basic', title: 'useCssVar' }, + { demo: 'useBreakpoints/basic', title: 'useBreakpoints' }, + { demo: 'useBluetooth/basic', title: 'useBluetooth' }, + { demo: 'usePreferredColorScheme/basic', title: 'usePreferredColorScheme' }, + { demo: 'usePreferredDark/basic', title: 'usePreferredDark' }, + { demo: 'useDark/basic', title: 'useDark' }, + { demo: 'usePreferredLanguages/basic', title: 'usePreferredLanguages' }, + { demo: 'usePreferredReducedMotion/basic', title: 'usePreferredReducedMotion' }, + { demo: 'usePreferredContrast/basic', title: 'usePreferredContrast' }, + { demo: 'usePreferredReducedTransparency/basic', title: 'usePreferredReducedTransparency' }, + { demo: 'useTextDirection/basic', title: 'useTextDirection' }, + { demo: 'useColorMode/basic', title: 'useColorMode' }, + { demo: 'useUrlSearchParams/basic', title: 'useUrlSearchParams' }, + { demo: 'useSSRWidth/basic', title: 'useSSRWidth' }, + { demo: 'useScreenOrientation/basic', title: 'useScreenOrientation' }, + { demo: 'useShare/basic', title: 'useShare' }, + { demo: 'useVibrate/basic', title: 'useVibrate' }, + { demo: 'useWakeLock/basic', title: 'useWakeLock' }, + { demo: 'useStyleTag/basic', title: 'useStyleTag' }, + { demo: 'useWebNotification/basic', title: 'useWebNotification' }, + { demo: 'useScreenSafeArea/basic', title: 'useScreenSafeArea' }, + { demo: 'useWindowSize/basic', title: 'useWindowSize' }, + { demo: 'useTitle/basic', title: 'useTitle' }, + { demo: 'useLockBodyScroll/basic', title: 'useLockBodyScroll' }, + { demo: 'useScript/basic', title: 'useScript' }, + { demo: 'usePageVisibility/basic', title: 'usePageVisibility' }, + { demo: 'useCopyToClipboard/basic', title: 'useCopyToClipboard' }, + { demo: 'useClipboardItems/basic', title: 'useClipboardItems' }, + { demo: 'usePermission/basic', title: 'usePermission' }, + { demo: 'usePerformanceObserver/basic', title: 'usePerformanceObserver' }, +] diff --git a/docs/.vitepress/data/hookDemoSubtitles.ts b/docs/.vitepress/data/hookDemoSubtitles.ts index 9d9289e..7d7f815 100644 --- a/docs/.vitepress/data/hookDemoSubtitles.ts +++ b/docs/.vitepress/data/hookDemoSubtitles.ts @@ -10,6 +10,81 @@ export const hookDemoSubtitles: Record = { 'useDraggable/basic': 'Drag a card by its handle and keep it inside a container while tracking position and drag state.', 'useDropZone/basic': 'Track drag-over state and capture dropped files with hover feedback and recent-drop logging.', + 'useBroadcastChannel/basic': + 'Broadcast structured messages across tabs on one channel and inspect the latest received payload.', + 'useBrowserLocation/basic': + 'Watch href/path/search/hash updates from popstate/hashchange and try quick hash navigation actions.', + 'useFileDialog/basic': 'Open the native file chooser, capture selected files, and reset selection programmatically.', + 'useFileSystemAccess/basic': + 'Open a text file with native picker, edit content, and save it back through the File System Access API.', + 'useBluetooth/basic': 'Trigger the Web Bluetooth device picker and track selected device name and request errors.', + 'useFullscreen/basic': + 'Enter, exit, and toggle fullscreen for a target element while tracking real-time fullscreen state.', + 'useMediaControls/basic': + 'Control a media element with custom play/pause, mute, volume, and seek interactions synced from events.', + 'useMemory/basic': 'Poll performance.memory in Chromium and display live used/total/limit JS heap snapshots.', + 'useMediaQuery/basic': + 'Subscribe to multiple media queries and watch match booleans react to viewport and system preference changes.', + 'useCssSupports/basic': + 'Check CSS.supports in both property/value and condition-string forms with live user-entered input.', + 'useCssVar/basic': 'Read and update a CSS custom property on a target element, keeping UI state in sync with styles.', + 'useBreakpoints/basic': + 'Resolve active breakpoint names from window width and inspect current, active list, boolean flags, and >= checks.', + 'usePreferredColorScheme/basic': + 'Read prefers-color-scheme changes and map them to light/dark/no-preference UI behavior.', + 'usePreferredDark/basic': + 'Return a boolean from prefers-color-scheme: dark for straightforward dark-mode branching in UI.', + 'useDark/basic': + 'Persist an explicit dark/light choice and synchronize dark/light classes on the chosen DOM element.', + 'usePreferredLanguages/basic': + 'Expose navigator language priority order and refresh when the browser locale preference changes.', + 'usePreferredReducedMotion/basic': + 'Observe prefers-reduced-motion and conditionally disable non-essential animations in UI.', + 'usePreferredContrast/basic': + 'Read prefers-contrast (more/less/custom/no-preference) and map it to stronger or softer visual emphasis.', + 'usePreferredReducedTransparency/basic': + 'Observe prefers-reduced-transparency and replace frosted/translucent UI surfaces with solid backgrounds.', + 'useColorMode/basic': + 'Persist light/dark/auto mode and expose resolved dark state while syncing theme attribute/class on the root.', + 'useUrlSearchParams/basic': + 'Read current URL query values and replace search params through record or URLSearchParams updates.', + 'useSSRWidth/basic': + 'Return fallback width on server and initial window width on client for SSR-safe layout branching.', + 'useScreenOrientation/basic': + 'Track screen orientation type and angle, then derive portrait/landscape oriented UI hints.', + 'useShare/basic': + 'Open the native share sheet with title, text, and URL, then reflect whether share succeeded or was dismissed.', + 'useVibrate/basic': + 'Invoke short and patterned device vibrations from user gestures, and report whether the browser accepted calls.', + 'useWakeLock/basic': + 'Acquire and release a screen wake lock, tracking active state and request outcomes in real time.', + 'useStyleTag/basic': + 'Inject CSS into a dynamic style tag, swap style variants live, and observe id/loaded/error state.', + 'useWebNotification/basic': + 'Request notification permission and send a browser notification while tracking permission/action outcomes.', + 'useScreenSafeArea/basic': + 'Read top/right/bottom/left safe-area insets and preview layout padding adjusted with current values.', + 'useWindowSize/basic': + 'Track live viewport width/height from resize events and derive orientation/aspect display hints.', + 'useTitle/basic': 'Set document.title from component state and optionally restore the previous title on unmount.', + 'useLockBodyScroll/basic': + 'Toggle body overflow lock with overlay state so background page scrolling is disabled while open.', + 'useScript/basic': + 'Load and track an external script URL with idle/loading/ready/error status and global availability checks.', + 'usePageVisibility/basic': + 'Mirror document visibility state and pause visible-only work while the tab or window is hidden.', + 'useCopyToClipboard/basic': + 'Copy text to navigator.clipboard, track success/failure, and expose the hook stored copied value.', + 'useClipboardItems/basic': + 'Read ClipboardItem entries from the async clipboard API and inspect returned MIME type groups.', + 'usePermission/basic': + 'Observe Permissions API state for selected names and watch updates when browser settings change.', + 'usePerformanceObserver/basic': + 'Subscribe to PerformanceObserver entry types and inspect buffered navigation/resource timeline events.', + 'useTextDirection/basic': + 'Watch document dir changes and switch UI direction between ltr and rtl with a simple state value.', + 'useFavicon/basic': 'Swap the tab icon dynamically using preset and custom favicon URLs.', + 'useHash/basic': 'Read and update window.location.hash with quick presets and custom hash input.', 'useElementBounding/basic': 'Track full DOMRect values (x/y/edges/size) while a target moves inside a scrollable container.', 'useElementSize/basic': diff --git a/docs/.vitepress/seo/transformHead.ts b/docs/.vitepress/seo/transformHead.ts index 89501f2..bfb9511 100644 --- a/docs/.vitepress/seo/transformHead.ts +++ b/docs/.vitepress/seo/transformHead.ts @@ -76,15 +76,25 @@ export interface TransformHeadContext { description: string } +function buildSeoTitle(pageData: PageData, title: string): string { + const hookMatch = pageData.relativePath.match(/^functions\/(use[A-Za-z0-9_]+)\.md$/) + if (hookMatch) { + return `${title} | ${hookMatch[1]}() | use react` + } + return `${title} | use react` +} + export function transformHead(ctx: TransformHeadContext): HeadConfig[] { const { siteData, pageData, title, description } = ctx if (pageData.isNotFound) return [] + const seoTitle = buildSeoTitle(pageData, title) const canonical = buildCanonical(siteData, pageData) const lang = siteData.lang || 'en-US' - const ld = buildJsonLd({ canonical, title, description, lang }) + const ld = buildJsonLd({ canonical, title: seoTitle, description, lang }) const head: HeadConfig[] = [ + ['title', seoTitle], ['link', { rel: 'canonical', href: canonical }], [ 'meta', @@ -97,6 +107,8 @@ export function transformHead(ctx: TransformHeadContext): HeadConfig[] { ['meta', { property: 'article:publisher:url', content: `${SITE}/` }], ['meta', { name: 'publisher', content: PUBLISHER_NAME }], ['meta', { property: 'og:url', content: canonical }], + ['meta', { property: 'og:title', content: seoTitle }], + ['meta', { name: 'twitter:title', content: seoTitle }], ['script', { type: 'application/ld+json' }, ld], ] diff --git a/docs/.vitepress/theme/components/HomeHookShowcase.vue b/docs/.vitepress/theme/components/HomeHookShowcase.vue index 7b14ddf..fd85a67 100644 --- a/docs/.vitepress/theme/components/HomeHookShowcase.vue +++ b/docs/.vitepress/theme/components/HomeHookShowcase.vue @@ -3,12 +3,14 @@ import { computed, ref } from 'vue' import { withBase } from 'vitepress' import { homeStateDemos } from '../../data/homeStateDemos' import { homeElementsDemos } from '../../data/homeElementsDemos' +import { homeBrowserDemos } from '../../data/homeBrowserDemos' /** Open card ids - several demos can stay open at once. */ const expandedDemos = ref([]) -/** All State cards show full preview (overrides per-card list for display). */ -const showAllDemos = ref(true) +/** Start collapsed by default. */ +const showAllDemos = ref(false) +const allHomeDemos = [...homeStateDemos, ...homeElementsDemos, ...homeBrowserDemos] const anyDemosOpen = computed(() => showAllDemos.value || expandedDemos.value.length > 0) @@ -37,7 +39,7 @@ function onDemoItemClick(demo: string, ev: MouseEvent) { if (!el.closest('.hook-live-demo__header')) return if (showAllDemos.value) { showAllDemos.value = false - expandedDemos.value = homeStateDemos.map((i) => i.demo).filter((d) => d !== demo) + expandedDemos.value = allHomeDemos.map((i) => i.demo).filter((d) => d !== demo) } else { expandedDemos.value = expandedDemos.value.filter((d) => d !== demo) } @@ -57,7 +59,7 @@ function onDemoItemKeydown(demo: string, ev: KeyboardEvent) { if (open) { if (showAllDemos.value) { showAllDemos.value = false - expandedDemos.value = homeStateDemos.map((i) => i.demo).filter((d) => d !== demo) + expandedDemos.value = allHomeDemos.map((i) => i.demo).filter((d) => d !== demo) } else { expandedDemos.value = expandedDemos.value.filter((d) => d !== demo) } @@ -74,7 +76,7 @@ function onDemoItemKeydown(demo: string, ev: KeyboardEvent) {
-

Live component examples

+

Demo component examples

These are real in-page previews: each card links to the hook’s full reference. More categories will land here next. @@ -150,6 +152,9 @@ function onDemoItemKeydown(demo: string, ev: KeyboardEvent) { @click="onDemoItemClick(item.demo, $event)" @keydown="onDemoItemKeydown(item.demo, $event)" > +

+ + + +
+ + +
+
+

Browser

+
+

+ Browser APIs and environment hooks: location/hash, media, permissions, clipboard, notifications, orientation, + scripts, and more. Browse + Browser in the function list → +

+ +
+
+ > = {} + const props = defineProps<{ demo: string title?: string @@ -18,11 +20,13 @@ const props = defineProps<{ titleHref?: string }>() +const initialCachedModule = demoModuleCache[props.demo] + const mountEl = ref(null) -const sourceJsx = ref('') +const sourceJsx = ref(initialCachedModule?.sourceJsx ?? '') const error = ref('') -const loading = ref(true) -const demoComponent = shallowRef(null) +const loading = ref(!initialCachedModule) +const demoComponent = shallowRef(initialCachedModule?.default ?? null) const highlightedHtml = ref('') const highlightLoading = ref(false) @@ -54,6 +58,47 @@ const demoLoaders: Record Promise> = { 'useWindowScroll/basic': () => import('../react-demos/useWindowScroll.basic'), 'useDebouncedRefHistory/basic': () => import('../react-demos/useDebouncedRefHistory.basic'), 'useDropZone/basic': () => import('../react-demos/useDropZone.basic'), + 'useBroadcastChannel/basic': () => import('../react-demos/useBroadcastChannel.basic'), + 'useBrowserLocation/basic': () => import('../react-demos/useBrowserLocation.basic'), + 'useFileDialog/basic': () => import('../react-demos/useFileDialog.basic'), + 'useFileSystemAccess/basic': () => import('../react-demos/useFileSystemAccess.basic'), + 'useBluetooth/basic': () => import('../react-demos/useBluetooth.basic'), + 'useFullscreen/basic': () => import('../react-demos/useFullscreen.basic'), + 'useMediaControls/basic': () => import('../react-demos/useMediaControls.basic'), + 'useMemory/basic': () => import('../react-demos/useMemory.basic'), + 'useMediaQuery/basic': () => import('../react-demos/useMediaQuery.basic'), + 'useCssSupports/basic': () => import('../react-demos/useCssSupports.basic'), + 'useCssVar/basic': () => import('../react-demos/useCssVar.basic'), + 'useBreakpoints/basic': () => import('../react-demos/useBreakpoints.basic'), + 'usePreferredColorScheme/basic': () => import('../react-demos/usePreferredColorScheme.basic'), + 'usePreferredDark/basic': () => import('../react-demos/usePreferredDark.basic'), + 'useDark/basic': () => import('../react-demos/useDark.basic'), + 'usePreferredLanguages/basic': () => import('../react-demos/usePreferredLanguages.basic'), + 'usePreferredReducedMotion/basic': () => import('../react-demos/usePreferredReducedMotion.basic'), + 'usePreferredContrast/basic': () => import('../react-demos/usePreferredContrast.basic'), + 'usePreferredReducedTransparency/basic': () => import('../react-demos/usePreferredReducedTransparency.basic'), + 'useColorMode/basic': () => import('../react-demos/useColorMode.basic'), + 'useUrlSearchParams/basic': () => import('../react-demos/useUrlSearchParams.basic'), + 'useSSRWidth/basic': () => import('../react-demos/useSSRWidth.basic'), + 'useScreenOrientation/basic': () => import('../react-demos/useScreenOrientation.basic'), + 'useShare/basic': () => import('../react-demos/useShare.basic'), + 'useVibrate/basic': () => import('../react-demos/useVibrate.basic'), + 'useWakeLock/basic': () => import('../react-demos/useWakeLock.basic'), + 'useStyleTag/basic': () => import('../react-demos/useStyleTag.basic'), + 'useWebNotification/basic': () => import('../react-demos/useWebNotification.basic'), + 'useScreenSafeArea/basic': () => import('../react-demos/useScreenSafeArea.basic'), + 'useWindowSize/basic': () => import('../react-demos/useWindowSize.basic'), + 'useTitle/basic': () => import('../react-demos/useTitle.basic'), + 'useLockBodyScroll/basic': () => import('../react-demos/useLockBodyScroll.basic'), + 'useScript/basic': () => import('../react-demos/useScript.basic'), + 'usePageVisibility/basic': () => import('../react-demos/usePageVisibility.basic'), + 'useCopyToClipboard/basic': () => import('../react-demos/useCopyToClipboard.basic'), + 'useClipboardItems/basic': () => import('../react-demos/useClipboardItems.basic'), + 'usePermission/basic': () => import('../react-demos/usePermission.basic'), + 'usePerformanceObserver/basic': () => import('../react-demos/usePerformanceObserver.basic'), + 'useTextDirection/basic': () => import('../react-demos/useTextDirection.basic'), + 'useFavicon/basic': () => import('../react-demos/useFavicon.basic'), + 'useHash/basic': () => import('../react-demos/useHash.basic'), 'useToggle/basic': () => import('../react-demos/useToggle.basic'), 'useDebounce/basic': () => import('../react-demos/useDebounce.basic'), 'useEventCallback/basic': () => import('../react-demos/useEventCallback.basic'), @@ -161,22 +206,38 @@ function renderDemo() { } async function loadDemo() { - loading.value = true - error.value = '' - sourceJsx.value = '' - demoComponent.value = null - highlightedHtml.value = '' - cleanupRoot() - const load = demoLoaders[props.demo] if (!load) { + loading.value = false error.value = `Demo "${props.demo}" is not registered yet.` + sourceJsx.value = '' + demoComponent.value = null + highlightedHtml.value = '' + cleanupRoot() + return + } + + const cached = demoModuleCache[props.demo] + if (cached) { + error.value = '' + sourceJsx.value = cached.sourceJsx + demoComponent.value = cached.default + highlightedHtml.value = '' loading.value = false + renderDemo() return } + loading.value = true + error.value = '' + sourceJsx.value = '' + demoComponent.value = null + highlightedHtml.value = '' + cleanupRoot() + try { const mod = await load() + demoModuleCache[props.demo] = mod demoComponent.value = mod.default sourceJsx.value = mod.sourceJsx loading.value = false @@ -191,6 +252,9 @@ async function loadDemo() { watch([sourceJsx, isDark, sourceOpen], () => void refreshHighlight()) onMounted(() => { + if (demoComponent.value) { + renderDemo() + } // Defer demo chunk + first React render so route transitions stay smooth. scheduleIdle(() => void loadDemo()) prefetchHighlighterIdle() @@ -216,7 +280,7 @@ onBeforeUnmount(() => { {{ displayTitle }} - +

{{ subtitle }}

diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index a1f4bde..9b0d2cf 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -72,6 +72,80 @@ function lockCoreFunctionsSidebarGroup() { }) } +function decorateActiveSidebarLink() { + if (typeof document === 'undefined') return + + document.querySelectorAll('.VPSidebar .sidebar-active-arrow').forEach((node) => node.remove()) + document + .querySelectorAll('.VPSidebar .sidebar-active-text') + .forEach((node) => node.classList.remove('sidebar-active-text')) + const textNode = + document.querySelector('.VPSidebar a[aria-current="page"] .text') ?? + document.querySelector('.VPSidebar .VPLink.active .text, .VPSidebar a.active .text') ?? + document.querySelector('.VPSidebar .VPSidebarItem.is-active > .item .text') ?? + document.querySelector('.VPSidebar .VPSidebarItem.has-active > .item .text') + if (!textNode) return + + textNode.classList.add('sidebar-active-text') + const arrow = document.createElement('span') + arrow.className = 'sidebar-active-arrow' + arrow.textContent = '▸' + textNode.prepend(arrow) +} + +function findScrollableParent(el: HTMLElement): HTMLElement | null { + let parent: HTMLElement | null = el.parentElement + while (parent) { + const style = window.getComputedStyle(parent) + const canScrollY = /(auto|scroll)/.test(style.overflowY) + if (canScrollY && parent.scrollHeight > parent.clientHeight) { + return parent + } + parent = parent.parentElement + } + return null +} + +function scrollSidebarToActiveItem() { + if (typeof document === 'undefined') return + if (typeof window === 'undefined') return + + const activeLink = + document.querySelector('.VPSidebar a[aria-current="page"]') ?? + document.querySelector('.VPSidebar .VPLink.active, .VPSidebar a.active') + + const activeTarget = + activeLink ?? + document.querySelector('.VPSidebar .VPSidebarItem.is-active .item') ?? + document.querySelector('.VPSidebar .VPSidebarItem.has-active .item') + + if (!activeTarget) return false + + const sidebarScroller = findScrollableParent(activeTarget) + if (!sidebarScroller) { + activeTarget.scrollIntoView({ block: 'center', inline: 'nearest' }) + return Boolean(activeLink) + } + + const scrollerRect = sidebarScroller.getBoundingClientRect() + const targetRect = activeTarget.getBoundingClientRect() + const deltaTop = targetRect.top - scrollerRect.top + const centeredTop = sidebarScroller.scrollTop + deltaTop - sidebarScroller.clientHeight / 2 + targetRect.height / 2 + const maxTop = Math.max(0, sidebarScroller.scrollHeight - sidebarScroller.clientHeight) + const clampedTop = Math.max(0, Math.min(centeredTop, maxTop)) + + sidebarScroller.scrollTo({ top: clampedTop, behavior: 'auto' }) + return Boolean(activeLink) +} + +function ensureSidebarActiveVisible(attempt = 0) { + const done = scrollSidebarToActiveItem() + if (done || attempt >= 8) return + window.setTimeout(() => ensureSidebarActiveVisible(attempt + 1), 120) +} + +let didInitialSidebarAutoScroll = false + export default { ...DefaultTheme, Layout: () => @@ -81,16 +155,27 @@ export default { setup() { const route = useRoute() - const refreshBlocks = async () => { + const refreshBlocks = async (options?: { scrollSidebarToActive?: boolean }) => { await nextTick() requestAnimationFrame(() => { enhanceCollapsibleCodeBlocks() lockCoreFunctionsSidebarGroup() + decorateActiveSidebarLink() + if (options?.scrollSidebarToActive) { + ensureSidebarActiveVisible() + } }) } - onMounted(refreshBlocks) - watch(() => route.path, refreshBlocks) + onMounted(() => { + const shouldScroll = !didInitialSidebarAutoScroll + didInitialSidebarAutoScroll = true + refreshBlocks({ scrollSidebarToActive: shouldScroll }) + }) + watch( + () => route.path, + () => refreshBlocks(), + ) }, enhanceApp(ctx) { DefaultTheme.enhanceApp?.(ctx) diff --git a/docs/.vitepress/theme/react-demos/useBluetooth.basic.ts b/docs/.vitepress/theme/react-demos/useBluetooth.basic.ts new file mode 100644 index 0000000..da9c8c8 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useBluetooth.basic.ts @@ -0,0 +1,105 @@ +import React, { useState } from 'react' +import useBluetooth from '@dedalik/use-react/useBluetooth' + +function BluetoothDemo() { + const { isSupported, deviceName, error, requestDevice } = useBluetooth() + const [hideStaleError, setHideStaleError] = useState(false) + const visibleError = + !hideStaleError && error && !/not found|user gesture|no device selected/i.test(error) ? error : null + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + !isSupported + ? React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Web Bluetooth is not available in this browser or context (HTTPS is required).', + ) + : React.createElement( + React.Fragment, + null, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Open the native Bluetooth chooser and keep the latest selected device name in state.', + ), + visibleError + ? React.createElement( + 'p', + { style: { margin: '0 0 10px', color: 'var(--vp-c-danger-1, #b42318)' } }, + visibleError, + ) + : null, + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: 'max-content 1fr' } }, + React.createElement( + 'button', + { + type: 'button', + onClick: () => { + setHideStaleError(true) + void requestDevice({ + acceptAllDevices: true, + optionalServices: ['battery_service'], + }).finally(() => { + setHideStaleError(false) + }) + }, + }, + 'Choose device', + ), + React.createElement( + 'p', + { style: { margin: 0, alignSelf: 'center', color: 'var(--vp-c-text-2)' } }, + `Selected: ${deviceName ?? '-'}`, + ), + ), + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useBluetooth from '@dedalik/use-react/useBluetooth' + +export default function BluetoothDemo() { + const { isSupported, deviceName, error, requestDevice } = useBluetooth() + const [hideStaleError, setHideStaleError] = useState(false) + const visibleError = + !hideStaleError && error && !/not found|user gesture|no device selected/i.test(error) ? error : null + + if (!isSupported) { + return ( +

Web Bluetooth is not available in this browser or context (HTTPS is required).

+ ) + } + + return ( +
+

Open the native Bluetooth chooser and keep the latest selected device name in state.

+ {visibleError ?

{visibleError}

: null} + +
+ + +

Selected: {deviceName ?? '-'}

+
+
+ ) +}` + +export default BluetoothDemo diff --git a/docs/.vitepress/theme/react-demos/useBreakpoints.basic.ts b/docs/.vitepress/theme/react-demos/useBreakpoints.basic.ts new file mode 100644 index 0000000..efc4735 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useBreakpoints.basic.ts @@ -0,0 +1,83 @@ +import React from 'react' +import useBreakpoints from '@dedalik/use-react/useBreakpoints' + +const BREAKPOINTS = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, +} as const + +function BreakpointsDemo() { + const { width, current, active, greaterOrEqual, flags } = useBreakpoints(BREAKPOINTS) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Resize the window to see the current breakpoint, active list, and flags for each threshold.', + ), + React.createElement('p', { style: { margin: '10px 0 6px' } }, `Width: ${width}px - current: ${current ?? '-'}`), + React.createElement( + 'p', + { style: { margin: '0 0 6px', color: 'var(--vp-c-text-2)' } }, + `Active: ${active.length ? active.join(' -> ') : 'none'}`, + ), + React.createElement( + 'p', + { style: { margin: '0 0 10px', color: 'var(--vp-c-text-2)' } }, + `greaterOrEqual("md"): ${greaterOrEqual('md') ? 'true' : 'false'}`, + ), + React.createElement( + 'div', + { style: { display: 'grid', gap: 6 } }, + ...Object.keys(BREAKPOINTS).map((key) => + React.createElement( + 'p', + { key, style: { margin: 0 } }, + `${key} (${BREAKPOINTS[key as keyof typeof BREAKPOINTS]}px): ${flags[key as keyof typeof BREAKPOINTS] ? 'on' : 'off'}`, + ), + ), + ), + ) +} + +export const sourceJsx = `import useBreakpoints from '@dedalik/use-react/useBreakpoints' + +const BREAKPOINTS = { + sm: 640, + md: 768, + lg: 1024, + xl: 1280, +} as const + +export default function BreakpointsDemo() { + const { width, current, active, greaterOrEqual, flags } = useBreakpoints(BREAKPOINTS) + + return ( +
+

+ Resize the window to see the current breakpoint, active list, and flags for each threshold. +

+ +

{'Width: ' + width + 'px - current: ' + (current ?? '-')}

+

{'Active: ' + (active.length ? active.join(' -> ') : 'none')}

+

+ {'greaterOrEqual("md"): ' + (greaterOrEqual('md') ? 'true' : 'false')} +

+ +
+ {Object.keys(BREAKPOINTS).map((key) => ( +

+ {key} ({BREAKPOINTS[key as keyof typeof BREAKPOINTS]}px):{' '} + {flags[key as keyof typeof BREAKPOINTS] ? 'on' : 'off'} +

+ ))} +
+
+ ) +}` + +export default BreakpointsDemo diff --git a/docs/.vitepress/theme/react-demos/useBroadcastChannel.basic.ts b/docs/.vitepress/theme/react-demos/useBroadcastChannel.basic.ts new file mode 100644 index 0000000..cfd007b --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useBroadcastChannel.basic.ts @@ -0,0 +1,175 @@ +import React, { useEffect, useMemo, useState } from 'react' +import useBroadcastChannel from '@dedalik/use-react/useBroadcastChannel' + +type DemoMessage = { + from: string + text: string + at: number +} + +function BroadcastChannelDemo() { + const { isSupported, data, post, close } = useBroadcastChannel('demo:chat') + const [draft, setDraft] = useState('hello') + const [status, setStatus] = useState('open') + const [sentCount, setSentCount] = useState(0) + const [lastSent, setLastSent] = useState(null) + const [tabId] = useState(() => Math.random().toString(36).slice(2, 7)) + + useEffect(() => { + if (data) setStatus(`received from ${data.from}`) + }, [data]) + + const last = useMemo(() => { + if (!data) return 'No messages yet' + const time = new Date(data.at).toLocaleTimeString() + return `${data.text} (from ${data.from} at ${time})` + }, [data]) + + const send = () => { + const text = draft.trim() + if (!text) return + const message = { from: tabId, text, at: Date.now() } + post(message) + setLastSent(message) + setSentCount((value) => value + 1) + setStatus('message sent') + } + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Open this same page in two tabs. Send text in one tab and the other tab will receive it instantly.', + ), + !isSupported + ? React.createElement( + 'p', + { style: { margin: 0, color: 'var(--vp-c-danger-1, #b42318)' } }, + 'BroadcastChannel is not supported in this environment.', + ) + : React.createElement( + React.Fragment, + null, + React.createElement( + 'p', + { style: { margin: '0 0 8px' } }, + 'This tab id: ', + React.createElement('strong', null, tabId), + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: '1fr auto auto' } }, + React.createElement('input', { + value: draft, + onChange: (event) => setDraft(event.target.value), + placeholder: 'Message to broadcast', + }), + React.createElement('button', { type: 'button', onClick: send }, 'Send'), + React.createElement('button', { type: 'button', onClick: close }, 'Close'), + ), + React.createElement( + 'p', + { style: { margin: '8px 0 0', color: 'var(--vp-c-text-2)' } }, + 'Channel status: ', + React.createElement('strong', null, status), + ), + React.createElement( + 'p', + { style: { margin: '4px 0 0', color: 'var(--vp-c-text-2)' } }, + `Messages sent from this tab: ${sentCount}`, + ), + React.createElement( + 'p', + { style: { margin: '8px 0 0' } }, + 'Last received message (from another tab): ', + React.createElement('strong', null, last), + ), + React.createElement( + 'p', + { style: { margin: '4px 0 0', color: 'var(--vp-c-text-2)' } }, + 'Last sent message (this tab): ', + React.createElement('strong', null, lastSent ? `${lastSent.text}` : '-'), + ), + ), + ) +} + +export const sourceJsx = `import { useMemo, useState } from 'react' +import useBroadcastChannel from '@dedalik/use-react/useBroadcastChannel' + +type DemoMessage = { + from: string + text: string + at: number +} + +export default function BroadcastChannelDemo() { + const { isSupported, data, post, close } = useBroadcastChannel('demo:chat') + const [draft, setDraft] = useState('hello') + const [status, setStatus] = useState('open') + const [sentCount, setSentCount] = useState(0) + const [lastSent, setLastSent] = useState(null) + const [tabId] = useState(() => Math.random().toString(36).slice(2, 7)) + + const last = useMemo(() => { + if (!data) return 'No messages yet' + const time = new Date(data.at).toLocaleTimeString() + return \`\${data.text} (from \${data.from} at \${time})\` + }, [data]) + + React.useEffect(() => { + if (data) setStatus(\`received from \${data.from}\`) + }, [data]) + + const send = () => { + const text = draft.trim() + if (!text) return + const message = { from: tabId, text, at: Date.now() } + post(message) + setLastSent(message) + setSentCount((value) => value + 1) + setStatus('message sent') + } + + return ( +
+

+ Open this same page in two tabs. Send text in one tab and the other tab will receive it instantly. +

+ {!isSupported ? ( +

+ BroadcastChannel is not supported in this environment. +

+ ) : ( + <> +

+ This tab id: {tabId} +

+
+ setDraft(event.target.value)} placeholder='Message to broadcast' /> + + +
+

+ Channel status: {status} +

+

Messages sent from this tab: {sentCount}

+

+ Last received message (from another tab): {last} +

+

+ Last sent message (this tab): {lastSent ? lastSent.text : '-'} +

+ + )} +
+ ) +}` + +export default BroadcastChannelDemo diff --git a/docs/.vitepress/theme/react-demos/useBrowserLocation.basic.ts b/docs/.vitepress/theme/react-demos/useBrowserLocation.basic.ts new file mode 100644 index 0000000..7d5d175 --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useBrowserLocation.basic.ts @@ -0,0 +1,203 @@ +import React, { useMemo, useState } from 'react' +import useBrowserLocation from '@dedalik/use-react/useBrowserLocation' + +type DemoLocation = { + href: string + pathname: string + search: string + hash: string +} + +function readLocation(): DemoLocation { + if (typeof window === 'undefined') return { href: '', pathname: '', search: '', hash: '' } + return { + href: window.location.href, + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + } +} + +function BrowserLocationDemo() { + const loc = useBrowserLocation() + const [demoLoc, setDemoLoc] = useState(loc) + const [counter, setCounter] = useState(1) + + React.useEffect(() => { + setDemoLoc(loc) + }, [loc]) + + const pretty = useMemo(() => { + const q = demoLoc.search ? ` ${demoLoc.search}` : '' + const h = demoLoc.hash ? ` ${demoLoc.hash}` : '' + return `${demoLoc.pathname}${q}${h}`.trim() + }, [demoLoc.hash, demoLoc.pathname, demoLoc.search]) + + const applyUrl = (updater: (url: URL) => void) => { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + updater(url) + // Same pattern as useHash demo: update URL without triggering docs remount. + window.history.replaceState(window.history.state, '', url.toString()) + setDemoLoc(readLocation()) + } + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Tracks href/path/search/hash. Demo mutations use replaceState to avoid docs remount flicker.', + ), + React.createElement( + 'p', + { style: { margin: '0 0 8px', color: 'var(--vp-c-text-2)' } }, + `href: ${demoLoc.href || '-'}`, + ), + React.createElement( + 'p', + { style: { margin: '0 0 10px', color: 'var(--vp-c-text-2)' } }, + `path + query + hash: ${pretty || '-'}`, + ), + React.createElement( + 'div', + { + className: 'hook-demo-toolbar', + style: { gridTemplateColumns: 'max-content max-content max-content max-content' }, + }, + React.createElement( + 'button', + { + type: 'button', + onClick: () => applyUrl((url) => void url.searchParams.set('demoLoc', String(counter))), + }, + 'Set query', + ), + React.createElement( + 'button', + { + type: 'button', + onClick: () => + applyUrl((url) => { + url.hash = `demo-${counter}` + }), + }, + 'Set hash', + ), + React.createElement( + 'button', + { + type: 'button', + onClick: () => + applyUrl((url) => { + url.searchParams.delete('demoLoc') + url.hash = '' + }), + }, + 'Clear', + ), + React.createElement( + 'button', + { type: 'button', onClick: () => setCounter((value) => value + 1) }, + `Bump (${counter})`, + ), + ), + React.createElement( + 'p', + { style: { margin: '10px 0 0', color: 'var(--vp-c-text-2)' } }, + 'Back/forward or manual URL edits still update from the hook listeners.', + ), + ) +} + +export const sourceJsx = `import { useMemo, useState } from 'react' +import useBrowserLocation from '@dedalik/use-react/useBrowserLocation' + +type DemoLocation = { + href: string + pathname: string + search: string + hash: string +} + +function readLocation(): DemoLocation { + if (typeof window === 'undefined') return { href: '', pathname: '', search: '', hash: '' } + return { + href: window.location.href, + pathname: window.location.pathname, + search: window.location.search, + hash: window.location.hash, + } +} + +export default function BrowserLocationDemo() { + const loc = useBrowserLocation() + const [demoLoc, setDemoLoc] = useState(loc) + const [counter, setCounter] = useState(1) + + React.useEffect(() => { + setDemoLoc(loc) + }, [loc]) + + const pretty = useMemo(() => { + const q = demoLoc.search ? \` \${demoLoc.search}\` : '' + const h = demoLoc.hash ? \` \${demoLoc.hash}\` : '' + return \`\${demoLoc.pathname}\${q}\${h}\`.trim() + }, [demoLoc.hash, demoLoc.pathname, demoLoc.search]) + + const applyUrl = (updater: (url: URL) => void) => { + if (typeof window === 'undefined') return + const url = new URL(window.location.href) + updater(url) + window.history.replaceState(window.history.state, '', url.toString()) + setDemoLoc(readLocation()) + } + + return ( +
+

Tracks href/path/search/hash. Demo mutations use replaceState to avoid docs remount flicker.

+

href: {demoLoc.href || '-'}

+

path + query + hash: {pretty || '-'}

+ +
+ + + + +
+ +

+ Back/forward or manual URL edits still update from the hook listeners. +

+
+ ) +}` + +export default BrowserLocationDemo diff --git a/docs/.vitepress/theme/react-demos/useClipboardItems.basic.ts b/docs/.vitepress/theme/react-demos/useClipboardItems.basic.ts new file mode 100644 index 0000000..49048de --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useClipboardItems.basic.ts @@ -0,0 +1,101 @@ +import React from 'react' +import useClipboardItems from '@dedalik/use-react/useClipboardItems' + +function ClipboardItemsDemo() { + const { isSupported, items, error, read } = useClipboardItems() + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Reads rich clipboard entries via navigator.clipboard.read and lists item MIME types.', + ), + !isSupported + ? React.createElement( + 'p', + { style: { margin: '10px 0 0', color: 'var(--vp-c-text-2)' } }, + 'navigator.clipboard.read is not available in this browser/context.', + ) + : React.createElement( + React.Fragment, + null, + error + ? React.createElement('p', { style: { margin: '0 0 10px', color: 'var(--vp-c-danger-1, #b42318)' } }, error) + : null, + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: 'max-content 1fr' } }, + React.createElement('button', { type: 'button', onClick: () => void read() }, 'Read clipboard'), + React.createElement( + 'p', + { style: { margin: 0, alignSelf: 'center', color: 'var(--vp-c-text-2)' } }, + `Items: ${items.length}`, + ), + ), + items.length === 0 + ? React.createElement( + 'p', + { style: { margin: '10px 0 0', color: 'var(--vp-c-text-2)' } }, + 'No items yet. Copy an image/rich content and then click Read clipboard.', + ) + : React.createElement( + 'ul', + { style: { margin: '10px 0 0', paddingInlineStart: 18 } }, + items.map((item, index) => + React.createElement( + 'li', + { key: `${index}-${item.types.join('|')}` }, + `Item ${index + 1}: ${item.types.join(', ') || '(no types)'}`, + ), + ), + ), + ), + ) +} + +export const sourceJsx = `import useClipboardItems from '@dedalik/use-react/useClipboardItems' + +export default function ClipboardItemsDemo() { + const { isSupported, items, error, read } = useClipboardItems() + + return ( +
+

+ Reads rich clipboard entries via navigator.clipboard.read and lists item MIME types. +

+ + {!isSupported ? ( +

+ navigator.clipboard.read is not available in this browser/context. +

+ ) : ( + <> + {error ?

{error}

: null} + +
+ +

Items: {items.length}

+
+ + {items.length === 0 ? ( +

+ No items yet. Copy an image/rich content and then click Read clipboard. +

+ ) : ( +
    + {items.map((item, index) => ( +
  • Item {index + 1}: {item.types.join(', ') || '(no types)'}
  • + ))} +
+ )} + + )} +
+ ) +}` + +export default ClipboardItemsDemo diff --git a/docs/.vitepress/theme/react-demos/useColorMode.basic.ts b/docs/.vitepress/theme/react-demos/useColorMode.basic.ts new file mode 100644 index 0000000..d42677d --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useColorMode.basic.ts @@ -0,0 +1,96 @@ +import React from 'react' +import useColorMode from '@dedalik/use-react/useColorMode' + +function ColorModeDemo() { + const { mode, isDark, setMode, toggle } = useColorMode({ + storageKey: 'docs:color-mode-demo', + attribute: 'class', + element: typeof document !== 'undefined' ? document.documentElement : null, + }) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Persists light/dark/auto mode and synchronizes resolved theme class on the document element.', + ), + React.createElement( + 'div', + { + style: { + marginTop: 10, + padding: 14, + borderRadius: 10, + border: `1px solid ${isDark ? '#334155' : '#cbd5e1'}`, + background: isDark ? '#0f172a' : '#f8fafc', + color: isDark ? '#e2e8f0' : '#0f172a', + }, + }, + React.createElement('p', { style: { margin: '0 0 8px' } }, `Mode: ${mode}`), + React.createElement( + 'p', + { style: { margin: '0 0 10px', opacity: 0.85 } }, + `Resolved dark: ${isDark ? 'yes' : 'no'}`, + ), + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { gridTemplateColumns: 'repeat(4, max-content)' } }, + React.createElement('button', { type: 'button', onClick: () => setMode('light') }, 'Light'), + React.createElement('button', { type: 'button', onClick: () => setMode('dark') }, 'Dark'), + React.createElement('button', { type: 'button', onClick: () => setMode('auto') }, 'Auto'), + React.createElement('button', { type: 'button', onClick: toggle }, 'Toggle'), + ), + ), + ) +} + +export const sourceJsx = `import useColorMode from '@dedalik/use-react/useColorMode' + +export default function ColorModeDemo() { + const { mode, isDark, setMode, toggle } = useColorMode({ + storageKey: 'docs:color-mode-demo', + attribute: 'class', + element: typeof document !== 'undefined' ? document.documentElement : null, + }) + + return ( +
+

+ Persists light/dark/auto mode and synchronizes resolved theme class on the document element. +

+ +
+

{'Mode: ' + mode}

+

{'Resolved dark: ' + (isDark ? 'yes' : 'no')}

+ +
+ + + + +
+
+
+ ) +}` + +export default ColorModeDemo diff --git a/docs/.vitepress/theme/react-demos/useCopyToClipboard.basic.ts b/docs/.vitepress/theme/react-demos/useCopyToClipboard.basic.ts new file mode 100644 index 0000000..91fea8e --- /dev/null +++ b/docs/.vitepress/theme/react-demos/useCopyToClipboard.basic.ts @@ -0,0 +1,92 @@ +import React, { useState } from 'react' +import useCopyToClipboard from '@dedalik/use-react/useCopyToClipboard' + +function CopyToClipboardDemo() { + const [draft, setDraft] = useState('Hello from use-react') + const [copiedText, copy] = useCopyToClipboard() + const [lastOk, setLastOk] = useState(null) + + return React.createElement( + 'div', + { className: 'hook-demo-surface' }, + React.createElement( + 'p', + { className: 'hook-demo-hint' }, + 'Writes text to navigator.clipboard and keeps the last successful copied value in hook state.', + ), + React.createElement('textarea', { + rows: 3, + style: { width: '100%', marginTop: 6, fontFamily: 'var(--vp-font-family-mono)' }, + value: draft, + onChange: (event) => setDraft(event.target.value), + }), + React.createElement( + 'div', + { className: 'hook-demo-toolbar', style: { marginTop: 10, gridTemplateColumns: 'max-content max-content' } }, + React.createElement( + 'button', + { + type: 'button', + onClick: async () => { + const ok = await copy(draft) + setLastOk(ok) + }, + }, + 'Copy to clipboard', + ), + React.createElement( + 'p', + { style: { margin: 0, alignSelf: 'center', color: 'var(--vp-c-text-2)' } }, + `Last call: ${lastOk === null ? '-' : lastOk ? 'success' : 'failed'}`, + ), + ), + React.createElement( + 'p', + { style: { margin: '10px 0 0', color: 'var(--vp-c-text-2)' } }, + `Hook state: ${copiedText || '-'}`, + ), + ) +} + +export const sourceJsx = `import { useState } from 'react' +import useCopyToClipboard from '@dedalik/use-react/useCopyToClipboard' + +export default function CopyToClipboardDemo() { + const [draft, setDraft] = useState('Hello from use-react') + const [copiedText, copy] = useCopyToClipboard() + const [lastOk, setLastOk] = useState(null) + + return ( +
+

+ Writes text to navigator.clipboard and keeps the last successful copied value in hook state. +

+ +