@godxjp/ui 28.7.0 → 28.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.
Files changed (85) hide show
  1. package/dist/components/data-display/index.d.ts +2 -0
  2. package/dist/components/data-display/index.js +2 -0
  3. package/dist/components/data-display/marquee.d.ts +16 -0
  4. package/dist/components/data-display/marquee.js +155 -0
  5. package/dist/components/general/reveal.d.ts +23 -2
  6. package/dist/components/general/reveal.js +37 -7
  7. package/dist/components/general/typography.d.ts +4 -1
  8. package/dist/components/general/typography.js +14 -1
  9. package/dist/components/layout/affix.d.ts +86 -0
  10. package/dist/components/layout/affix.js +187 -0
  11. package/dist/components/layout/index.d.ts +4 -0
  12. package/dist/components/layout/index.js +4 -0
  13. package/dist/components/layout/legal-document-shell.js +4 -3
  14. package/dist/components/layout/masonry.d.ts +74 -0
  15. package/dist/components/layout/masonry.js +214 -0
  16. package/dist/components/layout/page-container.js +5 -20
  17. package/dist/components/navigation/anchor.d.ts +64 -0
  18. package/dist/components/navigation/anchor.js +284 -0
  19. package/dist/components/navigation/index.d.ts +4 -0
  20. package/dist/components/navigation/index.js +4 -0
  21. package/dist/components/navigation/mega-menu.d.ts +21 -0
  22. package/dist/components/navigation/mega-menu.js +526 -0
  23. package/dist/contracts/measurement.json +1 -1
  24. package/dist/i18n/messages/en.json +517 -0
  25. package/dist/i18n/messages/ja.json +513 -0
  26. package/dist/i18n/messages/vi.json +513 -0
  27. package/dist/lib/hooks.d.ts +68 -0
  28. package/dist/lib/hooks.js +52 -0
  29. package/dist/lib/platform.d.ts +14 -0
  30. package/dist/lib/platform.js +10 -1
  31. package/dist/lib/utils.d.ts +1 -1
  32. package/dist/lib/utils.js +3 -2
  33. package/dist/props/components/data-display.prop.d.ts +95 -1
  34. package/dist/props/components/general.prop.d.ts +47 -3
  35. package/dist/props/components/layout.prop.d.ts +194 -0
  36. package/dist/props/components/navigation.prop.d.ts +263 -0
  37. package/dist/props/registry.d.ts +359 -4
  38. package/dist/props/registry.js +472 -3
  39. package/dist/props/vocabulary/index.d.ts +1 -1
  40. package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
  41. package/dist/styles/control.css +5 -6
  42. package/dist/styles/data-display-layout.css +2 -1
  43. package/dist/styles/density.css +4 -0
  44. package/dist/styles/layout.css +79 -0
  45. package/dist/styles/motion.css +121 -1
  46. package/dist/styles/navigation-layout.css +397 -1
  47. package/dist/styles/shell-layout.css +3 -0
  48. package/dist/styles/text-layout.css +52 -4
  49. package/dist/tokens/base.css +5 -0
  50. package/dist/tokens/components/affix.css +7 -0
  51. package/dist/tokens/components/anchor.css +17 -0
  52. package/dist/tokens/components/control.css +3 -3
  53. package/dist/tokens/components/form.css +1 -1
  54. package/dist/tokens/components/marquee.css +7 -0
  55. package/dist/tokens/components/masonry.css +6 -0
  56. package/dist/tokens/components/mega-menu.css +62 -0
  57. package/dist/tokens/components/shell.css +3 -0
  58. package/dist/tokens/foundation.css +11 -0
  59. package/dist/tokens/semantic/layout.css +7 -0
  60. package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
  61. package/docs/DESIGN-AUTHORITY.md +99 -18
  62. package/docs/FRAME-COVERAGE-REPORT.md +7 -2
  63. package/docs/data-display/marquee.tsx +254 -0
  64. package/docs/foundation/_theme-editor-scope.ts +222 -0
  65. package/docs/foundation/density.tsx +12 -2
  66. package/docs/foundation/spacing.tsx +5 -0
  67. package/docs/foundation/theme-editor.tsx +645 -0
  68. package/docs/general/activity.tsx +65 -0
  69. package/docs/general/reveal.tsx +290 -22
  70. package/docs/general/typography.tsx +91 -1
  71. package/docs/layout/affix.tsx +209 -0
  72. package/docs/layout/masonry.tsx +291 -0
  73. package/docs/navigation/anchor.tsx +285 -0
  74. package/docs/navigation/mega-menu-panel.tsx +86 -0
  75. package/docs/navigation/mega-menu.tsx +254 -0
  76. package/docs/roadmap/website-components.md +779 -0
  77. package/docs/showcase/acme-website.tsx +75 -39
  78. package/docs/showcase/futurelastic-web.tsx +91 -49
  79. package/docs/showcase/marketing-page.tsx +885 -0
  80. package/docs/showcase/table-footer-totals.tsx +12 -2
  81. package/docs/showcase/theme-customization.tsx +1259 -0
  82. package/package.json +5 -3
  83. package/scripts/brand-accent.generated.mjs +27 -0
  84. package/scripts/ui-audit.mjs +66 -0
  85. package/scripts/visual-audit-rules.mjs +46 -2
