@godxjp/ui 28.6.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 (99) hide show
  1. package/dist/components/data-display/data-table.d.ts +10 -2
  2. package/dist/components/data-display/data-table.js +12 -5
  3. package/dist/components/data-display/index.d.ts +2 -0
  4. package/dist/components/data-display/index.js +2 -0
  5. package/dist/components/data-display/marquee.d.ts +16 -0
  6. package/dist/components/data-display/marquee.js +155 -0
  7. package/dist/components/data-display/scroll-area.js +16 -2
  8. package/dist/components/data-display/service-launcher-card.d.ts +25 -0
  9. package/dist/components/data-display/table.d.ts +38 -1
  10. package/dist/components/data-display/table.js +57 -39
  11. package/dist/components/data-entry/form-field.js +29 -12
  12. package/dist/components/general/reveal.d.ts +23 -2
  13. package/dist/components/general/reveal.js +37 -7
  14. package/dist/components/general/typography.d.ts +4 -1
  15. package/dist/components/general/typography.js +14 -1
  16. package/dist/components/layout/affix.d.ts +86 -0
  17. package/dist/components/layout/affix.js +187 -0
  18. package/dist/components/layout/index.d.ts +4 -0
  19. package/dist/components/layout/index.js +4 -0
  20. package/dist/components/layout/legal-document-shell.js +4 -3
  21. package/dist/components/layout/masonry.d.ts +74 -0
  22. package/dist/components/layout/masonry.js +214 -0
  23. package/dist/components/layout/page-container.js +5 -20
  24. package/dist/components/navigation/anchor.d.ts +64 -0
  25. package/dist/components/navigation/anchor.js +284 -0
  26. package/dist/components/navigation/app-setting-picker.js +20 -3
  27. package/dist/components/navigation/index.d.ts +4 -0
  28. package/dist/components/navigation/index.js +4 -0
  29. package/dist/components/navigation/mega-menu.d.ts +21 -0
  30. package/dist/components/navigation/mega-menu.js +526 -0
  31. package/dist/components/ui/table.js +1 -0
  32. package/dist/contracts/measurement.json +1 -1
  33. package/dist/i18n/messages/en.json +522 -1
  34. package/dist/i18n/messages/ja.json +518 -1
  35. package/dist/i18n/messages/vi.json +518 -1
  36. package/dist/lib/hooks.d.ts +91 -0
  37. package/dist/lib/hooks.js +79 -0
  38. package/dist/lib/platform.d.ts +14 -0
  39. package/dist/lib/platform.js +10 -1
  40. package/dist/lib/utils.d.ts +1 -1
  41. package/dist/lib/utils.js +3 -2
  42. package/dist/props/components/data-display.prop.d.ts +109 -1
  43. package/dist/props/components/general.prop.d.ts +47 -3
  44. package/dist/props/components/layout.prop.d.ts +194 -0
  45. package/dist/props/components/navigation.prop.d.ts +263 -0
  46. package/dist/props/registry.d.ts +360 -5
  47. package/dist/props/registry.js +474 -4
  48. package/dist/props/vocabulary/index.d.ts +1 -1
  49. package/dist/props/vocabulary/interaction.prop.d.ts +39 -2
  50. package/dist/styles/control.css +5 -6
  51. package/dist/styles/data-display-layout.css +2 -1
  52. package/dist/styles/density.css +4 -0
  53. package/dist/styles/form-layout.css +22 -0
  54. package/dist/styles/layout.css +79 -1
  55. package/dist/styles/motion.css +121 -1
  56. package/dist/styles/navigation-layout.css +415 -20
  57. package/dist/styles/shell-layout.css +3 -0
  58. package/dist/styles/text-layout.css +52 -4
  59. package/dist/tokens/base.css +5 -0
  60. package/dist/tokens/components/affix.css +7 -0
  61. package/dist/tokens/components/anchor.css +17 -0
  62. package/dist/tokens/components/control.css +3 -3
  63. package/dist/tokens/components/form.css +3 -1
  64. package/dist/tokens/components/marquee.css +7 -0
  65. package/dist/tokens/components/masonry.css +6 -0
  66. package/dist/tokens/components/mega-menu.css +62 -0
  67. package/dist/tokens/components/navigation.css +2 -0
  68. package/dist/tokens/components/shell.css +3 -0
  69. package/dist/tokens/foundation.css +11 -0
  70. package/dist/tokens/semantic/layout.css +7 -0
  71. package/docs/COMPOSITION-VS-COMPONENT.md +21 -0
  72. package/docs/DESIGN-AUTHORITY.md +124 -23
  73. package/docs/FRAME-COVERAGE-REPORT.md +7 -2
  74. package/docs/data-display/marquee.tsx +254 -0
  75. package/docs/data-display/scroll-area.tsx +31 -0
  76. package/docs/data-display/service-launcher-card.tsx +275 -115
  77. package/docs/foundation/_theme-editor-scope.ts +222 -0
  78. package/docs/foundation/density.tsx +12 -2
  79. package/docs/foundation/spacing.tsx +5 -0
  80. package/docs/foundation/theme-editor.tsx +645 -0
  81. package/docs/general/activity.tsx +65 -0
  82. package/docs/general/reveal.tsx +290 -22
  83. package/docs/general/typography.tsx +91 -1
  84. package/docs/layout/affix.tsx +209 -0
  85. package/docs/layout/masonry.tsx +291 -0
  86. package/docs/navigation/anchor.tsx +285 -0
  87. package/docs/navigation/mega-menu-panel.tsx +86 -0
  88. package/docs/navigation/mega-menu.tsx +254 -0
  89. package/docs/roadmap/website-components.md +779 -0
  90. package/docs/showcase/acme-website.tsx +75 -39
  91. package/docs/showcase/futurelastic-web.tsx +91 -49
  92. package/docs/showcase/marketing-page.tsx +885 -0
  93. package/docs/showcase/table-footer-totals.tsx +12 -2
  94. package/docs/showcase/theme-customization.tsx +1259 -0
  95. package/package.json +8 -5
  96. package/scripts/brand-accent.generated.mjs +27 -0
  97. package/scripts/ui-audit.mjs +66 -0
  98. package/scripts/visual-audit-rules.mjs +46 -2
  99. package/scripts/visual-audit.mjs +29 -1
