@godxjp/ui 28.7.0 → 28.9.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 (119) 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/lib/variants.js +4 -1
  34. package/dist/props/components/data-display.prop.d.ts +95 -1
  35. package/dist/props/components/general.prop.d.ts +47 -3
  36. package/dist/props/components/layout.prop.d.ts +194 -0
  37. package/dist/props/components/navigation.prop.d.ts +263 -0
  38. package/dist/props/registry.d.ts +359 -4
  39. package/dist/props/registry.js +472 -3
  40. package/dist/props/vocabulary/index.d.ts +1 -1
  41. package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
  42. package/dist/props/vocabulary/layout.prop.d.ts +1 -1
  43. package/dist/styles/base.css +47 -14
  44. package/dist/styles/card-layout.css +2 -2
  45. package/dist/styles/chart-layout.css +6 -6
  46. package/dist/styles/control.css +15 -10
  47. package/dist/styles/data-display-layout.css +22 -6
  48. package/dist/styles/density.css +6 -0
  49. package/dist/styles/dialog-layout.css +4 -1
  50. package/dist/styles/focus-ring.css +4 -1
  51. package/dist/styles/layout.css +92 -3
  52. package/dist/styles/motion.css +121 -1
  53. package/dist/styles/navigation-layout.css +397 -1
  54. package/dist/styles/shell-layout.css +28 -21
  55. package/dist/styles/text-layout.css +134 -15
  56. package/dist/tokens/base.css +5 -0
  57. package/dist/tokens/components/activity.css +13 -4
  58. package/dist/tokens/components/affix.css +7 -0
  59. package/dist/tokens/components/anchor.css +17 -0
  60. package/dist/tokens/components/attachments.css +1 -1
  61. package/dist/tokens/components/badge.css +1 -1
  62. package/dist/tokens/components/card.css +28 -7
  63. package/dist/tokens/components/chart.css +4 -1
  64. package/dist/tokens/components/chat-composer.css +4 -1
  65. package/dist/tokens/components/control.css +72 -33
  66. package/dist/tokens/components/conversations.css +4 -1
  67. package/dist/tokens/components/data-display.css +42 -15
  68. package/dist/tokens/components/data-entry.css +8 -2
  69. package/dist/tokens/components/descriptions.css +1 -1
  70. package/dist/tokens/components/feedback.css +8 -5
  71. package/dist/tokens/components/float-button.css +8 -2
  72. package/dist/tokens/components/form.css +1 -1
  73. package/dist/tokens/components/legal-document.css +12 -3
  74. package/dist/tokens/components/logo.css +15 -6
  75. package/dist/tokens/components/marquee.css +7 -0
  76. package/dist/tokens/components/masonry.css +6 -0
  77. package/dist/tokens/components/mega-menu.css +71 -0
  78. package/dist/tokens/components/navigation.css +37 -13
  79. package/dist/tokens/components/segmented.css +4 -1
  80. package/dist/tokens/components/separator.css +4 -1
  81. package/dist/tokens/components/shell.css +99 -31
  82. package/dist/tokens/components/table.css +11 -5
  83. package/dist/tokens/components/thought-chain.css +4 -1
  84. package/dist/tokens/components/toggle.css +4 -1
  85. package/dist/tokens/components/tree.css +1 -1
  86. package/dist/tokens/components/upload.css +21 -9
  87. package/dist/tokens/foundation.css +35 -30
  88. package/dist/tokens/semantic/layout.css +26 -5
  89. package/docs/COMPOSITION-VS-COMPONENT.md +19 -1
  90. package/docs/DESIGN-AUTHORITY.md +99 -18
  91. package/docs/FRAME-COVERAGE-REPORT.md +7 -2
  92. package/docs/TOKENS.md +16 -1
  93. package/docs/data-display/marquee.tsx +254 -0
  94. package/docs/foundation/_theme-editor-scope.ts +222 -0
  95. package/docs/foundation/density.tsx +12 -2
  96. package/docs/foundation/spacing.tsx +5 -0
  97. package/docs/foundation/theme-editor.tsx +645 -0
  98. package/docs/general/activity.tsx +65 -0
  99. package/docs/general/reveal.tsx +290 -22
  100. package/docs/general/typography.tsx +91 -1
  101. package/docs/layout/affix.tsx +209 -0
  102. package/docs/layout/masonry.tsx +291 -0
  103. package/docs/navigation/anchor.tsx +285 -0
  104. package/docs/navigation/mega-menu-panel.tsx +86 -0
  105. package/docs/navigation/mega-menu.tsx +254 -0
  106. package/docs/roadmap/website-components.md +779 -0
  107. package/docs/showcase/acme-website.tsx +75 -39
  108. package/docs/showcase/case4-login.tsx +10 -2
  109. package/docs/showcase/case5-shift-calendar.tsx +1 -1
  110. package/docs/showcase/case6-agency-handy.tsx +6 -6
  111. package/docs/showcase/futurelastic-web.tsx +89 -49
  112. package/docs/showcase/marketing-page.tsx +885 -0
  113. package/docs/showcase/table-expandable-rows.tsx +4 -1
  114. package/docs/showcase/table-footer-totals.tsx +12 -2
  115. package/docs/showcase/theme-customization.tsx +1259 -0
  116. package/package.json +6 -3
  117. package/scripts/brand-accent.generated.mjs +27 -0
  118. package/scripts/ui-audit.mjs +66 -0
  119. package/scripts/visual-audit-rules.mjs +46 -2