@@ -0,0 +1,74 @@
1
+ import * as React from "react";
2
+ import type { MasonryProp } from "../../props/components/layout.prop.js";
3
+ export type { MasonryColumnsProp, MasonryGapProp, MasonryItemProp, MasonryLayoutEntryProp, MasonryProp, MasonryProp as MasonryProps, } from "../../props/components/layout.prop.js";
4
+ /**
5
+ * Masonry — Ant Design `Masonry` (6.0.0): tiles of unequal height packed into columns, each tile
6
+ * dropped into whichever column is shortest when its turn comes.
7
+ *
8
+ * ## Reading order is the real accessibility question, and this layout answers it by construction
9
+ *
10
+ * A masonry has two orders and they do not agree. **DOM order is `items` order, always** — the
11
+ * tiles are absolutely positioned, so nothing in this component ever reorders the DOM. A screen
12
+ * reader therefore reads, and the Tab key therefore visits, exactly the sequence the caller
13
+ * passed, at every width and in every column count. **Visual order is the packing** — with three
14
+ * columns, items 1·2·3 open the three columns and item 4 lands under whichever of them is
15
+ * shortest, so a later item can sit visually ABOVE an earlier one.
16
+ *
17
+ * That divergence is inherent to the form, not a defect to paper over, and it has one consequence
18
+ * the caller owns: **order `items` by importance, never by height.** `MasonryItem.column` makes
19
+ * the gap wider on purpose — a tile pinned to a column can end up far down the page while staying
20
+ * second in the reading order — so pin sparingly, and never to fake a visual sequence.
21
+ *
22
+ * The alternative implementation is worse on exactly this axis. CSS `column-count` fills the
23
+ * first column to the bottom before starting the second, so in a 30-tile feed the second tile in
24
+ * the DOM paints at the bottom-left of the screen; here the second tile paints at the top of the
25
+ * second column. Absolute placement is what keeps the two orders as close as a masonry can get
26
+ * them.
27
+ *
28
+ * ## It carries no ARIA, and that is the decision, not an omission
29
+ *
30
+ * The container is a plain `<div>` with no role, and so is each tile. WAI-ARIA 1.2 has no role
31
+ * for this, and every near-miss is worse than nothing:
32
+ *
33
+ * - `list` / `listitem` would announce "list, N items" over content that is usually already
34
+ * headed, linked and structured — and would strip the tiles' own semantics on some AT.
35
+ * - `group` must have an accessible name to be conveyed at all; an unnamed one is discarded, and
36
+ * naming a layout forces a string the caller has no reason to have.
37
+ * - `region` is a landmark: three masonries on a page would ship three landmarks that axe's
38
+ * `landmark-unique` rejects — the exact collision gh#817 recorded for the table scroll region.
39
+ * - `grid` / `table` promise a row/column keyboard model that this has none of.
40
+ * - `presentation` / `none` on a `<div>` is a no-op.
41
+ *
42
+ * So the first rule of ARIA applies: no role is the correct role. The tiles' own content carries
43
+ * the semantics, and the caller wraps the masonry in `<section aria-labelledby>` (or a `Flex`
44
+ * with a label, which does this for you) when the COLLECTION needs a name.
45
+ *
46
+ * There are no strings here either — no label, no `aria-label`, no announcement — so the
47
+ * component has nothing to route through `t()`. That is a property of a pure layout, and it is
48
+ * stated rather than papered over with an invented "masonry" label nobody asked for.
49
+ *
50
+ * ## The four deliberate differences from antd, each with its reason at the point of deviation
51
+ *
52
+ * 1. **`gap`, not `gutter`** (and a `GapProp` token step, not a raw pixel number). This package
53
+ * already owns that axis under that name on `Flex`, `ResponsiveGrid` and `AuthStack`, and
54
+ * `check:prop-vocabulary` maps a field called `gap` to `GapProp`. `gutter` is typed `never` so
55
+ * arriving from antd's docs is a compile error that names the replacement.
56
+ * 2. **Breakpoint steps are `base sm md lg xl`, not `xs sm md lg xl xxl`.** antd's `xs` IS this
57
+ * library's `base`; `xxl` has no step here. Both are rejected by TypeScript and named in a
58
+ * development warning rather than silently dropped.
59
+ * 3. **`classNames` / `styles` are not ported** — the standing decision for every antd port here
60
+ * (docs/DESIGN-AUTHORITY.md, "a knob that only a fork could reach is not parity either").
61
+ * `src/tokens/components/masonry.css` is the answer.
62
+ * 4. **`MasonryItem.height` is honoured.** antd declares and documents the field and then never
63
+ * reads it — its layout is measured from `getBoundingClientRect()` alone, and all six of its
64
+ * demos carry their heights in `data`. Shipping an inert prop is worse than shipping none, so
65
+ * here a finite `height` sizes the tile and skips its measurement, which is also what lets a
66
+ * first paint and an SSR render land in the right place.
67
+ *
68
+ * And one thing that is NOT ported: antd animates tile REMOVAL through rc-motion's `CSSMotionList`
69
+ * (`motionLeave`). A removed tile here disappears at once. The arrival fade and the re-flow slide
70
+ * are ported, in CSS, and both snap under `prefers-reduced-motion: reduce`.
71
+ */
72
+ export declare const Masonry: (<TData = unknown>(props: MasonryProp<TData> & React.RefAttributes<HTMLDivElement>) => React.ReactElement) & {
73
+ displayName?: string;
74
+ };
@@ -0,0 +1,214 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { isDevelopment } from "../../lib/dev.js";
5
+ import { useMediaQuery } from "../../lib/hooks.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ const BREAKPOINT_QUERY = {
8
+ sm: "(min-width: 40rem)",
9
+ md: "(min-width: 48rem)",
10
+ lg: "(min-width: 64rem)",
11
+ xl: "(min-width: 80rem)"
12
+ };
13
+ const BREAKPOINT_ORDER = ["xl", "lg", "md", "sm"];
14
+ const COLUMN_STEPS = /* @__PURE__ */ new Set(["base", ...BREAKPOINT_ORDER]);
15
+ const DEFAULT_COLUMNS = 3;
16
+ function gapToken(step, axis) {
17
+ if (step === "none" || step === 0) return "0px";
18
+ if (typeof step === "number") return `var(--space-${step})`;
19
+ return axis === "inline" ? `var(--space-inline-${step})` : `var(--space-stack-${step})`;
20
+ }
21
+ const EMPTY_METRICS = { rowGap: 0, items: [] };
22
+ function sameMetrics(a, b) {
23
+ if (a.rowGap !== b.rowGap || a.items.length !== b.items.length) return false;
24
+ return a.items.every((metric, index) => {
25
+ const other = b.items[index];
26
+ return metric[0] === other[0] && metric[1] === other[1] && metric[2] === other[2];
27
+ });
28
+ }
29
+ function packColumns(metrics, columnCount, rowGap) {
30
+ const columnHeights = new Array(columnCount).fill(0);
31
+ const positions = /* @__PURE__ */ new Map();
32
+ for (const [key, height, pinned] of metrics) {
33
+ const shortest = columnHeights.indexOf(Math.min(...columnHeights));
34
+ const target = pinned === void 0 ? shortest : Math.min(Math.max(Math.trunc(pinned), 0), columnCount - 1);
35
+ positions.set(key, { column: target, top: columnHeights[target] });
36
+ columnHeights[target] += height + rowGap;
37
+ }
38
+ return { positions, totalHeight: Math.max(0, Math.max(...columnHeights, 0) - rowGap) };
39
+ }
40
+ const Masonry = React.forwardRef(function Masonry2({
41
+ items,
42
+ itemRender,
43
+ columns = DEFAULT_COLUMNS,
44
+ gap,
45
+ gutter,
46
+ fresh = false,
47
+ onLayoutChange,
48
+ id,
49
+ className,
50
+ ...rest
51
+ }, ref) {
52
+ const rootRef = React.useRef(null);
53
+ const itemRefs = React.useRef(/* @__PURE__ */ new Map());
54
+ const matched = {
55
+ sm: useMediaQuery(BREAKPOINT_QUERY.sm),
56
+ md: useMediaQuery(BREAKPOINT_QUERY.md),
57
+ lg: useMediaQuery(BREAKPOINT_QUERY.lg),
58
+ xl: useMediaQuery(BREAKPOINT_QUERY.xl)
59
+ };
60
+ if (isDevelopment()) {
61
+ if (gutter !== void 0) {
62
+ console.warn(
63
+ '[@godxjp/ui] Masonry: `gutter` is Ant Design\'s name for this axis; here it is `gap`, and it takes a GapProp token step (`"sm"`, `4`, `["md", "lg"]`) rather than a pixel number. The value was IGNORED.'
64
+ );
65
+ }
66
+ if (columns !== null && typeof columns === "object") {
67
+ const unknown = Object.keys(columns).filter((step) => !COLUMN_STEPS.has(step));
68
+ if (unknown.length > 0) {
69
+ console.warn(
70
+ `[@godxjp/ui] Masonry: \`columns\` has no step ${unknown.join(", ")} \u2014 the steps here are base | sm | md | lg | xl. Ant Design's \`xs\` is \`base\`, and there is no \`xxl\`. The key(s) were IGNORED.`
71
+ );
72
+ }
73
+ }
74
+ }
75
+ const columnCount = React.useMemo(() => {
76
+ const resolved = (() => {
77
+ if (typeof columns === "number") return columns;
78
+ for (const step of BREAKPOINT_ORDER) {
79
+ if (matched[step] && columns[step] !== void 0) return columns[step];
80
+ }
81
+ return columns.base ?? 1;
82
+ })();
83
+ return Math.max(1, Math.trunc(resolved) || 1);
84
+ }, [columns, matched.sm, matched.md, matched.lg, matched.xl]);
85
+ const [inlineGap, blockGap] = React.useMemo(() => {
86
+ if (gap === void 0) return [void 0, void 0];
87
+ return Array.isArray(gap) ? [gap[0], gap[1]] : [gap, gap];
88
+ }, [gap]);
89
+ const [metrics, setMetrics] = React.useState(EMPTY_METRICS);
90
+ const measure = React.useCallback(() => {
91
+ const root = rootRef.current;
92
+ if (!root) return;
93
+ const rowGap = Number.parseFloat(window.getComputedStyle(root).rowGap) || 0;
94
+ const next = (items ?? []).map((item, index) => {
95
+ const key = item.key ?? index;
96
+ if (typeof item.height === "number" && Number.isFinite(item.height)) {
97
+ return [key, item.height, item.column];
98
+ }
99
+ const element = itemRefs.current.get(key);
100
+ return [key, element ? element.getBoundingClientRect().height : 0, item.column];
101
+ });
102
+ setMetrics((previous) => {
103
+ const candidate = { rowGap, items: next };
104
+ return sameMetrics(previous, candidate) ? previous : candidate;
105
+ });
106
+ }, [items]);
107
+ const frameRef = React.useRef(null);
108
+ const scheduleMeasure = React.useCallback(() => {
109
+ if (typeof window === "undefined") return;
110
+ if (frameRef.current !== null) window.cancelAnimationFrame(frameRef.current);
111
+ frameRef.current = window.requestAnimationFrame(() => {
112
+ frameRef.current = null;
113
+ measure();
114
+ });
115
+ }, [measure]);
116
+ React.useEffect(
117
+ () => () => {
118
+ if (frameRef.current !== null && typeof window !== "undefined") {
119
+ window.cancelAnimationFrame(frameRef.current);
120
+ }
121
+ },
122
+ []
123
+ );
124
+ React.useLayoutEffect(() => {
125
+ measure();
126
+ }, [measure, columnCount, inlineGap, blockGap]);
127
+ React.useEffect(() => {
128
+ const root = rootRef.current;
129
+ if (!root) return void 0;
130
+ root.addEventListener("load", scheduleMeasure, true);
131
+ root.addEventListener("error", scheduleMeasure, true);
132
+ let observer;
133
+ if (typeof ResizeObserver !== "undefined") {
134
+ observer = new ResizeObserver(scheduleMeasure);
135
+ observer.observe(root);
136
+ if (fresh) {
137
+ for (const element of itemRefs.current.values()) {
138
+ if (element) observer.observe(element);
139
+ }
140
+ }
141
+ }
142
+ return () => {
143
+ root.removeEventListener("load", scheduleMeasure, true);
144
+ root.removeEventListener("error", scheduleMeasure, true);
145
+ observer?.disconnect();
146
+ };
147
+ }, [scheduleMeasure, fresh, items]);
148
+ const { positions, totalHeight } = React.useMemo(
149
+ () => packColumns(metrics.items, columnCount, metrics.rowGap),
150
+ [metrics, columnCount]
151
+ );
152
+ const lastLayoutRef = React.useRef(null);
153
+ React.useEffect(() => {
154
+ if (!onLayoutChange || !items || items.length === 0) return;
155
+ if (items.length !== positions.size) return;
156
+ if (!items.every((item, index) => positions.has(item.key ?? index))) return;
157
+ const layout = items.map((item, index) => ({
158
+ ...item,
159
+ column: positions.get(item.key ?? index).column
160
+ }));
161
+ const signature = layout.map((entry) => `${String(entry.key)}:${entry.column}`).join("|");
162
+ if (signature === lastLayoutRef.current) return;
163
+ lastLayoutRef.current = signature;
164
+ onLayoutChange(layout);
165
+ }, [items, positions, onLayoutChange]);
166
+ const style = {
167
+ "--masonry-column-count": columnCount,
168
+ ...inlineGap === void 0 ? void 0 : { "--masonry-gap-inline": gapToken(inlineGap, "inline") },
169
+ ...blockGap === void 0 ? void 0 : { "--masonry-gap-block": gapToken(blockGap, "block") },
170
+ blockSize: totalHeight
171
+ };
172
+ return /* @__PURE__ */ jsx(
173
+ "div",
174
+ {
175
+ ref: (element) => {
176
+ rootRef.current = element;
177
+ if (typeof ref === "function") ref(element);
178
+ else if (ref) ref.current = element;
179
+ },
180
+ id,
181
+ "data-slot": "masonry",
182
+ className: cn("ui-masonry", className),
183
+ style,
184
+ ...rest,
185
+ children: (items ?? []).map((item, index) => {
186
+ const key = item.key ?? index;
187
+ const position = positions.get(key);
188
+ const itemStyle = {
189
+ "--masonry-item-column": position?.column ?? 0,
190
+ insetBlockStart: position?.top ?? 0,
191
+ ...typeof item.height === "number" && Number.isFinite(item.height) ? { blockSize: item.height } : void 0
192
+ };
193
+ return /* @__PURE__ */ jsx(
194
+ "div",
195
+ {
196
+ ref: (element) => {
197
+ itemRefs.current.set(key, element);
198
+ },
199
+ "data-slot": "masonry-item",
200
+ "data-positioned": position ? "" : void 0,
201
+ className: "ui-masonry-item",
202
+ style: itemStyle,
203
+ children: item.children ?? itemRender?.({ ...item, index, column: position?.column ?? 0 })
204
+ },
205
+ key
206
+ );
207
+ })
208
+ }
209
+ );
210
+ });
211
+ Masonry.displayName = "Masonry";
212
+ export {
213
+ Masonry
214
+ };
@@ -3,32 +3,17 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { isValidElement, useEffect, useRef, useState } from "react";
4
4
  import { ChevronRight } from "lucide-react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
