@fabio.caffarello/react-design-system 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -118,9 +118,10 @@ Consumers can import from:
118
118
 
119
119
  - `@fabio.caffarello/react-design-system` — the default entry. Everything: providers, hooks, all primitives, components, layouts, tokens. The emitted bundle carries `"use client";` so RSC frameworks (Next App Router 15+/16, etc.) accept it from a Server Component without crashing on `React.createContext`. The whole module is treated as a client boundary by the consumer's bundler.
120
120
  - `@fabio.caffarello/react-design-system/server` — the opt-in server entry (issue #150). Re-exports only the components whose render tree is safe to evaluate inside a React Server Component: presentational primitives (`Text`, `Skeleton`, `Spinner`, `Progress`, `Chip`, `ErrorMessage`, `Info`), layout (`Container`, `Stack`), and structural / informational components (`Breadcrumb`, `Timeline`, `AutocompleteOption`, `DialogHeader`, `DialogFooter`, `DrawerHeader`, `DrawerFooter`, `HeaderActions`, `HeaderNavigation`, `MenuSeparator`, `NavbarSeparator`, `TableCell`). The bundle carries NO `"use client"` directive, so importing from it in a Server Component does NOT cross a client boundary — useful for SEO-critical / first-paint-critical routes where the shell shouldn't ship JS to the client.
121
+ - `@fabio.caffarello/react-design-system/hooks` — the granular public-hooks entry (issue #203). Re-exports only the public hooks (today: `useScrollSpy`) as a tiny standalone client bundle (<1KB) with every dependency external. Use it when a route's client component needs a hook but nothing else from RDS: importing a hook from the main entry pulls the whole pre-bundled barrel into the route's client JS (+277KB minified measured on a Next 16 route), because the single-file `"use client"` bundle is opaque to the consumer bundler's tree-shaking. The same hooks remain available from the main entry for back-compat.
121
122
  - `@fabio.caffarello/react-design-system/styles` — the bundled CSS. Same stylesheet regardless of which JS entry you import.
122
123
 
123
- The two JS entries can be mixed in one Server Component — `import { Text } from "…/server"; import { Button } from "…"` is the normal pattern. The server entry is purely additive; consumers who never use it see no behavioural change.
124
+ The JS entries can be mixed in one Server Component — `import { Text } from "…/server"; import { Button } from "…"` is the normal pattern. The server entry is purely additive; consumers who never use it see no behavioural change.
124
125
 
125
126
  ```tsx
126
127
  // app/profile/page.tsx — a Next 16 App Router Server Component.
@@ -141,7 +142,7 @@ export default function Profile() {
141
142
  }
142
143
  ```
143
144
 
144
- Sub-entries (`/primitives`, `/components`, `/tokens`, `/providers`) were removed in Phase 13d — they had been silently broken for external consumers since v1.0.0 because cross-chunk references to `cva` and other shared utilities failed at runtime. A single main entry collapses that class of bug structurally; tree-shaking still works at the named-export level via any modern bundler. The `./server` entry sidesteps the same regression by being its OWN independent Vite build with all third-party deps externalised — no chunks are shared between the two entries.
145
+ Sub-entries (`/primitives`, `/components`, `/tokens`, `/providers`) were removed in Phase 13d — they had been silently broken for external consumers since v1.0.0 because cross-chunk references to `cva` and other shared utilities failed at runtime. A single main entry collapses that class of bug structurally; tree-shaking still works at the named-export level via any modern bundler. The `./server` and `./hooks` entries sidestep the same regression by each being their OWN independent Vite build with all third-party deps externalised — no chunks are shared between entries.
145
146
 
146
147
  ## Working with Claude Code
147
148
 
@@ -0,0 +1,2 @@
1
+ "use client";"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const l=require("react");function a(n,u={}){const{rootMargin:r="0px 0px -50% 0px",threshold:o=0}=u,[d,f]=l.useState(null),g=n.join("|");return l.useEffect(()=>{if(typeof window=="undefined"||typeof IntersectionObserver=="undefined")return;const i=n.map(e=>document.getElementById(e)).filter(e=>e!==null);if(i.length===0)return;const s=new IntersectionObserver(e=>{const c=e.filter(t=>t.isIntersecting).sort((t,p)=>t.boundingClientRect.top-p.boundingClientRect.top);c.length>0&&f(c[0].target.id)},{rootMargin:r,threshold:o});return i.forEach(e=>s.observe(e)),()=>s.disconnect()},[g,r,o]),d}exports.useScrollSpy=a;
2
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.cjs","sources":["../../src/ui/hooks/useScrollSpy.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Options for `useScrollSpy`.\n */\nexport interface UseScrollSpyOptions {\n /**\n * `IntersectionObserver` `rootMargin`. Shrinks the effective viewport\n * the observer reports on. The default `\"0px 0px -50% 0px\"` shrinks\n * the bottom edge by half — a section is considered \"in view\" only\n * when part of it sits in the upper half of the viewport, which is\n * the canonical \"table-of-contents follows the scroll\" behaviour. To\n * compensate for a sticky header, prefix the top with a negative\n * pixel value, e.g. `\"-56px 0px -50% 0px\"`.\n *\n * @default \"0px 0px -50% 0px\"\n */\n rootMargin?: string;\n /**\n * `IntersectionObserver` `threshold`. With the default `0`, the\n * observer fires when any part of the target enters the (margin-\n * shrunken) viewport.\n *\n * @default 0\n */\n threshold?: number | number[];\n}\n\n/**\n * Track which section of a long scroll surface is currently in view,\n * suitable for a table-of-contents nav that highlights the active section\n * (the classic \"scroll spy\" pattern).\n *\n * The hook resolves each `id` to a DOM element via\n * `document.getElementById`, observes those elements with a single\n * `IntersectionObserver`, and returns the **id of the topmost visible\n * section**. Returns `null` when nothing has reported as visible yet —\n * including on the server, during the first render before the effect\n * runs, and any frame where no observed section intersects the\n * (margin-shrunken) viewport.\n *\n * ### Behavioural contract\n *\n * - **Return value.** `string | null`. `null` until at least one section\n * has been reported intersecting; never falls back to a \"first id\"\n * heuristic. Consumers that want a default highlight should fall back\n * themselves: `active ?? ids[0]`.\n * - **Tie-breaking.** When multiple sections intersect simultaneously,\n * the hook picks the one **closest to the top of the viewport**\n * (smallest `boundingClientRect.top`). This matches the user's\n * expectation that scrolling DOWN advances the highlight forward, not\n * backward.\n * - **Missing ids.** An id that resolves to no element is skipped\n * silently. The observer is created only when at least one element\n * resolves; an empty `ids` array (or one with all-missing ids) leaves\n * `activeId` as `null` and creates no observer.\n * - **Cleanup.** The observer is disconnected on unmount and when the\n * `ids` set changes, before a new observer is created. No leaks.\n * - **Re-observation on `ids` change.** The hook detects changes via a\n * string sentinel `ids.join(\"|\")`. Pass `ids` as a stable reference\n * (constant module-scope array, or `useMemo`) to avoid recreating the\n * observer on every render. The hook does not memoise `ids` for you\n * because the consumer typically already knows whether the array is\n * stable.\n * - **SSR safety.** `IntersectionObserver` and `document` are accessed\n * only inside `useEffect`, which never runs on the server. The hook\n * returns `null` during server rendering and the first client render\n * pre-commit. A `typeof window` guard inside the effect protects\n * older runtimes that evaluate `useEffect` outside a browser.\n * - **`useState` initial value.** Always `null`. Returning the first id\n * would highlight a section that the user has not yet seen and\n * contradict the SSR/hydration contract.\n *\n * ### Why this lives in the design system as a hook, not as a component\n *\n * The visual surface (a sticky nav with highlighted active item) is\n * already covered by `Navigation` + `NavLink` with the `active` prop. A\n * `<ScrollSpy>` component would fuse behaviour and visual, restrict\n * layout choice, and couple to a sibling component (`SectionCard`) via\n * an opaque id-string convention. As a hook the consumer keeps the\n * `ids` constant in one place and composes the nav however they want:\n * vertical, horizontal, sticky, in a drawer, etc.\n *\n * @example\n * ```tsx\n * \"use client\";\n * import { useScrollSpy, Navigation, NavLink } from \"@fabio.caffarello/react-design-system\";\n *\n * const SECTIONS = [\"intro\", \"votos\", \"gastos\"];\n *\n * function ProfileToc() {\n * const active = useScrollSpy(SECTIONS, { rootMargin: \"-56px 0px -50% 0px\" });\n * return (\n * <nav className=\"sticky top-14\">\n * <Navigation orientation=\"vertical\">\n * {SECTIONS.map((id) => (\n * <NavLink\n * key={id}\n * href={`#${id}`}\n * active={id === active}\n * aria-current={id === active ? \"location\" : undefined}\n * >\n * {id}\n * </NavLink>\n * ))}\n * </Navigation>\n * </nav>\n * );\n * }\n * ```\n *\n * @param ids - Element ids to observe, in document order. Stable\n * reference recommended (constant or `useMemo`).\n * @param options - Optional `IntersectionObserver` overrides — see\n * {@link UseScrollSpyOptions}.\n * @returns The id of the topmost visible section, or `null` when\n * nothing is reported visible yet.\n */\nexport function useScrollSpy(\n ids: string[],\n options: UseScrollSpyOptions = {},\n): string | null {\n const { rootMargin = \"0px 0px -50% 0px\", threshold = 0 } = options;\n const [activeId, setActiveId] = useState<string | null>(null);\n\n // The dependency sentinel: `ids.join(\"|\")` collapses a stable array\n // content to a stable string, so passing `[\"a\",\"b\",\"c\"]` literally on\n // every render does not recreate the observer. The pipe is safe as a\n // separator because HTML id syntax does not allow it (per the HTML\n // spec, an id may not contain whitespace; pipes are not whitespace\n // but are also not produced by any conventional id-generation\n // strategy in this codebase). If a consumer ever generates ids with\n // pipes, `ids` should be memoised by the consumer and the sentinel\n // would still detect the array-identity change via render-trigger.\n const idsKey = ids.join(\"|\");\n\n useEffect(() => {\n if (\n typeof window === \"undefined\" ||\n typeof IntersectionObserver === \"undefined\"\n ) {\n return;\n }\n\n const targets = ids\n .map((id) => document.getElementById(id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (targets.length === 0) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);\n if (visible.length > 0) {\n setActiveId(visible[0].target.id);\n }\n },\n { rootMargin, threshold },\n );\n\n targets.forEach((el) => observer.observe(el));\n return () => observer.disconnect();\n // The dependency list intentionally watches the string sentinel\n // (idsKey), not the array itself, so a fresh array with identical\n // content does NOT recreate the observer. rootMargin/threshold are\n // primitive comparisons.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [idsKey, rootMargin, threshold]);\n\n return activeId;\n}\n"],"names":["useScrollSpy","ids","options","rootMargin","threshold","activeId","setActiveId","useState","idsKey","useEffect","targets","id","el","observer","entries","visible","entry","a","b"],"mappings":"sHAwHO,SAASA,EACdC,EACAC,EAA+B,GAChB,CACf,KAAM,CAAE,WAAAC,EAAa,mBAAoB,UAAAC,EAAY,GAAMF,EACrD,CAACG,EAAUC,CAAW,EAAIC,EAAAA,SAAwB,IAAI,EAWtDC,EAASP,EAAI,KAAK,GAAG,EAE3BQ,OAAAA,EAAAA,UAAU,IAAM,CACd,GACE,OAAO,QAAW,aAClB,OAAO,sBAAyB,YAEhC,OAGF,MAAMC,EAAUT,EACb,IAAKU,GAAO,SAAS,eAAeA,CAAE,CAAC,EACvC,OAAQC,GAA0BA,IAAO,IAAI,EAEhD,GAAIF,EAAQ,SAAW,EAAG,OAE1B,MAAMG,EAAW,IAAI,qBAClBC,GAAY,CACX,MAAMC,EAAUD,EACb,OAAQE,GAAUA,EAAM,cAAc,EACtC,KAAK,CAACC,EAAGC,IAAMD,EAAE,mBAAmB,IAAMC,EAAE,mBAAmB,GAAG,EACjEH,EAAQ,OAAS,GACnBT,EAAYS,EAAQ,CAAC,EAAE,OAAO,EAAE,CAEpC,EACA,CAAE,WAAAZ,EAAY,UAAAC,CAAA,CAAU,EAG1B,OAAAM,EAAQ,QAASE,GAAOC,EAAS,QAAQD,CAAE,CAAC,EACrC,IAAMC,EAAS,WAAA,CAMxB,EAAG,CAACL,EAAQL,EAAYC,CAAS,CAAC,EAE3BC,CACT"}
@@ -0,0 +1,23 @@
1
+ "use client";
2
+ import { useState as g, useEffect as a } from "react";
3
+ function v(n, l = {}) {
4
+ const { rootMargin: r = "0px 0px -50% 0px", threshold: o = 0 } = l, [u, d] = g(null), f = n.join("|");
5
+ return a(() => {
6
+ if (typeof window == "undefined" || typeof IntersectionObserver == "undefined")
7
+ return;
8
+ const i = n.map((e) => document.getElementById(e)).filter((e) => e !== null);
9
+ if (i.length === 0) return;
10
+ const s = new IntersectionObserver(
11
+ (e) => {
12
+ const c = e.filter((t) => t.isIntersecting).sort((t, p) => t.boundingClientRect.top - p.boundingClientRect.top);
13
+ c.length > 0 && d(c[0].target.id);
14
+ },
15
+ { rootMargin: r, threshold: o }
16
+ );
17
+ return i.forEach((e) => s.observe(e)), () => s.disconnect();
18
+ }, [f, r, o]), u;
19
+ }
20
+ export {
21
+ v as useScrollSpy
22
+ };
23
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sources":["../../src/ui/hooks/useScrollSpy.ts"],"sourcesContent":["\"use client\";\n\nimport { useEffect, useState } from \"react\";\n\n/**\n * Options for `useScrollSpy`.\n */\nexport interface UseScrollSpyOptions {\n /**\n * `IntersectionObserver` `rootMargin`. Shrinks the effective viewport\n * the observer reports on. The default `\"0px 0px -50% 0px\"` shrinks\n * the bottom edge by half — a section is considered \"in view\" only\n * when part of it sits in the upper half of the viewport, which is\n * the canonical \"table-of-contents follows the scroll\" behaviour. To\n * compensate for a sticky header, prefix the top with a negative\n * pixel value, e.g. `\"-56px 0px -50% 0px\"`.\n *\n * @default \"0px 0px -50% 0px\"\n */\n rootMargin?: string;\n /**\n * `IntersectionObserver` `threshold`. With the default `0`, the\n * observer fires when any part of the target enters the (margin-\n * shrunken) viewport.\n *\n * @default 0\n */\n threshold?: number | number[];\n}\n\n/**\n * Track which section of a long scroll surface is currently in view,\n * suitable for a table-of-contents nav that highlights the active section\n * (the classic \"scroll spy\" pattern).\n *\n * The hook resolves each `id` to a DOM element via\n * `document.getElementById`, observes those elements with a single\n * `IntersectionObserver`, and returns the **id of the topmost visible\n * section**. Returns `null` when nothing has reported as visible yet —\n * including on the server, during the first render before the effect\n * runs, and any frame where no observed section intersects the\n * (margin-shrunken) viewport.\n *\n * ### Behavioural contract\n *\n * - **Return value.** `string | null`. `null` until at least one section\n * has been reported intersecting; never falls back to a \"first id\"\n * heuristic. Consumers that want a default highlight should fall back\n * themselves: `active ?? ids[0]`.\n * - **Tie-breaking.** When multiple sections intersect simultaneously,\n * the hook picks the one **closest to the top of the viewport**\n * (smallest `boundingClientRect.top`). This matches the user's\n * expectation that scrolling DOWN advances the highlight forward, not\n * backward.\n * - **Missing ids.** An id that resolves to no element is skipped\n * silently. The observer is created only when at least one element\n * resolves; an empty `ids` array (or one with all-missing ids) leaves\n * `activeId` as `null` and creates no observer.\n * - **Cleanup.** The observer is disconnected on unmount and when the\n * `ids` set changes, before a new observer is created. No leaks.\n * - **Re-observation on `ids` change.** The hook detects changes via a\n * string sentinel `ids.join(\"|\")`. Pass `ids` as a stable reference\n * (constant module-scope array, or `useMemo`) to avoid recreating the\n * observer on every render. The hook does not memoise `ids` for you\n * because the consumer typically already knows whether the array is\n * stable.\n * - **SSR safety.** `IntersectionObserver` and `document` are accessed\n * only inside `useEffect`, which never runs on the server. The hook\n * returns `null` during server rendering and the first client render\n * pre-commit. A `typeof window` guard inside the effect protects\n * older runtimes that evaluate `useEffect` outside a browser.\n * - **`useState` initial value.** Always `null`. Returning the first id\n * would highlight a section that the user has not yet seen and\n * contradict the SSR/hydration contract.\n *\n * ### Why this lives in the design system as a hook, not as a component\n *\n * The visual surface (a sticky nav with highlighted active item) is\n * already covered by `Navigation` + `NavLink` with the `active` prop. A\n * `<ScrollSpy>` component would fuse behaviour and visual, restrict\n * layout choice, and couple to a sibling component (`SectionCard`) via\n * an opaque id-string convention. As a hook the consumer keeps the\n * `ids` constant in one place and composes the nav however they want:\n * vertical, horizontal, sticky, in a drawer, etc.\n *\n * @example\n * ```tsx\n * \"use client\";\n * import { useScrollSpy, Navigation, NavLink } from \"@fabio.caffarello/react-design-system\";\n *\n * const SECTIONS = [\"intro\", \"votos\", \"gastos\"];\n *\n * function ProfileToc() {\n * const active = useScrollSpy(SECTIONS, { rootMargin: \"-56px 0px -50% 0px\" });\n * return (\n * <nav className=\"sticky top-14\">\n * <Navigation orientation=\"vertical\">\n * {SECTIONS.map((id) => (\n * <NavLink\n * key={id}\n * href={`#${id}`}\n * active={id === active}\n * aria-current={id === active ? \"location\" : undefined}\n * >\n * {id}\n * </NavLink>\n * ))}\n * </Navigation>\n * </nav>\n * );\n * }\n * ```\n *\n * @param ids - Element ids to observe, in document order. Stable\n * reference recommended (constant or `useMemo`).\n * @param options - Optional `IntersectionObserver` overrides — see\n * {@link UseScrollSpyOptions}.\n * @returns The id of the topmost visible section, or `null` when\n * nothing is reported visible yet.\n */\nexport function useScrollSpy(\n ids: string[],\n options: UseScrollSpyOptions = {},\n): string | null {\n const { rootMargin = \"0px 0px -50% 0px\", threshold = 0 } = options;\n const [activeId, setActiveId] = useState<string | null>(null);\n\n // The dependency sentinel: `ids.join(\"|\")` collapses a stable array\n // content to a stable string, so passing `[\"a\",\"b\",\"c\"]` literally on\n // every render does not recreate the observer. The pipe is safe as a\n // separator because HTML id syntax does not allow it (per the HTML\n // spec, an id may not contain whitespace; pipes are not whitespace\n // but are also not produced by any conventional id-generation\n // strategy in this codebase). If a consumer ever generates ids with\n // pipes, `ids` should be memoised by the consumer and the sentinel\n // would still detect the array-identity change via render-trigger.\n const idsKey = ids.join(\"|\");\n\n useEffect(() => {\n if (\n typeof window === \"undefined\" ||\n typeof IntersectionObserver === \"undefined\"\n ) {\n return;\n }\n\n const targets = ids\n .map((id) => document.getElementById(id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (targets.length === 0) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n const visible = entries\n .filter((entry) => entry.isIntersecting)\n .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);\n if (visible.length > 0) {\n setActiveId(visible[0].target.id);\n }\n },\n { rootMargin, threshold },\n );\n\n targets.forEach((el) => observer.observe(el));\n return () => observer.disconnect();\n // The dependency list intentionally watches the string sentinel\n // (idsKey), not the array itself, so a fresh array with identical\n // content does NOT recreate the observer. rootMargin/threshold are\n // primitive comparisons.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [idsKey, rootMargin, threshold]);\n\n return activeId;\n}\n"],"names":["useScrollSpy","ids","options","rootMargin","threshold","activeId","setActiveId","useState","idsKey","useEffect","targets","id","el","observer","entries","visible","entry","a","b"],"mappings":";;AAwHO,SAASA,EACdC,GACAC,IAA+B,IAChB;AACf,QAAM,EAAE,YAAAC,IAAa,oBAAoB,WAAAC,IAAY,MAAMF,GACrD,CAACG,GAAUC,CAAW,IAAIC,EAAwB,IAAI,GAWtDC,IAASP,EAAI,KAAK,GAAG;AAE3B,SAAAQ,EAAU,MAAM;AACd,QACE,OAAO,UAAW,eAClB,OAAO,wBAAyB;AAEhC;AAGF,UAAMC,IAAUT,EACb,IAAI,CAACU,MAAO,SAAS,eAAeA,CAAE,CAAC,EACvC,OAAO,CAACC,MAA0BA,MAAO,IAAI;AAEhD,QAAIF,EAAQ,WAAW,EAAG;AAE1B,UAAMG,IAAW,IAAI;AAAA,MACnB,CAACC,MAAY;AACX,cAAMC,IAAUD,EACb,OAAO,CAACE,MAAUA,EAAM,cAAc,EACtC,KAAK,CAACC,GAAGC,MAAMD,EAAE,mBAAmB,MAAMC,EAAE,mBAAmB,GAAG;AACrE,QAAIH,EAAQ,SAAS,KACnBT,EAAYS,EAAQ,CAAC,EAAE,OAAO,EAAE;AAAA,MAEpC;AAAA,MACA,EAAE,YAAAZ,GAAY,WAAAC,EAAA;AAAA,IAAU;AAG1B,WAAAM,EAAQ,QAAQ,CAACE,MAAOC,EAAS,QAAQD,CAAE,CAAC,GACrC,MAAMC,EAAS,WAAA;AAAA,EAMxB,GAAG,CAACL,GAAQL,GAAYC,CAAS,CAAC,GAE3BC;AACT;"}