@@ -1,6 +1,6 @@
1
1
  import * as React from "react";
2
2
  import { flexRender, type ColumnVisibilityState, type OnChangeFn, type PaginationState } from "@tanstack/react-table";
3
- import type { BreakpointProp, ColumnDefProp, DensityProp, OnColumnFilterChangeProp, OnRowProp, RowToneProp, SortDirectionProp, SortStateProp, TableExpandableProp, TablePaginationProp, TablePresetProp, TableRowSelectionProp, TableScrollProp, TableStickyProp, TableSummaryProp } from "../../props/vocabulary/index.js";
3
+ import type { BreakpointProp, ColumnDefProp, DensityProp, LabelProp, OnColumnFilterChangeProp, OnRowProp, RowToneProp, SortDirectionProp, SortStateProp, TableExpandableProp, TablePaginationProp, TablePresetProp, TableRowSelectionProp, TableScrollProp, TableStickyProp, TableSummaryProp } from "../../props/vocabulary/index.js";
4
4
  export type Density = DensityProp;
5
5
  /**
6
6
  * Lean column definition — the simple, common-case column API. `render` shapes a cell; `sortable`
@@ -93,6 +93,14 @@ interface DataTableProps<T> {
93
93
  preset?: TablePresetProp;
94
94
  /** Default `"sm"`. Ignored while `preset` is `"default"`. */
95
95
  collapseBelow?: BreakpointProp;
