@godxjp/ui 28.6.0 → 28.7.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.
@@ -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
  },
@@ -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
  ] })
@@ -186,10 +186,27 @@ const AppSettingPicker = React.forwardRef(
186
186
  )
187
187
  ) : (
188
188
  // Labeled: sized to a per-kind width from `sm` up; below `sm` it hugs its content and
189
- // A form field that wants a full-width control passes
190
- // `className="w-full"`, which wins over `w-auto`.
191
189
  // `compact` drops the per-kind width entirely so the trigger hugs its value.
192
- cn("w-auto max-w-full", !isCompact && "ui-app-setting-picker-trigger")
190
+ //
191
+ // The width is a UTILITY READING A TOKEN, for the third time in this file's
192
+ // history and for the same reason (gh#366 icon, gh#375 Select, gh#819 here):
193
+ // @layer utilities is ordered after @layer components, so the `w-auto` that
194
+ // stood here outranked `.ui-app-setting-picker-trigger[data-kind]`'s
195
+ // `inline-size` and every one of the eight per-kind tokens was inert — a
196
+ // consumer who retuned --app-setting-picker-density-width saw nothing move.
197
+ // `--app-setting-picker-trigger-width` is what `data-kind` resolves in
198
+ // navigation-layout.css (a custom property, which no utility competes with);
199
+ // it defaults to `auto`, so below the breakpoint and under `compact` this is
200
+ // byte-for-byte the old `w-auto` behaviour.
201
+ //
202
+ // It stays a SINGLE unprefixed `w-*` (not `sm:w-[…]` per kind): `cn` is
203
+ // tailwind-merge, so a form field that wants a full-width control still passes
204
+ // `className="w-full"` and REPLACES this one. A `sm:`-variant utility would
205
+ // survive that merge and quietly ignore the consumer above 40rem.
206
+ cn(
207
+ "w-[length:var(--app-setting-picker-trigger-width)] max-w-full",
208
+ !isCompact && "ui-app-setting-picker-trigger"
209
+ )
193
210
  ),
194
211
  // Compact re-tiers the box through tokens (--app-setting-picker-compact-*); the height
195
212
  // still comes from the official --control-height-sm tier, never a literal. The gap is a
@@ -1 +1,2 @@
1
+ "use client";
1
2
  export * from "../data-display/table.js";
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "$comment": "AUTO-GENERATED by scripts/gen-measurement-contract.mjs — do not edit. Read this instead of guessing: docs/MEASUREMENT-CONTRACT.md.",
3
- "version": "28.6.0",
3
+ "version": "28.7.0",
4
4
  "targetSize": {
5
5
  "standard": "WCAG 2.2 SC 2.5.8 Target Size (Minimum), level AA — 24×24 CSS px",
6
6
  "min": 24,
@@ -325,6 +325,9 @@
325
325
  "ariaLabel": "Progress",
326
326
  "breakdownSeparator": ", "
327
327
  },
328
+ "scrollArea": {
329
+ "region": "Scrollable region"
330
+ },
328
331
  "tree": {
329
332
  "expand": "Expand",
330
333
  "collapse": "Collapse",
@@ -537,7 +540,8 @@
537
540
  "sortAscending": "Sort ascending",
538
541
  "sortDescending": "Sort descending",
539
542
  "sortCancel": "Cancel sort",
540
- "selectColumn": "Select"
543
+ "selectColumn": "Select",
544
+ "scrollRegion": "Scrollable table"
541
545
  },
542
546
  "dataGrid": {
543
547
  "search": "Search",
@@ -318,6 +318,9 @@
318
318
  "ariaLabel": "進捗",
319
319
  "breakdownSeparator": "、"
320
320
  },
321
+ "scrollArea": {
322
+ "region": "スクロール可能な領域"
323
+ },
321
324
  "tree": {
322
325
  "expand": "展開",
323
326
  "collapse": "折りたたむ",
@@ -524,7 +527,8 @@
524
527
  "sortAscending": "昇順に並べ替え",
525
528
  "sortDescending": "降順に並べ替え",
526
529
  "sortCancel": "並べ替えを解除",
527
- "selectColumn": "選択"
530
+ "selectColumn": "選択",
531
+ "scrollRegion": "スクロール可能な表"
528
532
  },
529
533
  "dataGrid": {
530
534
  "search": "検索",
@@ -319,6 +319,9 @@
319
319
  "ariaLabel": "Tiến độ",
320
320
  "breakdownSeparator": ", "
321
321
  },