+ import { scrollParent, useInView } from "../../lib/hooks.js";
6
7
  import { cn } from "../../lib/utils.js";
7
8
  import { densityClass, pageContainerVariantClass, padStyle } from "../../lib/variants.js";
8
- function scrollParent(el) {
9
- let node = el?.parentElement ?? null;
10
- while (node) {
11
- const overflowY = getComputedStyle(node).overflowY;
12
- if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") return node;
13
- node = node.parentElement;
14
- }
15
- return null;
16
- }
17
9
  function useFooterReveal(enabled) {
18
10
  const headerRef = useRef(null);
19
- const [revealed, setRevealed] = useState(false);
11
+ const [root, setRoot] = useState(null);
20
12
  useEffect(() => {
21
- if (!enabled) return;
22
- const el = headerRef.current;
23
- if (!el || typeof IntersectionObserver === "undefined") return;
24
- const observer = new IntersectionObserver(([entry]) => setRevealed(!entry.isIntersecting), {
25
- root: scrollParent(el),
26
- threshold: 0
27
- });
28
- observer.observe(el);
29
- return () => observer.disconnect();
13
+ setRoot(scrollParent(headerRef.current));
30
14
  }, [enabled]);
31
- return { headerRef, revealed: enabled && revealed };
15
+ const inView = useInView(headerRef, { enabled, root, assumeInView: true });
16
+ return { headerRef, revealed: enabled && !inView };
32
17
  }
