@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.
- package/dist/components/data-display/index.d.ts +2 -0
- package/dist/components/data-display/index.js +2 -0
- package/dist/components/data-display/marquee.d.ts +16 -0
- package/dist/components/data-display/marquee.js +155 -0
- package/dist/components/general/reveal.d.ts +23 -2
- package/dist/components/general/reveal.js +37 -7
- package/dist/components/general/typography.d.ts +4 -1
- package/dist/components/general/typography.js +14 -1
- package/dist/components/layout/affix.d.ts +86 -0
- package/dist/components/layout/affix.js +187 -0
- package/dist/components/layout/index.d.ts +4 -0
- package/dist/components/layout/index.js +4 -0
- package/dist/components/layout/legal-document-shell.js +4 -3
- package/dist/components/layout/masonry.d.ts +74 -0
- package/dist/components/layout/masonry.js +214 -0
- package/dist/components/layout/page-container.js +5 -20
- package/dist/components/navigation/anchor.d.ts +64 -0
- package/dist/components/navigation/anchor.js +284 -0
- package/dist/components/navigation/index.d.ts +4 -0
- package/dist/components/navigation/index.js +4 -0
- package/dist/components/navigation/mega-menu.d.ts +21 -0
- package/dist/components/navigation/mega-menu.js +526 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/i18n/messages/en.json +517 -0
- package/dist/i18n/messages/ja.json +513 -0
- package/dist/i18n/messages/vi.json +513 -0
- package/dist/lib/hooks.d.ts +68 -0
- package/dist/lib/hooks.js +52 -0
- package/dist/lib/platform.d.ts +14 -0
- package/dist/lib/platform.js +10 -1
- package/dist/lib/utils.d.ts +1 -1
- package/dist/lib/utils.js +3 -2
- package/dist/props/components/data-display.prop.d.ts +95 -1
- package/dist/props/components/general.prop.d.ts +47 -3
- package/dist/props/components/layout.prop.d.ts +194 -0
- package/dist/props/components/navigation.prop.d.ts +263 -0
- package/dist/props/registry.d.ts +359 -4
- package/dist/props/registry.js +472 -3
- package/dist/props/vocabulary/index.d.ts +1 -1
- package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
- package/dist/styles/control.css +5 -6
- package/dist/styles/data-display-layout.css +2 -1
- package/dist/styles/density.css +4 -0
- package/dist/styles/layout.css +79 -0
- package/dist/styles/motion.css +121 -1
- package/dist/styles/navigation-layout.css +397 -1
- package/dist/styles/shell-layout.css +3 -0
- package/dist/styles/text-layout.css +52 -4
- package/dist/tokens/base.css +5 -0
- package/dist/tokens/components/affix.css +7 -0
- package/dist/tokens/components/anchor.css +17 -0
- package/dist/tokens/components/control.css +3 -3
- package/dist/tokens/components/form.css +1 -1
- package/dist/tokens/components/marquee.css +7 -0
- package/dist/tokens/components/masonry.css +6 -0
- package/dist/tokens/components/mega-menu.css +62 -0
- package/dist/tokens/components/shell.css +3 -0
- package/dist/tokens/foundation.css +11 -0
- package/dist/tokens/semantic/layout.css +7 -0
- package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
- package/docs/DESIGN-AUTHORITY.md +99 -18
- package/docs/FRAME-COVERAGE-REPORT.md +7 -2
- package/docs/data-display/marquee.tsx +254 -0
- package/docs/foundation/_theme-editor-scope.ts +222 -0
- package/docs/foundation/density.tsx +12 -2
- package/docs/foundation/spacing.tsx +5 -0
- package/docs/foundation/theme-editor.tsx +645 -0
- package/docs/general/activity.tsx +65 -0
- package/docs/general/reveal.tsx +290 -22
- package/docs/general/typography.tsx +91 -1
- package/docs/layout/affix.tsx +209 -0
- package/docs/layout/masonry.tsx +291 -0
- package/docs/navigation/anchor.tsx +285 -0
- package/docs/navigation/mega-menu-panel.tsx +86 -0
- package/docs/navigation/mega-menu.tsx +254 -0
- package/docs/roadmap/website-components.md +779 -0
- package/docs/showcase/acme-website.tsx +75 -39
- package/docs/showcase/futurelastic-web.tsx +91 -49
- package/docs/showcase/marketing-page.tsx +885 -0
- package/docs/showcase/table-footer-totals.tsx +12 -2
- package/docs/showcase/theme-customization.tsx +1259 -0
- package/package.json +5 -3
- package/scripts/brand-accent.generated.mjs +27 -0
- package/scripts/ui-audit.mjs +66 -0
- 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 [
|
|
11
|
+
const [root, setRoot] = useState(null);
|
|
20
12
|
useEffect(() => {
|
|
21
|
-
|
|
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
|
-
|
|
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
|
+
};
|