@nexub/react 5.6.0 → 5.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.
@@ -1,10 +1,10 @@
1
1
  {
2
- "version": "5.6.0",
2
+ "version": "5.8.0",
3
3
  "standard": "WCAG 2.2 Level AA",
4
4
  "method": {
5
- "automated": "axe-core rules tagged wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa, run on every Storybook story in light and dark on every pull request; a new violation fails the build.",
5
+ "automated": "axe-core rules tagged wcag2a, wcag2aa, wcag21a, wcag21aa and wcag22aa, run on every Storybook story in light and dark, and on open popups (menus, listboxes, dialogs), on every pull request; a new violation fails the build.",
6
6
  "contrast": "Every colour role pair is tested for 4.5:1 (text) and 3:1 (non-text) at standard, medium and high contrast in light and dark.",
7
- "manual": "Keyboard behaviour and screen-reader output are documented per component; recorded VoiceOver, NVDA and TalkBack sessions are not published yet."
7
+ "manual": "Keyboard behaviour and screen-reader output are documented per component, with step-by-step VoiceOver and NVDA scripts for the complex ones (/foundations/accessibility#screen-reader-scripts); recorded sessions are not published yet."
8
8
  },
9
9
  "exceptions": [
10
10
  {
@@ -310,8 +310,19 @@
310
310
  "package": "@nexub/react",
311
311
  "automated": "pass",
312
312
  "exceptions": [],
313
- "keyboard": [],
314
- "screenReader": []
313
+ "keyboard": [
314
+ "Tab: Without `grid`: every control in the table is a tab stop. With `grid`: the table is one tab stop and remembers the last cell.",
315
+ "Arrow keys: With `grid`: move between cells, and between the controls inside a cell. Mirrored in RTL.",
316
+ "Home / End: With `grid`: first or last cell in the row. Ctrl+Home / Ctrl+End: first or last cell in the grid.",
317
+ "Space: Toggles a focused row checkbox.",
318
+ "Alt+Arrow: Moves a focused header when `enableColumnReordering` is on."
319
+ ],
320
+ "screenReader": [
321
+ "Name the table with `caption` (or `aria-label`); `captionHidden` keeps it for screen readers only.",
322
+ "Sorting, selection counts and column moves are announced from a polite live region.",
323
+ "DataTableVirtual sets aria-rowcount and aria-rowindex so readers report the full row count.",
324
+ "With `grid`, focus goes to a cell’s first control, or to the cell itself when it has none."
325
+ ]
315
326
  },
316
327
  {
317
328
  "slug": "date-picker",
@@ -9,6 +9,7 @@ import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
9
9
  import { overlaySurface, scrim } from "../../utils/surface.js";
10
10
  import { useClassScrollLock } from "../../hooks/useClassScrollLock.js";
11
11
  import { useControllableState } from "../../hooks/useControllableState.js";
12
+ import { useMergedRefs } from "../../hooks/useMergedRefs.js";
12
13
  const overlayClasses = cn("fixed inset-0 z-[var(--z-modal)] nx-motion", scrim);
13
14
  function ClassLockOverlay() {
14
15
  useClassScrollLock();
@@ -136,7 +137,7 @@ Item.displayName = "CommandPalette.Item";
136
137
  const Separator = React.forwardRef(({ className, ...props }, ref) => /* @__PURE__ */ jsx(
137
138
  Command.Separator,
138
139
  {
139
- ref,
140
+ ref: useMergedRefs(ref, (el) => el?.setAttribute("role", "none")),
140
141
  className: cn("-mx-1 my-1 h-px bg-[var(--color-outline-variant)]", className),
141
142
  ...props
142
143
  }
@@ -64,10 +64,15 @@ export interface DataTableProps<TData> extends React.HTMLAttributes<HTMLDivEleme
64
64
  onColumnPinningChange?: (pinning: ColumnPinningState) => void;
65
65
  /** Cap the table's height. Rows scroll and the header stays in view. */
66
66
  maxHeight?: number | string;
67
+ /**
68
+ * ARIA grid keyboard model for tables whose cells hold actions: the table is
69
+ * one tab stop, and Arrow keys, Home/End and Ctrl+Home/End move between cells.
70
+ */
71
+ grid?: boolean;
67
72
  /** Rows to render; set by DataTableVirtual. */
68
73
  rowWindow?: DataTableRowWindowHook;
69
74
  pagination?: {
70
75
  pageSize?: number;
71
76
  };
72
77
  }
73
- export declare function DataTable<TData>({ columns, data, enableSorting, enableRowSelection, selectionColumn, rowSelection: rowSelectionProp, onRowSelectionChange, getRowId, bulkActions, density: densityProp, defaultDensity, onDensityChange, columnVisibility: columnVisibilityProp, onColumnVisibilityChange, viewOptions, toolbar, enableColumnResizing, columnSizing: columnSizingProp, onColumnSizingChange, enableColumnReordering, columnOrder: columnOrderProp, onColumnOrderChange, columnPinning: columnPinningProp, onColumnPinningChange, maxHeight, rowWindow, pagination, caption, captionHidden, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }: DataTableProps<TData>): React.JSX.Element;
78
+ export declare function DataTable<TData>({ columns, data, enableSorting, enableRowSelection, selectionColumn, rowSelection: rowSelectionProp, onRowSelectionChange, getRowId, bulkActions, density: densityProp, defaultDensity, onDensityChange, columnVisibility: columnVisibilityProp, onColumnVisibilityChange, viewOptions, toolbar, enableColumnResizing, columnSizing: columnSizingProp, onColumnSizingChange, enableColumnReordering, columnOrder: columnOrderProp, onColumnOrderChange, columnPinning: columnPinningProp, onColumnPinningChange, maxHeight, grid, rowWindow, pagination, caption, captionHidden, className, 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledBy, ...props }: DataTableProps<TData>): React.JSX.Element;
@@ -20,6 +20,7 @@ import { Table } from "../Table/Table.js";
20
20
  import { Checkbox } from "../Checkbox/Checkbox.js";
21
21
  import { Button } from "../Button/Button.js";
22
22
  import { DropdownMenu } from "../DropdownMenu/DropdownMenu.js";
23
+ import { createGridNavigation } from "./gridNavigation.js";
23
24
  const alignText = {
24
25
  start: "text-start",
25
26
  end: "text-end",
@@ -130,6 +131,7 @@ function DataTable({
130
131
  columnPinning: columnPinningProp,
131
132
  onColumnPinningChange,
132
133
  maxHeight,
134
+ grid = false,
133
135
  rowWindow,
134
136
  pagination,
135
137
  caption,
@@ -174,6 +176,7 @@ function DataTable({
174
176
  });
175
177
  const [announcement, setAnnouncement] = React.useState("");
176
178
  const tableRef = React.useRef(null);
179
+ const gridNav = React.useMemo(() => createGridNavigation(dir), [dir]);
177
180
  const dragged = React.useRef(null);
178
181
  const [paginationState, setPaginationState] = React.useState({
179
182
  pageIndex: 0,
@@ -274,6 +277,7 @@ function DataTable({
274
277
  t.style.width = enableColumnResizing ? px(table.getTotalSize()) : "";
275
278
  const scroller = t.parentElement;
276
279
  if (scroller) scroller.style.maxHeight = maxHeight == null ? "" : px(maxHeight);
280
+ if (grid) gridNav.sync(t);
277
281
  });
278
282
  const placeCell = (column) => (el) => {
279
283
  if (!el) return;
@@ -352,6 +356,9 @@ function DataTable({
352
356
  "aria-label": ariaLabel,
353
357
  "aria-labelledby": ariaLabelledBy,
354
358
  "aria-rowcount": rowWin.virtual ? rows.length + 1 : void 0,
359
+ role: grid ? "grid" : void 0,
360
+ onKeyDown: grid ? gridNav.onKeyDown : void 0,
361
+ onFocus: grid ? gridNav.onFocus : void 0,
355
362
  className: cn(enableColumnResizing && "table-fixed"),
356
363
  children: [
357
364
  caption != null && /* @__PURE__ */ jsx(Table.Caption, { className: cn(captionHidden && "sr-only"), children: caption }),
@@ -0,0 +1,11 @@
1
+ import type * as React from 'react';
2
+ /**
3
+ * ARIA grid keyboard model (WAI-ARIA APG "Data Grid"): one tab stop for the
4
+ * whole table; Arrow keys move between cells (and the controls inside them),
5
+ * Home/End to the row's ends, Ctrl+Home/End to the grid's corners.
6
+ */
7
+ export declare function createGridNavigation(dir: 'ltr' | 'rtl'): {
8
+ sync: (table: HTMLTableElement | null) => void;
9
+ onFocus: (e: React.FocusEvent<HTMLTableElement>) => void;
10
+ onKeyDown: (e: React.KeyboardEvent<HTMLTableElement>) => void;
11
+ };
@@ -0,0 +1,52 @@
1
+ const FOCUSABLE = "a[href],button:not(:disabled),input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[role=separator]";
2
+ const TEXT_INPUT = /^(text|search|email|number|tel|url|password|date)$/;
3
+ function stopsByRow(table) {
4
+ return Array.from(table.rows).filter((r) => !r.hasAttribute("aria-hidden")).map(
5
+ (r) => Array.from(r.cells).flatMap((cell) => {
6
+ const inner = Array.from(cell.querySelectorAll(FOCUSABLE));
7
+ return inner.length ? inner : [cell];
8
+ })
9
+ );
10
+ }
11
+ const cellIndex = (el) => el.closest("td,th").cellIndex;
12
+ function createGridNavigation(dir) {
13
+ let active = null;
14
+ const sync = (table) => {
15
+ if (!table) return;
16
+ const stops = stopsByRow(table).flat();
17
+ if (!active || !stops.includes(active)) active = stops[0] ?? null;
18
+ for (const s of stops) s.tabIndex = s === active ? 0 : -1;
19
+ };
20
+ const onFocus = (e) => {
21
+ active = e.target;
22
+ sync(e.currentTarget);
23
+ };
24
+ const onKeyDown = (e) => {
25
+ if (e.defaultPrevented || e.altKey) return;
26
+ const target = e.target;
27
+ const typing = target.matches("textarea,select,[contenteditable=true]") || target instanceof HTMLInputElement && TEXT_INPUT.test(target.type);
28
+ if (typing && e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
29
+ const rows = stopsByRow(e.currentTarget);
30
+ const r = rows.findIndex((row2) => row2.includes(target));
31
+ if (r < 0) return;
32
+ const row = rows[r];
33
+ const c = row.indexOf(target);
34
+ const forward = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
35
+ const back = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
36
+ const sameColumn = (to) => to && (to.find((s) => cellIndex(s) >= cellIndex(target)) ?? to.at(-1));
37
+ let next;
38
+ if (e.key === forward) next = row[c + 1];
39
+ else if (e.key === back) next = row[c - 1];
40
+ else if (e.key === "ArrowDown") next = sameColumn(rows[r + 1]);
41
+ else if (e.key === "ArrowUp") next = sameColumn(rows[r - 1]);
42
+ else if (e.key === "Home") next = e.ctrlKey ? rows[0][0] : row[0];
43
+ else if (e.key === "End") next = e.ctrlKey ? rows.at(-1).at(-1) : row.at(-1);
44
+ else return;
45
+ e.preventDefault();
46
+ next?.focus();
47
+ };
48
+ return { sync, onFocus, onKeyDown };
49
+ }
50
+ export {
51
+ createGridNavigation
52
+ };
@@ -29,7 +29,7 @@ function useCalendarLocale(localeProp) {
29
29
  const locale = localeProp ?? dateLocale;
30
30
  const labels = React.useMemo(() => {
31
31
  const day = (date, modifiers, withSelected) => {
32
- let label = format(date, "PPPP", locale ? { locale } : void 0);
32
+ let label = new Intl.DateTimeFormat(intlLocale, { dateStyle: "full" }).format(date);
33
33
  if (modifiers?.today) label = messages.todayDate(label);
34
34
  if (withSelected && modifiers?.selected) label = messages.selectedDate(label);
35
35
  return label;
@@ -41,7 +41,7 @@ function useCalendarLocale(localeProp) {
41
41
  labelDayButton: (date, modifiers) => day(date, modifiers, true),
42
42
  labelGridcell: (date, modifiers) => day(date, modifiers, false)
43
43
  };
44
- }, [messages, locale]);
44
+ }, [messages, intlLocale]);
45
45
  return {
46
46
  dayPickerProps: { locale, dir, labels, components: { Chevron: CalendarChevron } },
47
47
  format: (date) => formatDate(date, intlLocale, locale)
@@ -1,36 +1,7 @@
1
1
  import * as React from 'react';
2
- import type { NexubMessages } from '@nexub/core';
3
- export type FilterFieldType = 'text' | 'number' | 'select' | 'date';
4
- export type FilterOperator = keyof NexubMessages['filterOperators'];
5
- export interface FilterField {
6
- id: string;
7
- label: string;
8
- type: FilterFieldType;
9
- /** Choices for `type: 'select'`. */
10
- options?: {
11
- value: string;
12
- label: string;
13
- }[];
14
- }
15
- export interface FilterRule {
16
- id: string;
17
- field: string;
18
- operator: FilterOperator;
19
- /** Always a string (dates as YYYY-MM-DD), so the query serialises as-is. */
20
- value: string;
21
- }
22
- export interface FilterGroup {
23
- id: string;
24
- combinator: 'and' | 'or';
25
- rules: Array<FilterRule | FilterGroup>;
26
- }
27
- /** Operators offered for each field type, in menu order. */
28
- export declare const filterOperators: Record<FilterFieldType, FilterOperator[]>;
29
- export declare const isFilterGroup: (r: FilterRule | FilterGroup) => r is FilterGroup;
30
- /** An empty top-level group, a starting `value`. */
31
- export declare const emptyFilter: () => FilterGroup;
32
- /** Rules with a field, operator and (unless "is empty") a value. Counts nested groups too. */
33
- export declare function countFilters(group: FilterGroup): number;
2
+ import { countFilters, emptyFilter, filterOperators, isFilterGroup, type FilterField, type FilterFieldType, type FilterGroup, type FilterOperator, type FilterRule } from '@nexub/core';
3
+ export type { FilterFieldType, FilterOperator, FilterField, FilterRule, FilterGroup };
4
+ export { filterOperators, isFilterGroup, emptyFilter, countFilters };
34
5
  export interface FilterBuilderProps {
35
6
  fields: FilterField[];
36
7
  value?: FilterGroup;
@@ -2,6 +2,14 @@
2
2
  import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import * as React from "react";
4
4
  import { Plus, X } from "@nexub/icons";
5
+ import {
6
+ countFilters,
7
+ emptyFilter,
8
+ filterId as uid,
9
+ filterOperators,
10
+ isFilterGroup,
11
+ newFilterRule
12
+ } from "@nexub/core";
5
13
  import { cn } from "../../utils/cn.js";
6
14
  import { useControllableState } from "../../hooks/useControllableState.js";
7
15
  import { useNexubMessages } from "../NexubProvider/NexubProvider.js";
@@ -10,22 +18,6 @@ import { IconButton } from "../IconButton/IconButton.js";
10
18
  import { Input } from "../Input/Input.js";
11
19
  import { Select } from "../Select/Select.js";
12
20
  import { SegmentedControl } from "../SegmentedControl/SegmentedControl.js";
13
- const filterOperators = {
14
- text: ["contains", "equals", "notEquals", "startsWith", "isEmpty"],
15
- number: ["equals", "notEquals", "greaterThan", "lessThan"],
16
- select: ["equals", "notEquals"],
17
- date: ["equals", "before", "after"]
18
- };
19
- const isFilterGroup = (r) => "rules" in r;
20
- let seq = 0;
21
- const uid = () => `f${(++seq).toString(36)}${Math.random().toString(36).slice(2, 6)}`;
22
- const emptyFilter = () => ({ id: uid(), combinator: "and", rules: [] });
23
- function countFilters(group) {
24
- return group.rules.reduce(
25
- (n, r) => n + (isFilterGroup(r) ? countFilters(r) : r.operator === "isEmpty" || r.value !== "" ? 1 : 0),
26
- 0
27
- );
28
- }
29
21
  function FilterBuilder({ fields, value: valueProp, defaultValue, onValueChange, maxDepth = 1, className }) {
30
22
  const [value = emptyFilter(), setValue] = useControllableState({
31
23
  value: valueProp,
@@ -49,12 +41,7 @@ function GroupView({ group, fields, depth, maxDepth, onChange, onRemove, classNa
49
41
  const t = useNexubMessages();
50
42
  const first = fields[0];
51
43
  const setRules = (rules) => onChange({ ...group, rules });
52
- const newRule = () => ({
53
- id: uid(),
54
- field: first?.id ?? "",
55
- operator: filterOperators[first?.type ?? "text"][0],
56
- value: ""
57
- });
44
+ const newRule = () => newFilterRule(first);
58
45
  return /* @__PURE__ */ jsxs(
59
46
  "div",
60
47
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nexub/react",
3
- "version": "5.6.0",
3
+ "version": "5.8.0",
4
4
  "description": "React component library for Nexub Design System — 90+ accessible, themeable UI components built on Radix UI primitives.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -81,14 +81,14 @@
81
81
  "date-fns": "^4.4.0",
82
82
  "react-day-picker": "^10.0.2",
83
83
  "tailwind-merge": "^3.7.0",
84
- "@nexub/core": "5.6.0",
85
- "@nexub/icons": "5.6.0"
84
+ "@nexub/core": "5.8.0",
85
+ "@nexub/icons": "5.8.0"
86
86
  },
87
87
  "peerDependencies": {
88
88
  "react": ">=19 <20",
89
89
  "react-dom": ">=19 <20",
90
90
  "@tanstack/react-virtual": "^3.14.22",
91
- "@nexub/tokens": "^5.6.0"
91
+ "@nexub/tokens": "^5.8.0"
92
92
  },
93
93
  "peerDependenciesMeta": {
94
94
  "@tanstack/react-virtual": {
@@ -120,8 +120,8 @@
120
120
  "vaul": "1.1.2",
121
121
  "vite": "^8.3.5",
122
122
  "vitest": "^5.0.5",
123
- "@nexub/themes": "5.6.0",
124
- "@nexub/tokens": "5.6.0"
123
+ "@nexub/themes": "5.8.0",
124
+ "@nexub/tokens": "5.8.0"
125
125
  },
126
126
  "scripts": {
127
127
  "build": "rm -rf dist && tsup && tsc -p tsconfig.build.json && rm -f dist/vendor/*.d.ts && node scripts/check-dts.mjs && tailwindcss -i src/styles.css -o dist/styles.css --minify && cp -R ../themes/src/fonts dist/fonts",