@@ -10,6 +10,8 @@ export { Thumbnail } from "./thumbnail.js";
10
10
  export type { ThumbnailProp, ThumbnailProps, ThumbnailSizeProp } from "./thumbnail.js";
11
11
  export { ListRow } from "./list-row.js";
12
12
  export type { ListRowProps, ListRowDensity } from "./list-row.js";
13
+ export { Marquee } from "./marquee.js";
14
+ export type { MarqueeDirectionProp, MarqueeProp, MarqueeProps, MarqueeSpeedProp } from "./marquee.js";
13
15
  export { CredentialReveal } from "./credential-reveal.js";
14
16
  export type { CredentialRevealProp, CredentialRevealProps, CredentialRevealTone, } from "./credential-reveal.js";
15
17
  export { QrCode } from "./qr-code.js";
@@ -4,6 +4,7 @@ import { Swatch } from "./swatch.js";
4
4
  import { FeatureList } from "./feature-list.js";
5
5
  import { Thumbnail } from "./thumbnail.js";
6
6
  import { ListRow } from "./list-row.js";
7
+ import { Marquee } from "./marquee.js";
7
8
  import { CredentialReveal } from "./credential-reveal.js";
8
9
  import { QrCode } from "./qr-code.js";
9
10
  import { Avatar, AvatarImage, AvatarFallback } from "./avatar.js";