96
+ /**
97
+ * Accessible name for the horizontal-scroll REGION (the `tabindex="0"` wrapper a keyboard user
98
+ * lands on), not for the `<table>`. Optional: left out, the region takes the localized
99
+ * `dataTable.scrollRegion` default, so no consumer has to invent a name for every list. The
100
+ * region is announced only while it HAS overflow to reach. Same contract as `Table label`.
101
+ * (gh#817)
102
+ */
103
+ label?: LabelProp;
96
104
  /**
97
105
  * Per-row className for state-based row tinting (e.g. flag an invalid or empty record). Returned
98
106
  * classes are appended last, so they win over the built-in hover/selected fills.
@@ -146,7 +154,7 @@ interface DataTableProps<T> {
146
154
  className?: string;
147
155
  children?: React.ReactNode;
148
156
  }
149
- export declare function DataTable<T>({ data, columns, getRowId, getRowLabel, selectable, selected: controlledSelected, onSelectChange, onRowClick, density: controlledDensity, onDensityChange, sort, onSortChange, globalFilter: controlledGlobalFilter, onGlobalFilterChange, pagination: paginationProp, onPaginationChange, rowCount, columnVisibility: controlledVisibility, onColumnVisibilityChange, manualSorting, manualFiltering, manualPagination, loading, empty, error, denied, onRetry, striped, hoverable, stickyHeader, preset, collapseBelow, rowClassName, rowTone, rowSelection, expandable, summary, scroll, sticky, onRow, bordered, showSorterTooltip, sortDirections, onFilterChange, className, children, }: DataTableProps<T>): React.JSX.Element;
157
+ export declare function DataTable<T>({ data, columns, getRowId, getRowLabel, selectable, selected: controlledSelected, onSelectChange, onRowClick, density: controlledDensity, onDensityChange, sort, onSortChange, globalFilter: controlledGlobalFilter, onGlobalFilterChange, pagination: paginationProp, onPaginationChange, rowCount, columnVisibility: controlledVisibility, onColumnVisibilityChange, manualSorting, manualFiltering, manualPagination, loading, empty, error, denied, onRetry, striped, hoverable, stickyHeader, preset, collapseBelow, label, rowClassName, rowTone, rowSelection, expandable, summary, scroll, sticky, onRow, bordered, showSorterTooltip, sortDirections, onFilterChange, className, children, }: DataTableProps<T>): React.JSX.Element;
150
158
  export declare namespace DataTable {
151
159
  export var Toolbar: ({ children, className, }: {
152
160
  children?: React.ReactNode;
@@ -65,9 +65,11 @@ import {
65
65
  TableCell,
66
66
  TableHead,
67
67
  TableHeader,
68
- TableRow
68
+ TableRow,
69
+ scrollRegionLabel
69
70
  } from "../data-display/table.js";
70
71
  import { cn } from "../../lib/utils.js";
72
+ import { useScrollsHorizontally } from "../../lib/hooks.js";
71
73
  import { densityClass } from "../../lib/variants.js";
72
74
  import {
73
75
  controlIconSmClass,
@@ -230,6 +232,7 @@ function DataTable({
230
232
  stickyHeader = true,
231
233
  preset = "default",
232
234
  collapseBelow = "sm",
235
+ label,
233
236
  rowClassName,
234
237
  rowTone,
235
238
  rowSelection,
@@ -450,6 +453,7 @@ function DataTable({
450
453
  stickyHeader: sticky === void 0 ? stickyHeader : sticky !== false,
451
454
  preset,
452
455
  collapseBelow,
456
+ label,
453
457
  rowClassName,
454
458
  rowTone,
455
459
  bordered,
@@ -774,6 +778,7 @@ DataTable.Content = function DataTableContent() {
774
778
  stickyHeader,
775
779
  preset,
776
780
  collapseBelow,
781
+ label,
777
782
  rowClassName,
778
783
  rowTone,
779
784
  bordered,
@@ -816,6 +821,7 @@ DataTable.Content = function DataTableContent() {
816
821
  }, [missingHeaderNames]);
817
822
  const selectLabelColumn = visibleColumns.find((col) => col.priority === "primary") ?? visibleColumns[0];
818
823
  const scrollRef = React.useRef(null);
824
+ const scrolls = useScrollsHorizontally(scrollRef, !presetAttr);
819
825
  const [hasOverflowEnd, setHasOverflowEnd] = React.useState(false);
820
826
  React.useEffect(() => {
821
827
  const el = scrollRef.current;
@@ -923,7 +929,7 @@ DataTable.Content = function DataTableContent() {
923
929
  "data-scroll-x": scroll?.x !== void 0 ? "" : void 0,
924
930
  "data-scroll-y": scroll?.y !== void 0 ? "" : void 0,
925
931
  "aria-busy": loading,
926
- tabIndex: presetAttr ? void 0 : 0,
932
+ ...scrolls ? { role: "group", "aria-label": scrollRegionLabel(label, t), tabIndex: 0 } : {},
927
933
  children: /* @__PURE__ */ jsx(
928
934
  "div",
929
935
  {
@@ -936,6 +942,7 @@ DataTable.Content = function DataTableContent() {
936
942
  Table,
937
943
  {
938
944
  scrollable: preset !== "default",
945
+ label,
939
946
  preset,
940
947
  collapseBelow,
941
948
  bordered,
@@ -982,7 +989,7 @@ DataTable.Content = function DataTableContent() {
982
989
  ) : null;
983
990
  const headerEmpty = isEmptyHeader(col.header);
984
991
  const headerContent = headerEmpty && col.ariaLabel ? /* @__PURE__ */ jsx("span", { className: "sr-only", children: col.ariaLabel }) : col.header;
985
- const label = /* @__PURE__ */ jsxs("span", { className: "ui-data-table-sort-label", children: [
992
+ const label2 = /* @__PURE__ */ jsxs("span", { className: "ui-data-table-sort-label", children: [
986
993
  headerContent,
987
994
  sortIndicator
988
995
  ] });
@@ -1000,7 +1007,7 @@ DataTable.Content = function DataTableContent() {
1000
1007
  onClick: () => {
1001
1008
  onHeaderClick(col);
1002
1009
  },
1003
- children: label
1010
+ children: label2
1004
1011
  }
1005
1012
  );
1006
1013
  return /* @__PURE__ */ jsxs(
@@ -1027,7 +1034,7 @@ DataTable.Content = function DataTableContent() {
1027
1034
  isSortable ? tooltipOn ? /* @__PURE__ */ jsxs(Tooltip, { children: [
1028
1035
  /* @__PURE__ */ jsx(TooltipTrigger, { asChild: true, children: sortButton }),
1029
1036
  /* @__PURE__ */ jsx(TooltipContent, { children: sortHint })
1030
- ] }) : sortButton : label,
1037
+ ] }) : sortButton : label2,
1031
1038
  col.filters ? /* @__PURE__ */ jsx(ColumnFilterMenu, { column: col }) : null
1032
1039
  ]
1033
1040
  },
@@ -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,6 +1,8 @@
1
1
  "use client";
2
2
  import { jsx } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
5
+ import { useScrollsOnAxis } from "../../lib/hooks.js";
4
6
  import { cn } from "../../lib/utils.js";
5
7
  const ANCHOR_OFFSET_TOKEN = "--scroll-area-anchor-offset";
6
8
  const ANCHOR_OFFSET_FALLBACK_PX = 48;
@@ -121,12 +123,16 @@ const ScrollArea = React.forwardRef(
121
123
  onAnchoredChange,
122
124
  orientation = "vertical",
123
125
  scrollbar = "auto",
126
+ label,
124
127
  ...props
125
128
  }, ref) => {
129
+ const { t } = useTranslation();
126
130
  const [viewport, setViewport] = React.useState(null);
131
+ const viewportHandle = React.useRef(null);
127
132
  const attachViewport = React.useCallback(
128
133
  (node) => {
129
134
  setViewport(node);
135
+ viewportHandle.current = node;
130
136
  for (const target of [ref, viewportRef]) {
131
137
  if (typeof target === "function") target(node);
132
138
  else if (target) target.current = node;
@@ -135,9 +141,17 @@ const ScrollArea = React.forwardRef(
135
141
  [ref, viewportRef]
136
142
  );
137
143
  useBottomAnchor(viewport, anchor === "bottom", anchorOffset, onAnchoredChange);
144
+ const scrolls = useScrollsOnAxis(viewportHandle, true, orientation);
145
+ const regionLabel = typeof label === "string" && label.trim() !== "" ? label : t("dataDisplay.scrollArea.region");
138
146
  return (
139
147
  // `tabIndex={0}` keeps the scroll viewport keyboard-reachable so overflowing content can be
140
- // scrolled without a pointer (WCAG 2.1.1 / axe scrollable-region-focusable).
148
+ // scrolled without a pointer (WCAG 2.1.1 / axe scrollable-region-focusable). That stop must
149
+ // not be ANONYMOUS: unroled and unnamed it announces nothing, so it carries `role="group"`
150
+ // plus an accessible name (gh#821, the same defect gh#817 fixed in Table). `group`, not
151
+ // `region`: a named `region` IS a landmark, and a page with several scroll areas would ship
152
+ // several identically-named landmarks (axe `landmark-unique`). All three attributes come and
153
+ // go together with the overflow, because a focus stop that scrolls nothing is noise — and
154
+ // they precede `{...props}`, so a consumer's own `role`/`aria-label`/`tabIndex` still wins.
141
155
  //
142
156
  // NO `dir` IS STAMPED HERE, deliberately. Radix's Root called `useDirection(dir)`, which
143
157
  // falls back to the literal "ltr" and writes it onto the element — and a `dir` attribute is
@@ -150,12 +164,12 @@ const ScrollArea = React.forwardRef(
150
164
  "div",
151
165
  {
152
166
  ref: attachViewport,
153
- tabIndex: 0,
154
167
  "data-slot": "scroll-area-viewport",
155
168
  "data-anchor": anchor,
156
169
  "data-orientation": orientation,
157
170
  "data-scrollbar": scrollbar,
158
171
  className: cn("ui-scroll-area", className),
172
+ ...scrolls ? { role: "group", "aria-label": regionLabel, tabIndex: 0 } : {},
159
173
  ...props,
160
174
  children: /* @__PURE__ */ jsx("div", { "data-slot": "scroll-area-content", className: "ui-scroll-area-content", children })
161
175
  }
@@ -1,3 +1,28 @@
1
+ /**
2
+ * RECORDED EXCEPTION to the Framework-Component Test (gh#814).
3
+ *
4
+ * `docs/COMPOSITION-VS-COMPONENT.md` §2 says all seven criteria must PASS and any FAIL makes a
5
+ * thing a composition pattern. This file FAILS two of them, and stays anyway:
6
+ *
7
+ * C2 (encapsulates reusable BEHAVIOR) — FAIL. It owns no state, no keyboard handling and no
8
+ * focus management; its only ARIA is three static attributes. §2 calls this "pure static
9
+ * layout/visual arrangement".
10
+ * C3 (not expressible by composing primitives) — FAIL. Its own imports are the answer: this is
11
+ * `Card` + `CardContent` + `Badge` + one Lucide glyph, and §2's heuristic — "could I build
12
+ * this right now from existing primitives + token overrides?" — is answered yes.
13
+ *
14
+ * It is RETAINED because `src/components/layout/app-launcher.tsx` consumes it: it is an internal
15
+ * building block of a component that does pass the test, so deleting it is not on the table, and
16
+ * removing a public export is breaking. Keeping it public was the cheaper call.
17
+ *
18
+ * SO: a consumer reaching for a service tile should compose `Card` + `CardContent` + `Badge`
19
+ * themselves, which is what C3 says. Reach for `ServiceLauncherCard` only to match `AppLauncher`'s
20
+ * own tiles. Do not cite it as precedent for adding another static tile to `src/components/` —
21
+ * the test still means what it says, and this is the exception that was argued and written down
22
+ * rather than the rule.
23
+ *
24
+ * Ledger, options considered and the decision: gh#814.
25
+ */
1
26
  import * as React from "react";
2
27
  import type { LucideIcon } from "lucide-react";
3
28
  import type { HeadingLevelProp, ToneProp } from "../../props/vocabulary/index.js";
@@ -1,5 +1,5 @@
1
1
  import * as React from "react";
2
- import type { BreakpointProp, FlushProp, TableCellIndentProp, TableColumnPriorityProp, TablePresetProp, WidthProp } from "../../props/vocabulary/index.js";
2
+ import type { BreakpointProp, FlushProp, LabelProp, TableCellIndentProp, TableColumnPriorityProp, TablePresetProp, WidthProp } from "../../props/vocabulary/index.js";
3
3
  export type TableProps = React.HTMLAttributes<HTMLTableElement> & {
4
4
  /**
5
5
  * Whether the Table owns its own horizontal-scroll region (default `true`). When `true` a table
@@ -58,7 +58,29 @@ export type TableProps = React.HTMLAttributes<HTMLTableElement> & {
58
58
  };
59
59
  /** Defaults to `"sm"` (40rem). Ignored while `preset` is `"default"`. */
60
60
  collapseBelow?: BreakpointProp;
61
+ /**
62
+ * Accessible name for the horizontal-scroll REGION — the `tabindex="0"` wrapper a keyboard user
63
+ * lands on to scroll a table wider than its container, NOT the `<table>` itself (pass `aria-label`
64
+ * for that; it reaches the table element as it always has).
65
+ *
66
+ * Optional on purpose. A consumer is never forced to invent a name for every table: left out, the
67
+ * region takes the localized `dataTable.scrollRegion` default ("Scrollable table"), which is what
68
+ * a screen-reader user needs to hear anyway — that the arrow keys now scroll something. Pass a
69
+ * plain string when the page can say WHICH table ("Pending approvals"); a non-string node cannot
70
+ * be an `aria-label`, so it falls back to the default (the PermissionMatrix `label` contract).
71
+ *
72
+ * The region is only announced while it HAS overflow to reach: no overflow, no tab stop, no role
73
+ * and no name, because a focus stop that scrolls nothing is noise. (gh#817)
74
+ */
75
+ label?: LabelProp;
61
76
  };
77
+ /**
78
+ * The scroll region's accessible name. A `label` is only usable as an `aria-label` when it is a
79
+ * plain string, and no consumer is obliged to supply one at all — so anything else takes the
80
+ * localized default, which still tells the user what the stop is for. Shared with `DataTable`, so
81
+ * both tables name their region the same way.
82
+ */
83
+ export declare function scrollRegionLabel(label: LabelProp | undefined, t: (key: string) => string): string;
62
84
  export declare const Table: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLTableElement> & {
63
85
  /**
64
86
  * Whether the Table owns its own horizontal-scroll region (default `true`). When `true` a table
@@ -117,6 +139,21 @@ export declare const Table: React.ForwardRefExoticComponent<React.HTMLAttributes
117
139
  };
118
140
  /** Defaults to `"sm"` (40rem). Ignored while `preset` is `"default"`. */
119
141
  collapseBelow?: BreakpointProp;
142
+ /**
143
+ * Accessible name for the horizontal-scroll REGION — the `tabindex="0"` wrapper a keyboard user
144
+ * lands on to scroll a table wider than its container, NOT the `<table>` itself (pass `aria-label`
145
+ * for that; it reaches the table element as it always has).
146
+ *
147
+ * Optional on purpose. A consumer is never forced to invent a name for every table: left out, the
148
+ * region takes the localized `dataTable.scrollRegion` default ("Scrollable table"), which is what
149
+ * a screen-reader user needs to hear anyway — that the arrow keys now scroll something. Pass a
150
+ * plain string when the page can say WHICH table ("Pending approvals"); a non-string node cannot
151
+ * be an `aria-label`, so it falls back to the default (the PermissionMatrix `label` contract).
152
+ *
153
+ * The region is only announced while it HAS overflow to reach: no overflow, no tab stop, no role
154
+ * and no name, because a focus stop that scrolls nothing is noise. (gh#817)
155
+ */
156
+ label?: LabelProp;
120
157
  } & React.RefAttributes<HTMLTableElement>>;
121
158
  export declare const TableHeader: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLTableSectionElement> & React.RefAttributes<HTMLTableSectionElement>>;
122
159
  export declare const TableBody: React.ForwardRefExoticComponent<React.HTMLAttributes<HTMLTableSectionElement> & React.RefAttributes<HTMLTableSectionElement>>;
@@ -1,7 +1,13 @@
1
+ "use client";
1
2
  import { jsx, jsxs } from "react/jsx-runtime";
2
3
  import * as React from "react";
4
+ import { useTranslation } from "../../i18n/use-translation.js";
3
5
  import { tableHeadHeightClass } from "../../lib/control-styles.js";
6
+ import { useScrollsHorizontally } from "../../lib/hooks.js";
4
7
  import { cn } from "../../lib/utils.js";
8
+ function scrollRegionLabel(label, t) {
9
+ return typeof label === "string" && label.trim() !== "" ? label : t("dataTable.scrollRegion");
10
+ }
5
11
  const Table = React.forwardRef(
6
12
  ({
7
13
  className,
@@ -11,45 +17,56 @@ const Table = React.forwardRef(
11
17
  preset = "default",
12
18
  collapseBelow = "sm",
13
19
  columnWidths,
20
+ label,
14
21
  ...props
15
- }, ref) => (
16
- // A table wider than its container scrolls horizontally in this wrapper; keep it
17
- // keyboard-reachable so it can be scrolled without a pointer (WCAG 2.1.1 / axe
18
- // scrollable-region-focusable). No landmark role — avoids landmark-unique collisions.
19
- // When `scrollable` is false an ancestor owns the scroll region, so this is a bare
20
- // positioning box (no `overflow`, no tab stop) to avoid a redundant nested scroller.
21
- // step attribute; with `preset="default"` neither is emitted, so the box is byte-identical.
22
- /* @__PURE__ */ jsx(
23
- "div",
24
- {
25
- className: cn(
26
- scrollable ? "relative w-full overflow-auto" : "relative w-full",
27
- preset === "action-collection" && "ui-table-collection",
28
- preset === "stacked-record-collection" && "ui-table-stacked-collection"
29
- ),
30
- "data-preset": preset === "default" ? void 0 : preset,
31
- "data-collapse-below": preset === "default" ? void 0 : collapseBelow,
32
- "data-column-widths": columnWidths ? "" : void 0,
33
- style: columnWidths ? {
34
- "--table-action-collection-actions-width": columnWidths.actions,
35
- "--table-action-collection-actions-width-compact": columnWidths.actionsCompact,
36
- "--table-action-collection-meta-width-compact": columnWidths.metaCompact,
37
- "--table-action-collection-min-inline-size-compact": columnWidths.minInlineSizeCompact
38
- } : void 0,
39
- ...scrollable ? { tabIndex: 0 } : {},
40
- children: /* @__PURE__ */ jsx(
41
- "table",
42
- {
43
- ref,
44
- "data-slot": "table",
45
- "data-striped": striped === void 0 ? void 0 : striped ? "" : "false",
46
- className: cn("w-full caption-bottom", bordered && "ui-table-bordered", className),
47
- ...props
48
- }
49
- )
50
- }
51
- )
52
- )
22
+ }, ref) => {
23
+ const { t } = useTranslation();
24
+ const scrollRef = React.useRef(null);
25
+ const scrolls = useScrollsHorizontally(scrollRef, scrollable);
26
+ return (
27
+ // A table wider than its container scrolls horizontally in this wrapper; keep it
28
+ // keyboard-reachable so it can be scrolled without a pointer (WCAG 2.1.1 / axe
29
+ // scrollable-region-focusable). That stop must not be ANONYMOUS: an unnamed, unroled focus
30
+ // stop announces nothing at all, so it carries `role="group"` + an accessible name (gh#817).
31
+ // `group`, not `region`: a named `region` IS a landmark, and a page with three tables would
32
+ // then ship three same-named landmarks (axe `landmark-unique`) — the reason the role was left
33
+ // off in the first place. `group` is announced, takes a name, and is not a landmark.
34
+ // When `scrollable` is false an ancestor owns the scroll region, so this is a bare
35
+ // positioning box (no `overflow`, no tab stop) to avoid a redundant nested scroller.
36
+ // step attribute; with `preset="default"` neither is emitted, so the box is byte-identical.
37
+ /* @__PURE__ */ jsx(
38
+ "div",
39
+ {
40
+ ref: scrollRef,
41
+ className: cn(
42
+ scrollable ? "relative w-full overflow-auto" : "relative w-full",
43
+ preset === "action-collection" && "ui-table-collection",
44
+ preset === "stacked-record-collection" && "ui-table-stacked-collection"
45
+ ),
46
+ "data-preset": preset === "default" ? void 0 : preset,
47
+ "data-collapse-below": preset === "default" ? void 0 : collapseBelow,
48
+ "data-column-widths": columnWidths ? "" : void 0,
49
+ style: columnWidths ? {
50
+ "--table-action-collection-actions-width": columnWidths.actions,
51
+ "--table-action-collection-actions-width-compact": columnWidths.actionsCompact,
52
+ "--table-action-collection-meta-width-compact": columnWidths.metaCompact,
53
+ "--table-action-collection-min-inline-size-compact": columnWidths.minInlineSizeCompact
54
+ } : void 0,
55
+ ...scrolls ? { role: "group", "aria-label": scrollRegionLabel(label, t), tabIndex: 0 } : {},
56
+ children: /* @__PURE__ */ jsx(
57
+ "table",
58
+ {
59
+ ref,
60
+ "data-slot": "table",
61
+ "data-striped": striped === void 0 ? void 0 : striped ? "" : "false",
62
+ className: cn("w-full caption-bottom", bordered && "ui-table-bordered", className),
63
+ ...props
64
+ }
65
+ )
66
+ }
67
+ )
68
+ );
69
+ }
53
70
  );
54
71
  Table.displayName = "Table";
55
72
  const TableHeader = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx("thead", { ref, className: cn("[&_tr]:border-b", className), ...props }));
@@ -140,5 +157,6 @@ export {
140
157
  TableCell,
141
158
  TableHead,
142
159
  TableHeader,
143
- TableRow
160
+ TableRow,
161
+ scrollRegionLabel
144
162
  };
@@ -4,7 +4,6 @@ import * as React from "react";
4
4
  import { CheckCircle, CircleAlert, LoaderCircle, TriangleAlert } from "lucide-react";
5
5
  import { useTranslation } from "../../i18n/use-translation.js";
6
6
  import { Label } from "../data-entry/label.js";
7
- import { controlIconClass } from "../../lib/control-styles.js";
8
7
  import { cn } from "../../lib/utils.js";
9
8
  import { FieldIdentityContext, FieldNameContext, mergeAriaIds } from "../../lib/field-a11y.js";
10
9
  import { useOptionalAppContext } from "../../app/app-provider.js";
@@ -107,7 +106,16 @@ function FormField({
107
106
  errorId
108
107
  ),
109
108
  "aria-required": required ? true : childProps?.["aria-required"],
110
- "aria-invalid": validationStatus === "error" ? true : childProps?.["aria-invalid"]
109
+ "aria-invalid": validationStatus === "error" ? true : childProps?.["aria-invalid"],
110
+ /* THE BOX SHOWS ITS OWN STATE, not just the line under it. `error` already reached the
111
+ * control through `aria-invalid`, but `warning` reached nothing — so a warned field drew an
112
+ * ordinary border and the only signal was a sentence below it. `.ui-control[data-status]`
113
+ * has painted both edges for a long time; FormField simply never passed the value down.
114
+ *
115
+ * Only `error` and `warning` are sent: `ControlStatusProp` is exactly those two, and
116
+ * `success` / `validating` have no control-level representation today. A child that sets its
117
+ * own `status` keeps it. */
118
+ ...validationStatus === "error" || validationStatus === "warning" ? { status: childProps?.status ?? validationStatus } : {}
111
119
  }) : children;
112
120
  const style = {};
113
121
  if (labelWidth != null)
@@ -166,16 +174,25 @@ function FormField({
166
174
  children: childWithA11y
167
175
  }
168
176
  ) }) }),
169
- hasFeedback && validationStatus ? /* @__PURE__ */ jsxs("span", { id: feedbackId, role: "status", className: "ui-inline-xs text-xs", children: [
170
- feedback ?? /* @__PURE__ */ jsx(
171
- FeedbackIcon,
172
- {
173
- "aria-hidden": "true",
174
- className: validationStatus === "validating" ? `${controlIconClass} motion-safe:animate-spin` : controlIconClass
175
- }
176
- ),
177
- t(`dataEntry.form.${validationStatus}`)
178
- ] }) : null,
177
+ hasFeedback && validationStatus ? /* @__PURE__ */ jsxs(
178
+ "span",
179
+ {
180
+ id: feedbackId,
181
+ role: "status",
182
+ "data-status": validationStatus,
183
+ className: "ui-form-feedback ui-inline-xs text-xs",
184
+ children: [
185
+ feedback ?? /* @__PURE__ */ jsx(
186
+ FeedbackIcon,
187
+ {
188
+ "aria-hidden": "true",
189
+ className: validationStatus === "validating" ? "motion-safe:animate-spin" : void 0
190
+ }
191
+ ),
192
+ t(`dataEntry.form.${validationStatus}`)
193
+ ]
194
+ }
195
+ ) : null,
179
196
  helperPlacement === "after" ? helperNode : null,
180
197
  error ? /* @__PURE__ */ jsx("p", { id: errorId, role: "alert", className: "ui-form-field-note text-error-strong text-xs", children: error }) : null
181
198
  ] })