33
18
  function PageContainerInset({ className, children, ...props }) {
34
19
  return /* @__PURE__ */ jsx("div", { className: cn("ui-page-container-inset", className), ...props, children });
@@ -0,0 +1,64 @@
1
+ import * as React from "react";
2
+ import type { AnchorProp } from "../../props/components/navigation.prop.js";
3
+ export type { AnchorContainerProp, AnchorDirectionProp, AnchorItemProp, AnchorProp, AnchorProp as AnchorProps, } from "../../props/components/navigation.prop.js";
4
+ /**
5
+ * Anchor — Ant Design `Anchor` (6.6.5): the in-page section navigation, and — the part that makes
6
+ * it a component rather than a composition — the thing that WORKS OUT which section is current.
7
+ *
8
+ * `NavList activeId` takes that answer as a prop. Nothing else in this library computes it.
9
+ *
10
+ * ## Why this resolves by measurement and not by `IntersectionObserver`
11
+ *
12
+ * The obvious build is an observer over every section with a thin `rootMargin` band, and it is
13
+ * what Bootstrap's Scrollspy does (`rootMargin: "0px 0px -25%"`, `threshold: [0.1, 0.5, 1]`) and
14
+ * what this repo's own `LegalDocumentShell` does (`"-10% 0px -75% 0px"`, threshold 0). It has two
15
+ * failure modes that no choice of margin fixes, because they are properties of the question rather
16
+ * than of the numbers:
17
+ *
18
+ * 1. **A section taller than the band reports nothing.** Scroll into the middle of a long
19
+ * section and no element intersects, so the answer is empty and the implementation has to fall
20
+ * back to "keep whatever was active" — which is a guess, and is wrong after any jump.
21
+ * 2. **The answer depends on scroll DIRECTION.** With several short sections inside the band at
22
+ * once, "first intersecting" and "last intersecting" disagree, and which is right depends on
23
+ * which way the reader is moving. That is where scrollspy flicker comes from, and it is why
24
+ * the good implementations end up bolting hysteresis onto the observer.
25
+ *
26
+ * Ant Design resolves it the other way and gets neither problem: on each scroll, take every
27
+ * section whose block-start edge has crossed a single decision line, and pick the LAST one. It is
28
+ * a pure function of scroll position, so it cannot oscillate at a fixed position, it needs no
29
+ * hysteresis, and a section a mile tall is still the current one all the way down. That rule is
30
+ * what is ported here, `bounds` and all.
31
+ *
32
+ * ## The three traps, and where each is handled
33
+ *
34
+ * **A click must not fight the resolver.** A click scrolls, the scroll fires the resolver, and the
35
+ * resolver re-picks every section the page passes on the way — so the item you clicked lights up,
36
+ * goes out, and comes back. `suppressedRef` holds the resolver off from the moment the
37
+ * programmatic scroll starts until the container stops emitting `scroll` (see
38
+ * `SCROLL_SETTLE_MS`), and a real `wheel`/`touchstart` releases it early.
39
+ *
40
+ * **The hash is state too.** Landing on `/pricing#enterprise` must select that entry, and must do
41
+ * it without waiting for a scroll event that may never come — the browser's own hash jump does not
42
+ * fire one when the section is already in view. It is therefore read in the state INITIALISER, so
43
+ * the very first render is already correct, and the mount-time resolution is skipped when it
44
+ * matched, rather than immediately overwriting it with a scroll position nobody has reached yet.
45
+ *
46
+ * **Reduced motion jumps.** `behavior: "smooth"` becomes `"auto"` under
47
+ * `prefers-reduced-motion: reduce` (WCAG 2.3.3) — it still lands on the section, instantly. The
48
+ * ink rail transitions on `--duration-fast` and snaps under the same query; the current item is
49
+ * never conveyed by motion alone, it is conveyed by `aria-current`.
50
+ *
51
+ * ## Accessibility
52
+ *
53
+ * A named `<nav>` landmark (a page routinely carries a breadcrumb, a rail and this one), a real
54
+ * `<ul>`/`<li>` list of real `<a href="#…">` — middle-clickable, deep-linkable, and working before
55
+ * JavaScript boots — and `aria-current="location"` on the active one. `"location"` and not
56
+ * `"page"`: the entry points at a FRAGMENT of the page being read, not at a different page, and
57
+ * `"page"` is what a breadcrumb's or a nav rail's current item uses. A click also moves FOCUS to
58
+ * the target section, not just the scroll position, so the next Tab continues from where the
59
+ * reader was sent instead of from the nav.
60
+ */
61
+ export declare function Anchor({ items, direction, affix, bounds, getContainer, getCurrentAnchor, offsetBlockStart, targetOffsetBlockStart, showInkInFixed, replace, value, defaultValue, onValueChange, onChange, offsetTop, targetOffset, onClick, label, id, className, ...rest }: AnchorProp): React.JSX.Element;
62
+ export declare namespace Anchor {
63
+ var displayName: string;
64
+ }
@@ -0,0 +1,284 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
5
+ import { isDevelopment } from "../../lib/dev.js";
6
+ import { useControlledLatch } from "../../lib/hooks.js";
7
+ import { cn, prefersReducedMotion } from "../../lib/utils.js";
8
+ import { Affix } from "../layout/affix.js";
9
+ const SHARP_MATCHER = /#([^\t\r\n\f\v]+)$/;
10
+ const DEFAULT_BOUNDS = 5;
11
+ const SCROLL_SETTLE_MS = 120;
12
+ function hashOf(href) {
13
+ return SHARP_MATCHER.exec(href)?.[1] ?? "";
14
+ }
15
+ function flatten(items, out = []) {
16
+ for (const item of items ?? []) {
17
+ out.push(item);
18
+ if (item.children?.length) flatten(item.children, out);
19
+ }
20
+ return out;
21
+ }
22
+ function offsetTopWithin(element, container) {
23
+ if (!element.getClientRects().length) return 0;
24
+ const rect = element.getBoundingClientRect();
25
+ if (rect.width || rect.height) {
26
+ if (container === window) return rect.top - element.ownerDocument.documentElement.clientTop;
27
+ return rect.top - container.getBoundingClientRect().top;
28
+ }
29
+ return rect.top;
30
+ }
31
+ function scrollTopOf(container) {
32
+ return container === window ? window.scrollY ?? document.documentElement.scrollTop : container.scrollTop;
33
+ }
34
+ function Anchor({
35
+ items,
36
+ direction = "vertical",
37
+ affix = true,
38
+ bounds = DEFAULT_BOUNDS,
39
+ getContainer,
40
+ getCurrentAnchor,
41
+ offsetBlockStart,
42
+ targetOffsetBlockStart,
43
+ showInkInFixed = false,
44
+ replace = false,
45
+ value,
46
+ defaultValue,
47
+ onValueChange,
48
+ onChange,
49
+ offsetTop,
50
+ targetOffset,
51
+ onClick,
52
+ label,
53
+ id,
54
+ className,
55
+ ...rest
56
+ }) {
57
+ const { t } = useTranslation();
58
+ const horizontal = direction === "horizontal";
59
+ if (isDevelopment()) {
60
+ if (onChange !== void 0) {
61
+ console.warn(
62
+ "[@godxjp/ui] Anchor: `onChange` is Ant Design's name for this callback; here the active href is a controlled value, so it is `onValueChange` (beside `value` / `defaultValue`). The handler was IGNORED."
63
+ );
64
+ }
65
+ if (offsetTop !== void 0) {
66
+ console.warn(
67
+ "[@godxjp/ui] Anchor: `offsetTop` is Ant Design's name for this axis; here it is `offsetBlockStart` \u2014 the logical axis, per check:rtl. The value was IGNORED."
68
+ );
69
+ }
70
+ if (targetOffset !== void 0) {
71
+ console.warn(
72
+ "[@godxjp/ui] Anchor: `targetOffset` is Ant Design's name for this axis; here it is `targetOffsetBlockStart`. The value was IGNORED."
73
+ );
74
+ }
75
+ if (horizontal && items?.some((item) => item.children?.length)) {
76
+ console.warn(
77
+ '[@godxjp/ui] Anchor: `items[].children` is not supported when `direction="horizontal"` \u2014 a nested list has nowhere to go on one row. The nested entries were DROPPED. (Ant Design warns about the same combination.)'
78
+ );
79
+ }
80
+ }
81
+ const links = React.useMemo(
82
+ () => horizontal ? items ?? [] : flatten(items),
83
+ [items, horizontal]
84
+ );
85
+ const linksKey = links.map((item) => item.href).join("\n");
86
+ const navRef = React.useRef(null);
87
+ const listRef = React.useRef(null);
88
+ const suppressedRef = React.useRef(false);
89
+ const settleTimerRef = React.useRef(null);
90
+ const hashOnMountRef = React.useRef(null);
91
+ const [internal, setInternal] = React.useState(() => {
92
+ if (typeof window === "undefined") return defaultValue ?? "";
93
+ const hash = window.location.hash;
94
+ if (!hash) return defaultValue ?? "";
95
+ const decoded = decodeURIComponent(hash.slice(1));
96
+ const matched = links.find((item) => hashOf(item.href) === decoded);
97
+ if (!matched) return defaultValue ?? "";
98
+ hashOnMountRef.current = matched.href;
99
+ return matched.href;
100
+ });
101
+ const controlled = useControlledLatch(value !== void 0);
102
+ const resolved = controlled ? value ?? "" : internal;
103
+ const active = controlled ? resolved : getCurrentAnchor?.(resolved) ?? resolved;
104
+ const activeRef = React.useRef(active);
105
+ const controlledRef = React.useRef(controlled);
106
+ const onValueChangeRef = React.useRef(onValueChange);
107
+ const getCurrentAnchorRef = React.useRef(getCurrentAnchor);
108
+ React.useEffect(() => {
109
+ activeRef.current = active;
110
+ controlledRef.current = controlled;
111
+ onValueChangeRef.current = onValueChange;
112
+ getCurrentAnchorRef.current = getCurrentAnchor;
113
+ });
114
+ const commit = React.useCallback((href, force = false) => {
115
+ const mapped = getCurrentAnchorRef.current?.(href) ?? href;
116
+ if (!force && mapped === activeRef.current) return;
117
+ if (!controlledRef.current) setInternal(href);
118
+ onValueChangeRef.current?.(href);
119
+ }, []);
120
+ const container = React.useCallback(
121
+ () => getContainer?.() ?? window,
122
+ [getContainer]
123
+ );
124
+ const line = targetOffsetBlockStart ?? offsetBlockStart ?? 0;
125
+ const resolveFromScroll = React.useCallback(() => {
126
+ if (typeof document === "undefined") return "";
127
+ const box = container();
128
+ let best = "";
129
+ let bestTop = Number.NEGATIVE_INFINITY;
130
+ for (const item of links) {
131
+ const hash = hashOf(item.href);
132
+ if (!hash) continue;
133
+ const element = document.getElementById(hash);
134
+ if (!element) continue;
135
+ const top = offsetTopWithin(element, box);
136
+ const itemLine = item.targetOffsetBlockStart ?? line;
137
+ if (top <= itemLine + bounds && top > bestTop) {
138
+ best = item.href;
139
+ bestTop = top;
140
+ }
141
+ }
142
+ return best;
143
+ }, [container, links, line, bounds]);
144
+ const release = React.useCallback(() => {
145
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
146
+ settleTimerRef.current = null;
147
+ suppressedRef.current = false;
148
+ }, []);
149
+ React.useEffect(() => {
150
+ if (typeof window === "undefined") return void 0;
151
+ const box = container();
152
+ const handleScroll = () => {
153
+ if (suppressedRef.current) {
154
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
155
+ settleTimerRef.current = setTimeout(release, SCROLL_SETTLE_MS);
156
+ return;
157
+ }
158
+ commit(resolveFromScroll());
159
+ };
160
+ const handleGesture = () => {
161
+ if (suppressedRef.current) release();
162
+ };
163
+ if (hashOnMountRef.current === null) commit(resolveFromScroll());
164
+ hashOnMountRef.current = null;
165
+ box.addEventListener("scroll", handleScroll, { passive: true });
166
+ window.addEventListener("resize", handleScroll);
167
+ window.addEventListener("wheel", handleGesture, { passive: true });
168
+ window.addEventListener("touchstart", handleGesture, { passive: true });
169
+ return () => {
170
+ box.removeEventListener("scroll", handleScroll);
171
+ window.removeEventListener("resize", handleScroll);
172
+ window.removeEventListener("wheel", handleGesture);
173
+ window.removeEventListener("touchstart", handleGesture);
174
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
175
+ };
176
+ }, [container, resolveFromScroll, commit, release, linksKey]);
177
+ const [ink, setInk] = React.useState(null);
178
+ React.useEffect(() => {
179
+ const list = listRef.current;
180
+ if (!list) return;
181
+ const current = list.querySelector('[data-slot="anchor-link"][data-active]');
182
+ if (!current) {
183
+ setInk(null);
184
+ return;
185
+ }
186
+ const listRect = list.getBoundingClientRect();
187
+ const rect = current.getBoundingClientRect();
188
+ if (!horizontal) {
189
+ setInk({ offset: rect.top - listRect.top, size: rect.height });
190
+ return;
191
+ }
192
+ const rtl = getComputedStyle(list).direction === "rtl";
193
+ setInk({
194
+ offset: rtl ? listRect.right - rect.right : rect.left - listRect.left,
195
+ size: rect.width
196
+ });
197
+ }, [active, horizontal, linksKey]);
198
+ const handleLinkClick = (event, item) => {
199
+ onClick?.(event, item);
200
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
201
+ return;
202
+ }
203
+ const hash = hashOf(item.href);
204
+ const targetElement = hash && typeof document !== "undefined" ? document.getElementById(hash) : null;
205
+ if (!targetElement) return;
206
+ event.preventDefault();
207
+ commit(item.href, true);
208
+ const useReplace = item.replace ?? replace;
209
+ window.history[useReplace ? "replaceState" : "pushState"](null, "", item.href);
210
+ const box = container();
211
+ const landing = item.targetOffsetBlockStart ?? targetOffsetBlockStart ?? offsetBlockStart ?? 0;
212
+ const top = scrollTopOf(box) + offsetTopWithin(targetElement, box) - landing;
213
+ const behavior = prefersReducedMotion() ? "auto" : "smooth";
214
+ suppressedRef.current = true;
215
+ if (settleTimerRef.current) clearTimeout(settleTimerRef.current);
216
+ settleTimerRef.current = setTimeout(release, SCROLL_SETTLE_MS);
217
+ if (box === window) window.scrollTo({ top, behavior });
218
+ else box.scrollTo({ top, behavior });
219
+ if (!targetElement.hasAttribute("tabindex") && targetElement.tabIndex < 0) {
220
+ targetElement.setAttribute("tabindex", "-1");
221
+ }
222
+ targetElement.focus({ preventScroll: true });
223
+ };
224
+ const renderItems = (list, nested) => list.map((item) => /* @__PURE__ */ jsxs("li", { "data-slot": "anchor-item", "data-nested": nested ? "" : void 0, children: [
225
+ /* @__PURE__ */ jsx(
226
+ "a",
227
+ {
228
+ href: item.href,
229
+ target: item.target,
230
+ "data-slot": "anchor-link",
231
+ "data-active": item.href === active ? "" : void 0,
232
+ "aria-current": item.href === active ? "location" : void 0,
233
+ onClick: (event) => {
234
+ handleLinkClick(event, item);
235
+ },
236
+ children: item.title
237
+ }
238
+ ),
239
+ !horizontal && item.children?.length ? /* @__PURE__ */ jsx("ul", { "data-slot": "anchor-list", "data-nested": "", children: renderItems(item.children, true) }) : null
240
+ ] }, item.key));
241
+ const content = /* @__PURE__ */ jsx(
242
+ "nav",
243
+ {
244
+ ref: navRef,
245
+ id,
246
+ "data-slot": "anchor",
247
+ "data-direction": direction,
248
+ "data-ink": affix === false && !showInkInFixed ? "hidden" : void 0,
249
+ "aria-label": label ?? t("navigation.anchor.ariaLabel"),
250
+ className: cn("ui-anchor", className),
251
+ ...rest,
252
+ children: /* @__PURE__ */ jsxs("div", { className: "ui-anchor-track", children: [
253
+ /* @__PURE__ */ jsx(
254
+ "span",
255
+ {
256
+ "data-slot": "anchor-ink",
257
+ "data-visible": ink ? "" : void 0,
258
+ "aria-hidden": "true",
259
+ className: "ui-anchor-ink",
260
+ style: ink ? {
261
+ "--anchor-ink-offset": `${ink.offset}px`,
262
+ "--anchor-ink-extent": `${ink.size}px`
263
+ } : void 0
264
+ }
265
+ ),
266
+ /* @__PURE__ */ jsx("ul", { ref: listRef, "data-slot": "anchor-list", className: "ui-anchor-list", children: renderItems(items ?? [], false) })
267
+ ] })
268
+ }
269
+ );
270
+ if (!affix) return content;
271
+ return /* @__PURE__ */ jsx(
272
+ Affix,
273
+ {
274
+ offsetBlockStart,
275
+ target: container,
276
+ ...typeof affix === "object" ? affix : void 0,
277
+ children: content
278
+ }
279
+ );
280
+ }
281
+ Anchor.displayName = "Anchor";
282
+ export {
283
+ Anchor
284
+ };