@@ -101,6 +102,7 @@ export {
101
102
  HoverCardTrigger,
102
103
  Legend,
103
104
  ListRow,
105
+ Marquee,
104
106
  PermissionMatrix,
105
107
  Popover,
106
108
  PopoverAnchor,
@@ -0,0 +1,16 @@
1
+ import * as React from "react";
2
+ import type { GapProp } from "../../props/vocabulary/index.js";
3
+ export type { MarqueeDirectionProp, MarqueeSpeedProp, MarqueeProp, MarqueeProp as MarqueeProps, } from "../../props/components/data-display.prop.js";
4
+ export declare const Marquee: React.ForwardRefExoticComponent<Omit<React.HTMLAttributes<HTMLDivElement>, "children" | "onPause" | "onPlay"> & {
5
+ children?: import("../../props/index.js").ChildrenProp;
6
+ play?: boolean;
7
+ defaultPlay?: boolean;
8
+ onPlayChange?: (play: boolean) => void;
9
+ direction?: import("./marquee.js").MarqueeDirectionProp;
10
+ speed?: import("./marquee.js").MarqueeSpeedProp;
11
+ gap?: GapProp;
12
+ pauseOnHover?: boolean;
13
+ fade?: boolean;
14
+ label?: import("../../props/index.js").LabelProp;
15
+ className?: import("../../props/index.js").ClassNameProp;
16
+ } & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,155 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { Pause, Play } from "lucide-react";
5
+ import { useTranslation } from "../../i18n/use-translation.js";
6
+ import { useMediaQuery } from "../../lib/hooks.js";
7
+ import { cn } from "../../lib/utils.js";
8
+ import { Button } from "../general/button.js";
9
+ import { ScrollArea } from "./scroll-area.js";
10
+ function gapToken(step) {
11
+ if (step === "none" || step === 0) return "0px";
12
+ if (typeof step === "number") return `var(--space-${step})`;
13
+ return `var(--space-inline-${step})`;
14
+ }
15
+ const MAX_COPIES = 20;
16
+ const Marquee = React.forwardRef(function Marquee2({
17
+ children,
18
+ play,
19
+ defaultPlay = true,
20
+ onPlayChange,
21
+ direction = "start",
22
+ speed = "base",
23
+ gap,
24
+ pauseOnHover = false,
25
+ fade = false,
26
+ label,
27
+ className,
28
+ style,
29
+ ...props
30
+ }, ref) {
31
+ const { t } = useTranslation();
32
+ const reducedMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
33
+ const trackId = React.useId();
34
+ const viewportRef = React.useRef(null);
35
+ const copyRef = React.useRef(null);
36
+ const [uncontrolledPlay, setUncontrolledPlay] = React.useState(defaultPlay);
37
+ const playing = play ?? uncontrolledPlay;
38
+ const [measurement, setMeasurement] = React.useState({ copies: 2, cycleScale: 1 });
39
+ React.useEffect(() => {
40
+ if (reducedMotion) return void 0;
41
+ const viewport = viewportRef.current;
42
+ const copy = copyRef.current;
43
+ if (!viewport || !copy || typeof ResizeObserver === "undefined") return void 0;
44
+ const measure = () => {
45
+ const copyWidth = copy.offsetWidth;
46
+ const viewportWidth = viewport.offsetWidth;
47
+ if (copyWidth <= 0 || viewportWidth <= 0) return;
48
+ const copies = Math.min(MAX_COPIES, Math.ceil(viewportWidth / copyWidth) + 1);
49
+ const cycleScale = Math.round(copyWidth / viewportWidth * 1e3) / 1e3;
50
+ setMeasurement(
51
+ (current) => current.copies === copies && current.cycleScale === cycleScale ? current : { copies, cycleScale }
52
+ );
53
+ };
54
+ measure();
55
+ const observer = new ResizeObserver(measure);
56
+ observer.observe(viewport);
57
+ observer.observe(copy);
58
+ return () => {
59
+ observer.disconnect();
60
+ };
61
+ }, [reducedMotion, children]);
62
+ const setPlaying = (next) => {
63
+ if (play === void 0) setUncontrolledPlay(next);
64
+ onPlayChange?.(next);
65
+ };
66
+ const rootStyle = {
67
+ ...style,
68
+ ...gap === void 0 ? null : { "--marquee-gap-inline": gapToken(gap) }
69
+ };
70
+ if (reducedMotion) {
71
+ return /* @__PURE__ */ jsx(
72
+ "div",
73
+ {
74
+ ref,
75
+ "data-slot": "marquee",
76
+ "data-reduced-motion": "true",
77
+ "data-direction": direction,
78
+ className: cn("ui-marquee", className),
79
+ style: rootStyle,
80
+ ...props,
81
+ children: /* @__PURE__ */ jsx(
82
+ ScrollArea,
83
+ {
84
+ orientation: "horizontal",
85
+ className: "ui-marquee-scroller",
86
+ label: typeof label === "string" ? label : void 0,
87
+ children: /* @__PURE__ */ jsx("div", { id: trackId, "data-slot": "marquee-track", className: "ui-marquee-track", children: /* @__PURE__ */ jsx("div", { ref: copyRef, "data-slot": "marquee-copy", className: "ui-marquee-copy", children }) })
88
+ }
89
+ )
90
+ }
91
+ );
92
+ }
93
+ const animatedStyle = {
94
+ ...rootStyle,
95
+ "--marquee-copies": measurement.copies,
96
+ "--marquee-cycle-scale": measurement.cycleScale
97
+ };
98
+ const named = typeof label === "string" && label.trim() !== "";
99
+ const controlLabel = playing ? named ? t("dataDisplay.marquee.pauseNamed", { label }) : t("dataDisplay.marquee.pause") : named ? t("dataDisplay.marquee.playNamed", { label }) : t("dataDisplay.marquee.play");
100
+ return /* @__PURE__ */ jsxs(
101
+ "div",
102
+ {
103
+ ref,
104
+ "data-slot": "marquee",
105
+ "data-direction": direction,
106
+ "data-speed": speed,
107
+ "data-playing": playing ? "true" : "false",
108
+ "data-pause-on-hover": pauseOnHover ? "true" : "false",
109
+ "data-fade": fade ? "true" : "false",
110
+ className: cn("ui-marquee", className),
111
+ style: animatedStyle,
112
+ ...props,
113
+ children: [
114
+ /* @__PURE__ */ jsx("div", { ref: viewportRef, "data-slot": "marquee-viewport", className: "ui-marquee-viewport", children: /* @__PURE__ */ jsxs("div", { id: trackId, "data-slot": "marquee-track", className: "ui-marquee-track", children: [
115
+ /* @__PURE__ */ jsx("div", { ref: copyRef, "data-slot": "marquee-copy", className: "ui-marquee-copy", children }),
116
+ Array.from({ length: measurement.copies - 1 }, (_, index) => (
117
+ // Decorative by construction: `aria-hidden` keeps the duplicate out of the
118
+ // accessibility tree and `inert` keeps it out of the tab order. The prior art has
119
+ // neither, which is why its content is announced once per screenful of width.
120
+ /* @__PURE__ */ jsx(
121
+ "div",
122
+ {
123
+ "data-slot": "marquee-clone",
124
+ className: "ui-marquee-copy",
125
+ "aria-hidden": "true",
126
+ inert: true,
127
+ children
128
+ },
129
+ index
130
+ )
131
+ ))
132
+ ] }) }),
133
+ /* @__PURE__ */ jsx(
134
+ Button,
135
+ {
136
+ type: "button",
137
+ variant: "ghost",
138
+ size: "icon-sm",
139
+ "data-slot": "marquee-control",
140
+ className: "ui-marquee-control",
141
+ "aria-label": controlLabel,
142
+ "aria-controls": trackId,
143
+ onClick: () => {
144
+ setPlaying(!playing);
145
+ },
146
+ children: playing ? /* @__PURE__ */ jsx(Pause, { className: "ui-marquee-control-glyph", "aria-hidden": "true" }) : /* @__PURE__ */ jsx(Play, { className: "ui-marquee-control-glyph", "aria-hidden": "true" })
147
+ }
148
+ )
149
+ ]
150
+ }
151
+ );
152
+ });
153
+ export {
154
+ Marquee
155
+ };
@@ -1,8 +1,29 @@
1
- import type { RevealProp } from "../../props/components/general.prop.js";
1
+ import * as React from "react";
2
2
  export type { RevealProp, RevealProp as RevealProps } from "../../props/components/general.prop.js";
3
3
  /**
4
4
  * Reveal — the official entrance-motion primitive (staggered fade-up). `delay` is a stagger
5
5
  * ordinal (`0..6`) — an index into the `--reveal-stagger-step` ladder, never a raw ms — so a
6
6
  * column of rows cascades in.
7
+ *
8
+ * `on="view"` moves the TRIGGER to the viewport (gh#829). It is one prop rather than a second
9
+ * component because everything else is identical: the same keyframes, the same tokens, the same
10
+ * reduced-motion contract.
11
+ *
12
+ * ## The observer gates the ANIMATION, never the VISIBILITY
13
+ *
14
+ * `.ui-reveal`'s resting state in `styles/motion.css` is the finished, fully visible one. The
15
+ * hidden state is written here, as `data-reveal-state="out"`, and ONLY once this component is
16
+ * mounted in a browser that has an `IntersectionObserver` and has measured the element as still
17
+ * outside the viewport. Everything else — the server render, jsdom, a browser without the API,
18
+ * and `prefers-reduced-motion: reduce`, under which no observer is attached at all — renders the
19
+ * content exactly as it will finally look.
7
20
  */
8
- export declare function Reveal({ children, delay, asChild, className, ...props }: RevealProp): import("react").JSX.Element;
21
+ export declare const Reveal: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLDivElement> & {
22
+ children?: import("../../props/index.js").ChildrenProp;
23
+ delay?: import("../../props/index.js").RevealDelayProp;
24
+ on?: import("../../props/index.js").RevealTriggerProp;
25
+ once?: boolean;
26
+ amount?: import("../../props/index.js").InViewAmountProp;
27
+ asChild?: import("../../props/index.js").AsChildProp;
28
+ className?: import("../../props/index.js").ClassNameProp;
29
+ } & React.RefAttributes<HTMLDivElement>>;
@@ -1,21 +1,51 @@
1
+ "use client";
1
2
  import { jsx } from "react/jsx-runtime";
2
- import { cloneElement, isValidElement } from "react";
3
+ import { mergeRefs } from "@react-aria/utils";
4
+ import * as React from "react";
5
+ import { useInView, useMediaQuery } from "../../lib/hooks.js";
3
6
  import { cn } from "../../lib/utils.js";
4
- function Reveal({ children, delay = 0, asChild = false, className, ...props }) {
7
+ const Reveal = React.forwardRef(function Reveal2({
8
+ children,
9
+ delay = 0,
10
+ on = "mount",
11
+ once = true,
12
+ amount = "some",
13
+ asChild = false,
14
+ className,
15
+ ...props
16
+ }, ref) {
17
+ const localRef = React.useRef(null);
18
+ const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
19
+ const observing = on === "view" && !reduceMotion;
20
+ const inView = useInView(localRef, { enabled: observing, once, amount });
5
21
  const shared = {
6
22
  "data-slot": "reveal",
7
- "data-reveal-delay": delay > 0 ? String(delay) : void 0
23
+ "data-reveal-delay": delay > 0 ? String(delay) : void 0,
24
+ "data-reveal-on": on === "view" ? "view" : void 0,
25
+ "data-reveal-state": observing ? inView ? "in" : "out" : void 0
8
26
  };
9
- if (asChild && isValidElement(children)) {
27
+ if (asChild && React.isValidElement(children)) {
10
28
  const child = children;
11
- return cloneElement(child, {
29
+ return React.cloneElement(child, {
12
30
  ...shared,
13
31
  ...props,
32
+ // React 19 passes `ref` through `cloneElement` like any other prop, which is what `asChild`
33
+ // promises — the observer measures the child's own box, not a wrapper's.
34
+ ref: mergeRefs(ref, localRef, child.props.ref),
14
35
  className: cn("ui-reveal", child.props.className, className)
15
36
  });
16
37
  }
17
- return /* @__PURE__ */ jsx("div", { ...shared, ...props, className: cn("ui-reveal", className), children });
18
- }
38
+ return /* @__PURE__ */ jsx(
39
+ "div",
40
+ {
41
+ ...shared,
42
+ ...props,
43
+ ref: mergeRefs(ref, localRef),
44
+ className: cn("ui-reveal", className),
45
+ children
46
+ }
47
+ );
48
+ });
19
49
  export {
20
50
  Reveal
21
51
  };
@@ -59,13 +59,16 @@ export declare const Link: React.ForwardRefExoticComponent<Omit<TextProp, "as" |
59
59
  } & React.RefAttributes<HTMLElement>>;
60
60
  /**
61
61
  * Heading — h1..h4 sized from the `--heading-h*` tokens. `level` sets both the size token and the
62
- * semantic element; override the rendered element with `as` (e.g. a visual h2 that is a real <h1>).
62
+ * semantic element; override the rendered element with `as` (e.g. a visual h2 that is a real <h1>),
63
+ * and override the SIZE alone with `size` — the same ten-step ladder `Text` reads, whose top three
64
+ * steps are the display ramp a marketing hero needs (gh#826).
63
65
  *
64
66
  * UNCHANGED by the antd port. antd's heading is `Title`, which is the sibling below; `Heading` is
65
67
  * this library's own and keeps its four levels, its props and its markup exactly as they shipped.
66
68
  */
67
69
  export declare const Heading: React.ForwardRefExoticComponent<Omit<React.HTMLAttributes<HTMLHeadingElement>, "color"> & {
68
70
  level?: import("../../props/index.js").HeadingLevelProp;
71
+ size?: import("../../props/index.js").TextSizeProp;
69
72
  as?: "h1" | "h2" | "h3" | "h4" | "div";
70
73
  tone?: import("../../props/index.js").TextToneProp;
71
74
  align?: import("../../props/index.js").TextAlignProp;
@@ -527,10 +527,23 @@ const Link = React.forwardRef(
527
527
  );
528
528
  Link.displayName = "Link";
529
529
  const Heading = React.forwardRef(
530
- ({ level = 2, as, tone = "default", align, truncate, weight = "medium", className, ...props }, ref) => React.createElement(as ?? `h${level}`, {
530
+ ({
531
+ level = 2,
532
+ size,
533
+ as,
534
+ tone = "default",
535
+ align,
536
+ truncate,
537
+ weight = "medium",
538
+ className,
539
+ ...props
540
+ }, ref) => React.createElement(as ?? `h${level}`, {
531
541
  ref,
532
542
  "data-slot": "heading",
533
543
  "data-level": level,
544
+ // Emitted only when asked for. The absent attribute is what `[data-level]:not([data-size])`
545
+ // keys on, so a Heading with no `size` paints from its level exactly as it always has.
546
+ "data-size": size,
534
547
  "data-tone": tone,
535
548
  "data-align": align,
536
549
  "data-weight": weight,
@@ -0,0 +1,86 @@
1
+ import * as React from "react";
2
+ import type { AffixProp } from "../../props/components/layout.prop.js";
3
+ export type { AffixProp, AffixProp as AffixProps, AffixTargetProp, } from "../../props/components/layout.prop.js";
4
+ /**
5
+ * Affix — Ant Design `Affix` (6.6.5): pin an element to its scrollport once the page scrolls past
6
+ * it, and REPORT that it is pinned.
7
+ *
8
+ * ## Why this is not `position: sticky`
9
+ *
10
+ * `position: sticky` pins, and then says nothing. There is no state, no attribute and no callback,
11
+ * so a sticky header cannot shrink on pin, cannot swap a wordmark for a monogram, cannot raise its
12
+ * shadow, and cannot tell the page that the filters are now floating over the table. Both website
13
+ * showcases in `docs/showcase/` wrote `position: sticky` and got exactly nothing out of it. This
14
+ * component's product is the boolean: `data-affixed` for CSS, `onChange` for JavaScript.
15
+ *
16
+ * It also pins against a scroll box that need not be the nearest scrolling ancestor — antd's
17
+ * `target`, which `FloatButton.BackTop` already spells the same way here — where `sticky` is
18
+ * captive to whichever ancestor happens to scroll.
19
+ *
20
+ * ## The placeholder is as much the component as the pin is
21
+ *
22
+ * Taking an element out of flow removes its height from the page, so everything below jumps UP by
23
+ * exactly that height at the instant of pinning and drops back on release. Every hand-rolled
24
+ * sticky header has this bug and it is invisible until someone measures it. So the outer box stays
25
+ * in flow and, the moment its content goes `position: fixed`, is given the content's MEASURED
26
+ * block size. Measured, not declared: a bar re-wraps between widths, and a number written once in
27
+ * CSS is wrong at every other width.
28
+ *
29
+ * ## One `IntersectionObserver`, and it is not this file's
30
+ *
31
+ * The pin is a threshold crossing — the one question `IntersectionObserver` exists to answer — and
32
+ * this component asks it through `useInView` (`src/lib/hooks.ts`), the library's single observer
33
+ * wrapper, which gained `rootMargin` and `assumeInView` for exactly these two needs. Nothing here
34
+ * polls, and nothing here listens to `scroll` at all when the scroll box is the viewport: antd's
35
+ * `Affix` re-measures on seven event types on every animation frame of every scroll, and all of
36
+ * that is replaced by one observer entry per transition.
37
+ *
38
+ * The observed element is a zero-ish **sentinel** at the pinning edge, not the box itself. This is
39
+ * the published `position: sticky` sentinel technique ("An event for position: sticky",
40
+ * developers.google.com), and the separate node is arithmetic, not decoration: the box's own
41
+ * intersection flips when its TRAILING edge crosses the line, a full bar-height after the moment
42
+ * it should pin. A hairline node at the LEADING edge flips on the leading edge. It is
43
+ * `aria-hidden`, out of flow, and contributes nothing to the measured height.
44
+ *
45
+ * Both directions reduce to the same expression — `affixed = !inView` — because the sentinel sits
46
+ * at the block-start edge with the clipping box grown downwards, and at the block-end edge with it
47
+ * grown upwards.
48
+ *
49
+ * ## The offset is a TOKEN that the prop overrides, and it is never a number in JavaScript
50
+ *
51
+ * `--affix-inset-block-start` / `--affix-inset-block-end` place the pin line, and they place it
52
+ * ONCE: the sentinel is offset by `calc(-1 * …)`, which moves the crossing, and the pinned bar is
53
+ * inset by the same `var()`, which moves the paint. One declaration drives both, so they cannot
54
+ * drift. A service sets the resting offset once (a product whose app header is 64px tall writes
55
+ * `--affix-inset-block-start: 64px` in its theme and every affixed bar clears it); `offsetBlockStart`
56
+ * / `offsetBlockEnd` override it per instance by writing the same custom property inline. That is
57
+ * cardinal rules #44/#45 in their literal form.
58
+ *
59
+ * It also means the offset is never parsed back out of `getComputedStyle` — a custom property
60
+ * hands back its AUTHORED text, so a theme written in `rem` would have come back as `"4rem"` and
61
+ * `parseFloat` would have read it as 4px. CSS resolves the unit; JavaScript never sees it.
62
+ *
63
+ * ## What a pinned bar owes the keyboard (C6)
64
+ *
65
+ * A bar pinned over the block-start edge covers whatever the browser scrolls to — the next focused
66
+ * field, the `#section` a skip link jumps to — and the browser has no idea it is there. While
67
+ * pinned there, this sets `scroll-padding-block-start` on the scroll box to the bar's measured
68
+ * bottom edge, and puts back whatever was there on release. That is WCAG 2.4.11 (Focus Not
69
+ * Obscured) bought with one declaration, and it is why C6 passes rather than being asserted.
70
+ *
71
+ * ## Motion
72
+ *
73
+ * `Affix` animates nothing, so it has no transition to suppress and satisfies
74
+ * `prefers-reduced-motion: reduce` by construction — the pin is instantaneous in every mode. The
75
+ * SHRINK is a composition: read `[data-affixed]` off `data-slot="affix-content"` and transition
76
+ * whatever the brand condenses, on `--duration-fast` / `--ease-standard`, snapping under reduced
77
+ * motion. It must condense; it must never fade or disappear.
78
+ *
79
+ * ## One inherited limitation, stated rather than hidden
80
+ *
81
+ * A `transform`, `filter` or `backdrop-filter` on an ancestor makes that ancestor the containing
82
+ * block for `position: fixed`, so a pinned bar inside one is positioned against it instead of the
83
+ * viewport. `position: sticky` and antd's `Affix` are both subject to it; there is no workaround
84
+ * inside a component, only a call site that does not do that.
85
+ */
86
+ export declare const Affix: React.ForwardRefExoticComponent<AffixProp & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,187 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { isDevelopment } from "../../lib/dev.js";
5
+ import { useInView } from "../../lib/hooks.js";
6
+ import { cn } from "../../lib/utils.js";
7
+ const OVERSHOOT_MARGIN = "1000000px";
8
+ const ROOT_MARGIN_BLOCK_START = `0px 0px ${OVERSHOOT_MARGIN} 0px`;
9
+ const ROOT_MARGIN_BLOCK_END = `${OVERSHOOT_MARGIN} 0px 0px 0px`;
10
+ function resolveTarget(target) {
11
+ if (!target) return null;
12
+ const node = target();
13
+ if (!node || typeof window === "undefined" || node === window) return null;
14
+ return node;
15
+ }
16
+ const Affix = React.forwardRef(function Affix2({
17
+ children,
18
+ offsetBlockStart,
19
+ offsetBlockEnd,
20
+ offsetTop,
21
+ offsetBottom,
22
+ target,
23
+ onChange,
24
+ id,
25
+ className,
26
+ ...rest
27
+ }, ref) {
28
+ const rootRef = React.useRef(null);
29
+ const sentinelRef = React.useRef(null);
30
+ const contentRef = React.useRef(null);
31
+ if (isDevelopment()) {
32
+ if (offsetTop !== void 0) {
33
+ console.warn(
34
+ "[@godxjp/ui] Affix: `offsetTop` is Ant Design's name for this axis; here it is `offsetBlockStart` \u2014 the logical axis, per check:rtl and docs/DESIGN-AUTHORITY.md. The value was IGNORED."
35
+ );
36
+ }
37
+ if (offsetBottom !== void 0) {
38
+ console.warn(
39
+ "[@godxjp/ui] Affix: `offsetBottom` is Ant Design's name for this axis; here it is `offsetBlockEnd` \u2014 the logical axis, per check:rtl and docs/DESIGN-AUTHORITY.md. The value was IGNORED."
40
+ );
41
+ }
42
+ }
43
+ const pinToEnd = offsetBlockStart === void 0 && offsetBlockEnd !== void 0;
44
+ const [targetElement, setTargetElement] = React.useState(null);
45
+ React.useEffect(() => {
46
+ setTargetElement(resolveTarget(target));
47
+ }, [target]);
48
+ const inView = useInView(sentinelRef, {
49
+ root: targetElement,
50
+ rootMargin: pinToEnd ? ROOT_MARGIN_BLOCK_END : ROOT_MARGIN_BLOCK_START,
51
+ assumeInView: true
52
+ });
53
+ const affixed = !inView;
54
+ const [box, setBox] = React.useState(null);
55
+ const [targetInset, setTargetInset] = React.useState(0);
56
+ const measure = React.useCallback(() => {
57
+ const root = rootRef.current;
58
+ const content = contentRef.current;
59
+ if (!root || !content) return;
60
+ const rootRect = root.getBoundingClientRect();
61
+ const contentRect = content.getBoundingClientRect();
62
+ setBox(
63
+ (previous) => previous && previous.inline === rootRect.width && previous.block === contentRect.height ? previous : { inline: rootRect.width, block: contentRect.height }
64
+ );
65
+ const rect = targetElement?.getBoundingClientRect();
66
+ const next = rect ? pinToEnd ? window.innerHeight - rect.bottom : rect.top : 0;
67
+ setTargetInset((previous) => previous === next ? previous : next);
68
+ }, [pinToEnd, targetElement]);
69
+ React.useLayoutEffect(() => {
70
+ measure();
71
+ }, [measure, affixed, children]);
72
+ React.useEffect(() => {
73
+ const root = rootRef.current;
74
+ const content = contentRef.current;
75
+ if (!root || typeof ResizeObserver === "undefined") return void 0;
76
+ const observer = new ResizeObserver(() => {
77
+ measure();
78
+ });
79
+ observer.observe(root);
80
+ if (content) observer.observe(content);
81
+ if (targetElement) observer.observe(targetElement);
82
+ return () => {
83
+ observer.disconnect();
84
+ };
85
+ }, [measure, targetElement]);
86
+ React.useEffect(() => {
87
+ if (!affixed || !targetElement || typeof window === "undefined") return void 0;
88
+ let frame = null;
89
+ const onScroll = () => {
90
+ if (frame !== null) return;
91
+ frame = window.requestAnimationFrame(() => {
92
+ frame = null;
93
+ measure();
94
+ });
95
+ };
96
+ window.addEventListener("scroll", onScroll, true);
97
+ window.addEventListener("resize", onScroll);
98
+ return () => {
99
+ window.removeEventListener("scroll", onScroll, true);
100
+ window.removeEventListener("resize", onScroll);
101
+ if (frame !== null) window.cancelAnimationFrame(frame);
102
+ };
103
+ }, [affixed, targetElement, measure]);
104
+ React.useEffect(() => {
105
+ const content = contentRef.current;
106
+ if (pinToEnd || !affixed || !content || typeof document === "undefined") return void 0;
107
+ const scrollBox = targetElement ?? document.documentElement;
108
+ const boxTop = targetElement ? targetElement.getBoundingClientRect().top : 0;
109
+ const padding = content.getBoundingClientRect().bottom - boxTop;
110
+ if (padding <= 0) return void 0;
111
+ const previous = scrollBox.style.scrollPaddingBlockStart;
112
+ scrollBox.style.scrollPaddingBlockStart = `${padding}px`;
113
+ return () => {
114
+ scrollBox.style.scrollPaddingBlockStart = previous;
115
+ };
116
+ }, [affixed, pinToEnd, targetInset, box?.block, targetElement]);
117
+ const onChangeRef = React.useRef(onChange);
118
+ React.useEffect(() => {
119
+ onChangeRef.current = onChange;
120
+ });
121
+ const mountedRef = React.useRef(false);
122
+ React.useEffect(() => {
123
+ if (!mountedRef.current) {
124
+ mountedRef.current = true;
125
+ return;
126
+ }
127
+ onChangeRef.current?.(affixed);
128
+ }, [affixed]);
129
+ const style = {
130
+ ...offsetBlockStart === void 0 ? void 0 : { "--affix-inset-block-start": `${offsetBlockStart}px` },
131
+ ...offsetBlockEnd === void 0 ? void 0 : { "--affix-inset-block-end": `${offsetBlockEnd}px` },
132
+ ...targetInset === 0 ? void 0 : { "--affix-target-inset": `${targetInset}px` },
133
+ ...affixed && box ? { "--affix-content-inline-size": `${box.inline}px` } : void 0
134
+ };
135
+ return /* @__PURE__ */ jsxs(
136
+ "div",
137
+ {
138
+ ref: (node) => {
139
+ rootRef.current = node;
140
+ if (typeof ref === "function") ref(node);
141
+ else if (ref) ref.current = node;
142
+ },
143
+ id,
144
+ "data-slot": "affix",
145
+ "data-affixed": affixed ? "" : void 0,
146
+ className: cn("ui-affix", className),
147
+ style,
148
+ ...rest,
149
+ children: [
150
+ /* @__PURE__ */ jsx(
151
+ "div",
152
+ {
153
+ ref: sentinelRef,
154
+ "data-slot": "affix-sentinel",
155
+ "data-edge": pinToEnd ? "block-end" : "block-start",
156
+ "aria-hidden": "true",
157
+ className: "ui-affix-sentinel"
158
+ }
159
+ ),
160
+ affixed && box ? /* @__PURE__ */ jsx(
161
+ "div",
162
+ {
163
+ "data-slot": "affix-placeholder",
164
+ "aria-hidden": "true",
165
+ className: "ui-affix-placeholder",
166
+ style: { blockSize: box.block }
167
+ }
168
+ ) : null,
169
+ /* @__PURE__ */ jsx(
170
+ "div",
171
+ {
172
+ ref: contentRef,
173
+ "data-slot": "affix-content",
174
+ "data-edge": pinToEnd ? "block-end" : "block-start",
175
+ "data-affixed": affixed ? "" : void 0,
176
+ className: "ui-affix-content",
177
+ children
178
+ }
179
+ )
180
+ ]
181
+ }
182
+ );
183
+ });
184
+ Affix.displayName = "Affix";
185
+ export {
186
+ Affix
187
+ };
@@ -59,3 +59,7 @@ export type { LegalDocumentSectionProp, LegalDocumentShellProp, LegalDocumentShe
59
59
  export { ServiceRolePanel } from "./service-role-panel.js";
60
60
  export type { ServiceRoleItemProp, ServiceRolePanelProp, ServiceRolePanelProps, } from "./service-role-panel.js";
61
61
  export { useAppShellNavigationMode } from "./app-shell.js";
62
+ export { Masonry } from "./masonry.js";
63
+ export type { MasonryColumnsProp, MasonryGapProp, MasonryItemProp, MasonryLayoutEntryProp, MasonryProp, MasonryProps, } from "./masonry.js";
64
+ export { Affix } from "./affix.js";
65
+ export type { AffixProp, AffixProps, AffixTargetProp } from "./affix.js";
@@ -28,8 +28,11 @@ import { AspectRatio } from "./aspect-ratio.js";
28
28
  import { LegalDocumentShell } from "./legal-document-shell.js";
29
29
  import { ServiceRolePanel } from "./service-role-panel.js";
30
30
  import { useAppShellNavigationMode } from "./app-shell.js";
31
+ import { Masonry } from "./masonry.js";
32
+ import { Affix } from "./affix.js";
31
33
  export {
32
34
  AccountChip,
35
+ Affix,
33
36
  AppLauncher,
34
37
  AppShell,
35
38
  AspectRatio,
@@ -45,6 +48,7 @@ export {
45
48
  ErrorSurface,
46
49
  Flex,
47
50
  LegalDocumentShell,
51
+ Masonry,
48
52
  MasterDetail,
49
53
  MobileShell,
50
54
  NavList,
@@ -6,6 +6,7 @@ import { useControlledLatch } from "../../lib/hooks.js";
6
6
  import { cn } from "../../lib/utils.js";
7
7
  import { Heading } from "../general/typography.js";
8
8
  const SPY_ROOT_MARGIN = "-10% 0px -75% 0px";
9
+ const SECTION_ID_SEPARATOR = "\0";
9
10
  const ISO_CALENDAR_DATE = /^\d{4}-\d{2}-\d{2}$/;
10
11
  function prefersReducedMotion() {
11
12
  if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
@@ -75,18 +76,18 @@ const LegalDocumentShell = React.forwardRef(
75
76
  activeRef.current = active;
76
77
  emitRef.current = emit;
77
78
  });
78
- const sectionIdsKey = sections.map((section) => section.id).join("\0");
79
+ const sectionIdsKey = sections.map((section) => section.id).join(SECTION_ID_SEPARATOR);
79
80
  const hashHandledRef = React.useRef(false);
80
81
  React.useEffect(() => {
81
82
  if (hashHandledRef.current || typeof window === "undefined") return;
82
83
  hashHandledRef.current = true;
83
84
  const hash = decodeURIComponent(window.location.hash.replace(/^#/, ""));
84
- if (hash && sectionIdsKey.split("\0").includes(hash)) emitRef.current(hash);
85
+ if (hash && sectionIdsKey.split(SECTION_ID_SEPARATOR).includes(hash)) emitRef.current(hash);
85
86
  }, [sectionIdsKey]);
86
87
  React.useEffect(() => {
87
88
  const root = rootRef.current;
88
89
  if (!root || typeof IntersectionObserver === "undefined") return;
89
- const ids = sectionIdsKey ? sectionIdsKey.split("\0") : [];
90
+ const ids = sectionIdsKey ? sectionIdsKey.split(SECTION_ID_SEPARATOR) : [];
90
91
  const intersecting = /* @__PURE__ */ new Map();
91
92
  const observer = new IntersectionObserver(
92
93
  (entries) => {