322
+ "scrollArea": {
323
+ "region": "Vùng có thể cuộn"
324
+ },
322
325
  "tree": {
323
326
  "expand": "Mở rộng",
324
327
  "collapse": "Thu gọn",
@@ -525,7 +528,8 @@
525
528
  "sortAscending": "Sắp xếp tăng dần",
526
529
  "sortDescending": "Sắp xếp giảm dần",
527
530
  "sortCancel": "Bỏ sắp xếp",
528
- "selectColumn": "Chọn"
531
+ "selectColumn": "Chọn",
532
+ "scrollRegion": "Bảng có thể cuộn"
529
533
  },
530
534
  "dataGrid": {
531
535
  "search": "Tìm kiếm",
@@ -1,3 +1,4 @@
1
+ import { type RefObject } from "react";
1
2
  /**
2
3
  * Returns a debounced view of `value`, updated only after `delay` ms of no
3
4
  * change. Use for search inputs to avoid querying on every keystroke.
@@ -37,3 +38,25 @@ export declare function useIsMobile(): boolean;
37
38
  * @param element the scroll container itself (state, not a ref, so the effect re-runs when it mounts)
38
39
  */
39
40
  export declare function useScrollableRegionTabIndex(element: HTMLElement | null): void;
41
+ /**
42
+ * Does this scroll box actually have somewhere to scroll, on the axes it is allowed to scroll on?
43
+ *
44
+ * Drives BOTH halves of a scroll region: the `tabindex="0"` a keyboard user needs to reach the
45
+ * overflow (WCAG 2.1.1 / axe `scrollable-region-focusable`) AND the `role`/name that stop needs in
46
+ * order not to be an anonymous one (gh#817 for the table, gh#821 for `ScrollArea`). A stop that
47
+ * scrolls nothing is pure noise, and a name on it is noise too, so both are withheld until there is
48
+ * overflow to reach.
49
+ *
50
+ * The measurement may only ever REMOVE the stop, never withhold it on a guess: a box that has not
51
+ * been laid out reports 0 for `clientWidth`/`clientHeight` (the server render, jsdom, a
52
+ * `display:none` ancestor, the frame before first layout), and that is not evidence that nothing
53
+ * overflows. Reading it as "no overflow" would strand the overflow from every keyboard user — a
54
+ * worse failure than an extra tab stop — so an unmeasured box counts as scrolling.
55
+ *
56
+ * `axis` is the box's OWN `overflow`, not a preference: an axis it does not scroll on is `hidden`
57
+ * there, so overflow on that axis is CLIPPED rather than reachable, and measuring it would keep a
58
+ * tab stop that scrolls nothing. `ScrollArea`'s `orientation` is exactly this union.
59
+ */
60
+ export declare function useScrollsOnAxis(ref: RefObject<HTMLElement | null>, enabled: boolean, axis: "horizontal" | "vertical" | "both"): boolean;
61
+ /** A table's wrapper scrolls on one axis only, so it asks the one question it has (gh#817). */
62
+ export declare function useScrollsHorizontally(ref: RefObject<HTMLElement | null>, enabled: boolean): boolean;
package/dist/lib/hooks.js CHANGED
@@ -104,11 +104,38 @@ function useScrollableRegionTabIndex(element) {
104
104
  };
105
105
  }, [element]);
106
106
  }
107
+ function useScrollsOnAxis(ref, enabled, axis) {
108
+ const [scrolls, setScrolls] = useState(true);
109
+ useEffect(() => {
110
+ const el = ref.current;
111
+ if (!enabled || !el) return void 0;
112
+ const overflows = (client, scroll) => client === 0 || scroll - client > 1;
113
+ const update = () => {
114
+ setScrolls(
115
+ axis !== "vertical" && overflows(el.clientWidth, el.scrollWidth) || axis !== "horizontal" && overflows(el.clientHeight, el.scrollHeight)
116
+ );
117
+ };
118
+ update();
119
+ if (typeof ResizeObserver === "undefined") return void 0;
120
+ const observer = new ResizeObserver(update);
121
+ observer.observe(el);
122
+ if (el.firstElementChild) observer.observe(el.firstElementChild);
123
+ return () => {
124
+ observer.disconnect();
125
+ };
126
+ }, [ref, enabled, axis]);
127
+ return enabled && scrolls;
128
+ }
129
+ function useScrollsHorizontally(ref, enabled) {
130
+ return useScrollsOnAxis(ref, enabled, "horizontal");
131
+ }
107
132
  export {
108
133
  useControlledLatch,
109
134
  useDebouncedValue,
110
135
  useIsMobile,
111
136
  useMediaQuery,
112
137
  useScrollableRegionTabIndex,
138
+ useScrollsHorizontally,
139
+ useScrollsOnAxis,
113
140
  useTimeoutFlag
114
141
  };