@dashforge/tw 0.6.0-beta → 0.8.0-beta

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 (107) hide show
  1. package/CHANGELOG.md +451 -0
  2. package/THEME-AUDIT.md +175 -0
  3. package/dist/index.esm.js +2009 -87
  4. package/dist/src/components/Accordion/accordion.variants.d.ts.map +1 -1
  5. package/dist/src/components/Box/box.variants.d.ts.map +1 -1
  6. package/dist/src/components/DataGrid/DataGrid.d.ts +29 -0
  7. package/dist/src/components/DataGrid/DataGrid.d.ts.map +1 -0
  8. package/dist/src/components/DataGrid/dataGrid.types.d.ts +214 -0
  9. package/dist/src/components/DataGrid/dataGrid.types.d.ts.map +1 -0
  10. package/dist/src/components/DataGrid/dataGrid.variants.d.ts +222 -0
  11. package/dist/src/components/DataGrid/dataGrid.variants.d.ts.map +1 -0
  12. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts +23 -0
  13. package/dist/src/components/DataGrid/filters/ColumnFilters.d.ts.map +1 -0
  14. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts +50 -0
  15. package/dist/src/components/DataGrid/reorder/useColumnReorder.d.ts.map +1 -0
  16. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts +32 -0
  17. package/dist/src/components/DataGrid/resize/useColumnResize.d.ts.map +1 -0
  18. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts +33 -0
  19. package/dist/src/components/DataGrid/visibility/ColumnVisibilityDialog.d.ts.map +1 -0
  20. package/dist/src/components/Dialog/dialog.variants.d.ts.map +1 -1
  21. package/dist/src/components/Divider/divider.variants.d.ts.map +1 -1
  22. package/dist/src/components/Pagination/Pagination.d.ts.map +1 -1
  23. package/dist/src/components/Pagination/pagination.variants.d.ts.map +1 -1
  24. package/dist/src/components/Popover/popover.variants.d.ts.map +1 -1
  25. package/dist/src/components/Skeleton/skeleton.variants.d.ts.map +1 -1
  26. package/dist/src/components/Table/Table.d.ts.map +1 -1
  27. package/dist/src/components/Table/table.types.d.ts +77 -5
  28. package/dist/src/components/Table/table.types.d.ts.map +1 -1
  29. package/dist/src/components/Tabs/tabs.variants.d.ts.map +1 -1
  30. package/dist/src/components/Tooltip/tooltip.variants.d.ts.map +1 -1
  31. package/dist/src/components/Typography/typography.variants.d.ts.map +1 -1
  32. package/dist/src/components/_shared/data/getNestedValue.d.ts.map +1 -0
  33. package/dist/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.d.ts +1 -1
  34. package/dist/src/components/_shared/data/useColumnAutoDetect.d.ts.map +1 -0
  35. package/dist/src/components/_shared/data/useControllableState.d.ts.map +1 -0
  36. package/dist/src/components/_shared/data/useDebouncedValue.d.ts.map +1 -0
  37. package/dist/src/components/_shared/data/useTableFilter.d.ts +36 -0
  38. package/dist/src/components/_shared/data/useTableFilter.d.ts.map +1 -0
  39. package/dist/src/components/{Table/_internal → _shared/data}/useTableSearch.d.ts +1 -1
  40. package/dist/src/components/_shared/data/useTableSearch.d.ts.map +1 -0
  41. package/dist/src/components/{Table/_internal → _shared/data}/useTableSelection.d.ts +1 -1
  42. package/dist/src/components/_shared/data/useTableSelection.d.ts.map +1 -0
  43. package/dist/src/components/{Table/_internal → _shared/data}/useTableSort.d.ts +1 -1
  44. package/dist/src/components/_shared/data/useTableSort.d.ts.map +1 -0
  45. package/dist/src/components/_shared/data/useVirtualizer.d.ts +107 -0
  46. package/dist/src/components/_shared/data/useVirtualizer.d.ts.map +1 -0
  47. package/dist/src/index.d.ts +6 -3
  48. package/dist/src/index.d.ts.map +1 -1
  49. package/package.json +5 -5
  50. package/src/components/Accordion/accordion.variants.ts +5 -3
  51. package/src/components/Box/Box.test.tsx +32 -11
  52. package/src/components/Box/box.variants.ts +23 -4
  53. package/src/components/DataGrid/DataGrid.test.tsx +917 -0
  54. package/src/components/DataGrid/DataGrid.tsx +1199 -0
  55. package/src/components/DataGrid/dataGrid.types.ts +238 -0
  56. package/src/components/DataGrid/dataGrid.variants.ts +152 -0
  57. package/src/components/DataGrid/filters/ColumnFilters.tsx +432 -0
  58. package/src/components/DataGrid/reorder/useColumnReorder.test.ts +38 -0
  59. package/src/components/DataGrid/reorder/useColumnReorder.ts +144 -0
  60. package/src/components/DataGrid/resize/useColumnResize.ts +106 -0
  61. package/src/components/DataGrid/visibility/ColumnVisibilityDialog.tsx +200 -0
  62. package/src/components/Dialog/dialog.variants.ts +10 -6
  63. package/src/components/Divider/Divider.test.tsx +3 -1
  64. package/src/components/Divider/Divider.tsx +1 -1
  65. package/src/components/Divider/divider.variants.ts +2 -1
  66. package/src/components/Pagination/Pagination.tsx +3 -1
  67. package/src/components/Pagination/pagination.variants.ts +19 -13
  68. package/src/components/Popover/popover.variants.ts +10 -4
  69. package/src/components/Skeleton/skeleton.variants.ts +2 -1
  70. package/src/components/Table/Table.tsx +22 -8
  71. package/src/components/Table/table.types.ts +83 -5
  72. package/src/components/Tabs/tabs.variants.ts +19 -8
  73. package/src/components/Tooltip/tooltip.variants.ts +12 -2
  74. package/src/components/Typography/Typography.test.tsx +3 -1
  75. package/src/components/Typography/typography.variants.ts +6 -2
  76. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.test.ts +1 -1
  77. package/src/components/{Table/_internal → _shared/data}/useColumnAutoDetect.ts +1 -1
  78. package/src/components/_shared/data/useTableFilter.test.ts +166 -0
  79. package/src/components/_shared/data/useTableFilter.ts +118 -0
  80. package/src/components/{Table/_internal → _shared/data}/useTableSearch.test.ts +1 -1
  81. package/src/components/{Table/_internal → _shared/data}/useTableSearch.ts +1 -1
  82. package/src/components/{Table/_internal → _shared/data}/useTableSelection.ts +1 -1
  83. package/src/components/{Table/_internal → _shared/data}/useTableSort.test.ts +1 -1
  84. package/src/components/{Table/_internal → _shared/data}/useTableSort.ts +1 -1
  85. package/src/components/_shared/data/useVirtualizer.test.ts +158 -0
  86. package/src/components/_shared/data/useVirtualizer.ts +199 -0
  87. package/src/components/_shared/themeIdentity.test.ts +113 -0
  88. package/src/index.ts +20 -2
  89. package/dist/src/components/Table/_internal/getNestedValue.d.ts.map +0 -1
  90. package/dist/src/components/Table/_internal/useColumnAutoDetect.d.ts.map +0 -1
  91. package/dist/src/components/Table/_internal/useControllableState.d.ts.map +0 -1
  92. package/dist/src/components/Table/_internal/useDebouncedValue.d.ts.map +0 -1
  93. package/dist/src/components/Table/_internal/useTableFilter.d.ts +0 -13
  94. package/dist/src/components/Table/_internal/useTableFilter.d.ts.map +0 -1
  95. package/dist/src/components/Table/_internal/useTableSearch.d.ts.map +0 -1
  96. package/dist/src/components/Table/_internal/useTableSelection.d.ts.map +0 -1
  97. package/dist/src/components/Table/_internal/useTableSort.d.ts.map +0 -1
  98. package/src/components/Table/_internal/themeIdentity.test.ts +0 -84
  99. package/src/components/Table/_internal/useTableFilter.ts +0 -37
  100. /package/dist/src/components/{Table/_internal → _shared/data}/getNestedValue.d.ts +0 -0
  101. /package/dist/src/components/{Table/_internal → _shared/data}/useControllableState.d.ts +0 -0
  102. /package/dist/src/components/{Table/_internal → _shared/data}/useDebouncedValue.d.ts +0 -0
  103. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.test.ts +0 -0
  104. /package/src/components/{Table/_internal → _shared/data}/getNestedValue.ts +0 -0
  105. /package/src/components/{Table/_internal → _shared/data}/useControllableState.ts +0 -0
  106. /package/src/components/{Table/_internal → _shared/data}/useDebouncedValue.ts +0 -0
  107. /package/src/components/{Table/_internal → _shared/data}/useTableSelection.test.ts +0 -0
@@ -0,0 +1,106 @@
1
+ import { useCallback, useRef, type PointerEvent as RPointerEvent } from 'react';
2
+
3
+ /**
4
+ * Native column-resize behavior for DataGrid (and Table later if
5
+ * promoted to `_shared`).
6
+ *
7
+ * The returned `startResize` is wired to a `pointerdown` handler on a
8
+ * thin grabber positioned at the right edge of a `<th>`. We capture
9
+ * the pointer on the target element so `pointermove` / `pointerup`
10
+ * keep flowing even if the cursor leaves the grabber.
11
+ *
12
+ * Why native pointer events vs a drag library:
13
+ * - Zero new runtime deps (sprint constraint)
14
+ * - Pointer events normalize mouse/touch/pen out of the box
15
+ * - Pointer capture means we don't have to track the global
16
+ * `mousemove` / `mouseup` — the browser routes events to our
17
+ * listener until release.
18
+ */
19
+
20
+ export interface UseColumnResizeArgs {
21
+ /** Current column widths (Record<field, px>). */
22
+ widths: Record<string, number>;
23
+ /** Commit a new width map. */
24
+ onChange: (next: Record<string, number>) => void;
25
+ /** Hard floor for any column (px). @default 40 */
26
+ minWidth?: number;
27
+ /** Hard ceiling for any column (px). @default 1200 */
28
+ maxWidth?: number;
29
+ }
30
+
31
+ export interface ColumnResizeHandlers {
32
+ startResize: (
33
+ field: string,
34
+ startWidth: number,
35
+ clampMin: number,
36
+ clampMax: number,
37
+ ) => (e: RPointerEvent<HTMLElement>) => void;
38
+ }
39
+
40
+ export function useColumnResize(args: UseColumnResizeArgs): ColumnResizeHandlers {
41
+ const { widths, onChange, minWidth = 40, maxWidth = 1200 } = args;
42
+
43
+ // We need the LATEST widths inside the pointermove closure. Stick
44
+ // them in a ref so the closure captured at pointerdown stays valid
45
+ // for the duration of the drag.
46
+ const widthsRef = useRef(widths);
47
+ widthsRef.current = widths;
48
+
49
+ const startResize = useCallback(
50
+ (
51
+ field: string,
52
+ startWidth: number,
53
+ clampMin: number,
54
+ clampMax: number,
55
+ ) => {
56
+ return (e: RPointerEvent<HTMLElement>) => {
57
+ // Only respond to the primary mouse button (or touch / pen).
58
+ if (e.pointerType === 'mouse' && e.button !== 0) return;
59
+
60
+ e.preventDefault();
61
+ e.stopPropagation();
62
+
63
+ const startX = e.clientX;
64
+ const target = e.currentTarget;
65
+ try {
66
+ target.setPointerCapture(e.pointerId);
67
+ } catch {
68
+ // jsdom or older browsers — fine to fall back to global listeners.
69
+ }
70
+
71
+ const effectiveMin = Math.max(minWidth, clampMin);
72
+ const effectiveMax = Math.min(maxWidth, clampMax);
73
+
74
+ const handleMove = (ev: PointerEvent) => {
75
+ const delta = ev.clientX - startX;
76
+ const next = clamp(startWidth + delta, effectiveMin, effectiveMax);
77
+ onChange({ ...widthsRef.current, [field]: next });
78
+ };
79
+
80
+ const handleEnd = (ev: PointerEvent) => {
81
+ try {
82
+ target.releasePointerCapture(ev.pointerId);
83
+ } catch {
84
+ /* noop */
85
+ }
86
+ target.removeEventListener('pointermove', handleMove);
87
+ target.removeEventListener('pointerup', handleEnd);
88
+ target.removeEventListener('pointercancel', handleEnd);
89
+ };
90
+
91
+ target.addEventListener('pointermove', handleMove);
92
+ target.addEventListener('pointerup', handleEnd);
93
+ target.addEventListener('pointercancel', handleEnd);
94
+ };
95
+ },
96
+ [onChange, minWidth, maxWidth],
97
+ );
98
+
99
+ return { startResize };
100
+ }
101
+
102
+ function clamp(n: number, min: number, max: number): number {
103
+ if (n < min) return min;
104
+ if (n > max) return max;
105
+ return n;
106
+ }
@@ -0,0 +1,200 @@
1
+ import { useState, useEffect, type ReactNode } from 'react';
2
+ import { Dialog } from '../../Dialog/Dialog.js';
3
+ import { cn } from '../../../utils/cn.js';
4
+ import type { TableColumn } from '../../Table/table.types.js';
5
+
6
+ /**
7
+ * Column visibility dialog for DataGrid.
8
+ *
9
+ * Renders a toolbar button (the trigger) that opens a `<Dialog>` with
10
+ * one checkbox per `hideable` column. The consumer can:
11
+ * - Show/hide individual columns
12
+ * - "Show all" / "Hide all" shortcuts
13
+ * - Persist visibility via the controllable `hiddenColumns` model
14
+ *
15
+ * Columns with `hideable: false` are NEVER shown in the dialog —
16
+ * those are considered structurally required (e.g. an ID column).
17
+ *
18
+ * RBAC-hidden columns (`access.onUnauthorized === 'hide'`) are also
19
+ * excluded — they're already removed from `visibleCols` upstream.
20
+ */
21
+
22
+ export interface ColumnVisibilityLabels {
23
+ columnsButton?: string;
24
+ columnsTitle?: string;
25
+ columnsDescription?: string;
26
+ columnsShowAll?: string;
27
+ columnsHideAll?: string;
28
+ columnsDone?: string;
29
+ }
30
+
31
+ const DEFAULT_VISIBILITY_LABELS: Required<ColumnVisibilityLabels> = {
32
+ columnsButton: 'Columns',
33
+ columnsTitle: 'Manage columns',
34
+ columnsDescription: 'Show or hide columns. Required columns cannot be toggled.',
35
+ columnsShowAll: 'Show all',
36
+ columnsHideAll: 'Hide all',
37
+ columnsDone: 'Done',
38
+ };
39
+
40
+ export interface ColumnVisibilityTriggerProps<T extends object> {
41
+ cols: TableColumn<T>[];
42
+ hiddenColumns: string[];
43
+ onChange: (next: string[]) => void;
44
+ disabled?: boolean;
45
+ labels?: ColumnVisibilityLabels;
46
+ }
47
+
48
+ export function ColumnVisibilityTrigger<T extends object>(
49
+ props: ColumnVisibilityTriggerProps<T>,
50
+ ) {
51
+ const { cols, hiddenColumns, onChange, disabled, labels: labelsProp } = props;
52
+ const labels = { ...DEFAULT_VISIBILITY_LABELS, ...labelsProp };
53
+ const [open, setOpen] = useState(false);
54
+
55
+ // Hideable cols are the only ones the user can toggle. `hideable`
56
+ // defaults to `true` — a column is hideable unless explicitly opted
57
+ // out with `hideable: false` (structurally required column).
58
+ const hideableCols = cols.filter((c) => c.hideable !== false);
59
+
60
+ return (
61
+ <>
62
+ <button
63
+ type="button"
64
+ onClick={() => setOpen(true)}
65
+ disabled={disabled}
66
+ className={cn(
67
+ 'inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md',
68
+ 'text-sm text-neutral-700 hover:text-neutral-900',
69
+ 'border border-neutral-300 bg-neutral-50 hover:bg-neutral-100',
70
+ 'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
71
+ 'transition-colors motion-reduce:transition-none',
72
+ 'disabled:opacity-50 disabled:cursor-not-allowed',
73
+ )}
74
+ >
75
+ <ColumnsIcon />
76
+ <span>{labels.columnsButton}</span>
77
+ </button>
78
+ <Dialog
79
+ open={open}
80
+ onOpenChange={setOpen}
81
+ title={labels.columnsTitle}
82
+ description={labels.columnsDescription}
83
+ size="sm"
84
+ >
85
+ <ColumnVisibilityBody
86
+ cols={hideableCols}
87
+ hiddenColumns={hiddenColumns}
88
+ onChange={onChange}
89
+ onDone={() => setOpen(false)}
90
+ labels={labels}
91
+ />
92
+ </Dialog>
93
+ </>
94
+ );
95
+ }
96
+
97
+ // ───── Dialog body ─────
98
+
99
+ function ColumnVisibilityBody<T extends object>(props: {
100
+ cols: TableColumn<T>[];
101
+ hiddenColumns: string[];
102
+ onChange: (next: string[]) => void;
103
+ onDone: () => void;
104
+ labels: Required<ColumnVisibilityLabels>;
105
+ }) {
106
+ const { cols, hiddenColumns, onChange, onDone, labels } = props;
107
+ // Local draft so the user can stage changes; commit on Done.
108
+ const [draft, setDraft] = useState<string[]>(hiddenColumns);
109
+
110
+ // Re-sync when the dialog re-opens with a different external state.
111
+ useEffect(() => {
112
+ setDraft(hiddenColumns);
113
+ }, [hiddenColumns]);
114
+
115
+ const toggle = (field: string) => {
116
+ setDraft((prev) =>
117
+ prev.includes(field) ? prev.filter((f) => f !== field) : [...prev, field],
118
+ );
119
+ };
120
+
121
+ const showAll = () => setDraft([]);
122
+ const hideAll = () => setDraft(cols.map((c) => c.field as string));
123
+
124
+ return (
125
+ <div className="flex flex-col gap-3">
126
+ <div className="flex items-center gap-2 pb-2 border-b border-neutral-200">
127
+ <button
128
+ type="button"
129
+ onClick={showAll}
130
+ className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
131
+ >
132
+ {labels.columnsShowAll}
133
+ </button>
134
+ <span className="text-neutral-300">·</span>
135
+ <button
136
+ type="button"
137
+ onClick={hideAll}
138
+ className="text-xs text-neutral-600 hover:text-neutral-900 underline-offset-2 hover:underline focus:outline-none focus-visible:underline"
139
+ >
140
+ {labels.columnsHideAll}
141
+ </button>
142
+ </div>
143
+ <ul className="flex flex-col gap-1.5 max-h-72 overflow-auto">
144
+ {cols.map((col) => {
145
+ const field = col.field as string;
146
+ const hidden = draft.includes(field);
147
+ const header =
148
+ typeof col.header === 'function' ? col.header() : col.header;
149
+ return (
150
+ <li key={field}>
151
+ <label className="flex items-center gap-2 cursor-pointer py-1 px-1 rounded hover:bg-neutral-100">
152
+ <input
153
+ type="checkbox"
154
+ checked={!hidden}
155
+ onChange={() => toggle(field)}
156
+ className="cursor-pointer"
157
+ />
158
+ <span className="text-sm text-neutral-900">{header}</span>
159
+ </label>
160
+ </li>
161
+ );
162
+ })}
163
+ </ul>
164
+ <div className="flex justify-end gap-2 pt-2 border-t border-neutral-200">
165
+ <button
166
+ type="button"
167
+ onClick={() => {
168
+ onChange(draft);
169
+ onDone();
170
+ }}
171
+ className="text-sm bg-primary-500 text-white rounded-md hover:bg-primary-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 px-3 py-1.5"
172
+ >
173
+ {labels.columnsDone}
174
+ </button>
175
+ </div>
176
+ </div>
177
+ );
178
+ }
179
+
180
+ // ───── Icon ─────
181
+
182
+ function ColumnsIcon(): ReactNode {
183
+ return (
184
+ <svg
185
+ width="0.875em"
186
+ height="0.875em"
187
+ viewBox="0 0 16 16"
188
+ fill="none"
189
+ stroke="currentColor"
190
+ strokeWidth="1.5"
191
+ strokeLinecap="round"
192
+ strokeLinejoin="round"
193
+ aria-hidden="true"
194
+ >
195
+ <rect x="2" y="3" width="3.5" height="10" rx="0.5" />
196
+ <rect x="6.25" y="3" width="3.5" height="10" rx="0.5" />
197
+ <rect x="10.5" y="3" width="3.5" height="10" rx="0.5" />
198
+ </svg>
199
+ );
200
+ }
@@ -26,21 +26,25 @@ export const dialogVariants = tv({
26
26
  ],
27
27
  content: [
28
28
  'fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2',
29
- 'w-full bg-white dark:bg-neutral-900 rounded-lg shadow-xl',
30
- 'border border-neutral-200 dark:border-neutral-800',
29
+ // bg-white hardcoded (doesn't auto-invert) so `dark:` is needed.
30
+ // Target `dark:bg-neutral-100` = #171717 in dark mode (one
31
+ // elevation tier above page surface).
32
+ 'w-full bg-white dark:bg-neutral-100 rounded-lg shadow-xl',
33
+ // border-neutral-200 auto-inverts — no `dark:` needed.
34
+ 'border border-neutral-200',
31
35
  'p-6 flex flex-col gap-4 max-h-[90vh]',
32
36
  'focus:outline-none',
33
37
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
34
38
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
35
39
  'motion-reduce:transition-none motion-reduce:duration-0',
36
40
  ],
37
- title: 'text-lg font-semibold leading-tight text-neutral-900 dark:text-neutral-50',
38
- description: 'text-sm text-neutral-600 dark:text-neutral-400',
41
+ // All neutral text classes auto-invert via CSS-var swap.
42
+ title: 'text-lg font-semibold leading-tight text-neutral-900',
43
+ description: 'text-sm text-neutral-600',
39
44
  closeButton: [
40
45
  'absolute top-4 right-4 inline-flex items-center justify-center',
41
46
  'h-8 w-8 rounded-md text-neutral-500 hover:text-neutral-900',
42
- 'dark:text-neutral-400 dark:hover:text-neutral-50',
43
- 'hover:bg-neutral-100 dark:hover:bg-neutral-800',
47
+ 'hover:bg-neutral-100',
44
48
  'focus:outline-none focus:ring-2 focus:ring-primary-500',
45
49
  'transition-colors',
46
50
  ],
@@ -66,8 +66,10 @@ describe('<Divider>', () => {
66
66
  it('default neutral', () => {
67
67
  const { container } = render(<Divider />);
68
68
  const cls = container.firstElementChild?.className ?? '';
69
+ // Sprint 4.3 identity sweep: neutral palette auto-inverts via
70
+ // the dashforgePreset() CSS-var swap — no `dark:` needed.
69
71
  expect(cls).toContain('border-neutral-200');
70
- expect(cls).toContain('dark:border-neutral-800');
72
+ expect(cls).not.toContain('dark:border-neutral-');
71
73
  });
72
74
 
73
75
  it('color="primary"', () => {
@@ -128,7 +128,7 @@ export const Divider = forwardRef<HTMLElement, DividerProps>(
128
128
  {...rest}
129
129
  >
130
130
  <span aria-hidden="true" className={cn(lineCommon, leftStubClass)} />
131
- <span className={cn('shrink-0 text-sm text-neutral-500 dark:text-neutral-400', labelPadding)}>
131
+ <span className={cn('shrink-0 text-sm text-neutral-500', labelPadding)}>
132
132
  {children}
133
133
  </span>
134
134
  <span aria-hidden="true" className={cn(lineCommon, rightStubClass)} />
@@ -85,7 +85,8 @@ export const dividerLineVariants = tv({
85
85
  },
86
86
 
87
87
  color: {
88
- neutral: 'border-neutral-200 dark:border-neutral-800',
88
+ // Neutral palette auto-inverts via CSS-var swap (no `dark:`).
89
+ neutral: 'border-neutral-200',
89
90
  primary: 'border-primary-300 dark:border-primary-700',
90
91
  secondary: 'border-secondary-300 dark:border-secondary-700',
91
92
  success: 'border-success-300 dark:border-success-700',
@@ -238,7 +238,9 @@ export function Pagination(props: PaginationProps) {
238
238
  disabled={disabled}
239
239
  aria-label={labels.perPage}
240
240
  className={cn(
241
- 'rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1',
241
+ // Auto-invert via CSS-var swap — no `dark:` variants needed
242
+ // on neutral palette.
243
+ 'rounded-md border border-neutral-300 bg-neutral-50 px-2 py-1',
242
244
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
243
245
  )}
244
246
  >
@@ -23,28 +23,34 @@ import { tv, type VariantProps } from 'tailwind-variants';
23
23
  export const paginationVariants = tv({
24
24
  slots: {
25
25
  root: 'flex flex-wrap items-center gap-3',
26
- summary: 'text-neutral-600 dark:text-neutral-400',
26
+ // All neutral classes auto-invert via the dashforgePreset() CSS-var
27
+ // swap. `bg-neutral-50` provides the button surface (matches page
28
+ // surface; the border gives the separation). `hover:bg-neutral-100`
29
+ // is the one-tier elevation that works in both modes.
30
+ summary: 'text-neutral-600',
27
31
  list: 'inline-flex items-center gap-1',
28
32
  pageButton: [
29
33
  'inline-flex items-center justify-center rounded-md',
30
- 'border border-neutral-300 dark:border-neutral-700',
31
- 'bg-white dark:bg-neutral-900',
32
- 'text-neutral-700 dark:text-neutral-300',
33
- 'hover:bg-neutral-50 dark:hover:bg-neutral-800',
34
+ 'border border-neutral-300',
35
+ 'bg-neutral-50',
36
+ 'text-neutral-700',
37
+ 'hover:bg-neutral-100',
34
38
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
35
39
  'transition-colors motion-reduce:transition-none',
36
40
  'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
37
41
  ],
38
42
  activeButton: [
43
+ // Primary palette does NOT auto-invert — `dark:` shift is a
44
+ // design choice for tone refinement (kept).
39
45
  'bg-primary-500 text-white border-primary-500',
40
46
  'hover:bg-primary-600 dark:hover:bg-primary-400',
41
47
  ],
42
48
  navButton: [
43
49
  'inline-flex items-center justify-center rounded-md',
44
- 'border border-neutral-300 dark:border-neutral-700',
45
- 'bg-white dark:bg-neutral-900',
46
- 'text-neutral-700 dark:text-neutral-300',
47
- 'hover:bg-neutral-50 dark:hover:bg-neutral-800',
50
+ 'border border-neutral-300',
51
+ 'bg-neutral-50',
52
+ 'text-neutral-700',
53
+ 'hover:bg-neutral-100',
48
54
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
49
55
  'transition-colors motion-reduce:transition-none',
50
56
  'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none',
@@ -52,12 +58,12 @@ export const paginationVariants = tv({
52
58
  ellipsis: 'inline-flex items-center justify-center text-neutral-500 px-1',
53
59
  pageSizeSelector: [
54
60
  'inline-flex items-center gap-2',
55
- 'text-neutral-700 dark:text-neutral-300',
61
+ 'text-neutral-700',
56
62
  ],
57
63
  jumpInput: [
58
- 'rounded-md border border-neutral-300 dark:border-neutral-700',
59
- 'bg-white dark:bg-neutral-900',
60
- 'text-neutral-900 dark:text-neutral-50',
64
+ 'rounded-md border border-neutral-300',
65
+ 'bg-neutral-50',
66
+ 'text-neutral-900',
61
67
  'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
62
68
  'transition-colors motion-reduce:transition-none',
63
69
  ],
@@ -3,15 +3,21 @@ import { tv, type VariantProps } from 'tailwind-variants';
3
3
  export const popoverVariants = tv({
4
4
  slots: {
5
5
  content: [
6
- 'z-50 rounded-md border border-neutral-200 dark:border-neutral-800',
7
- 'bg-white dark:bg-neutral-900 p-4 shadow-lg',
8
- 'text-sm text-neutral-900 dark:text-neutral-50',
6
+ // Neutral border auto-inverts via CSS-var swap.
7
+ 'z-50 rounded-md border border-neutral-200',
8
+ // bg-white hardcoded (no auto-invert) so `dark:` is required.
9
+ // Target `dark:bg-neutral-100` resolves to #171717 in dark mode
10
+ // (one elevation tier above the page surface).
11
+ 'bg-white dark:bg-neutral-100 p-4 shadow-lg',
12
+ // Text auto-inverts.
13
+ 'text-sm text-neutral-900',
9
14
  'focus:outline-none',
10
15
  'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
11
16
  'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
12
17
  'motion-reduce:transition-none motion-reduce:duration-0',
13
18
  ],
14
- arrow: 'fill-white dark:fill-neutral-900',
19
+ // Arrow follows content bg: `fill-white` in light + matching dark target.
20
+ arrow: 'fill-white dark:fill-neutral-100',
15
21
  },
16
22
  });
17
23
 
@@ -35,7 +35,8 @@ export const skeletonVariants = tv({
35
35
  slots: {
36
36
  root: [
37
37
  'block',
38
- 'bg-neutral-200 dark:bg-neutral-800',
38
+ // Neutral palette auto-inverts via CSS-var swap (no `dark:`).
39
+ 'bg-neutral-200',
39
40
  'motion-reduce:animate-none',
40
41
  ],
41
42
  },
@@ -3,19 +3,19 @@ import { cn } from '../../utils/cn.js';
3
3
  import { useAccessState } from '../../hooks/useAccessState.js';
4
4
  import { Skeleton } from '../Skeleton/Skeleton.js';
5
5
  import { tableVariants } from './table.variants.js';
6
- import { useControllableState } from './_internal/useControllableState.js';
7
- import { useDebouncedValue } from './_internal/useDebouncedValue.js';
8
- import { useTableSearch } from './_internal/useTableSearch.js';
9
- import { useTableFilter } from './_internal/useTableFilter.js';
10
- import { useTableSort, cycleSortFor } from './_internal/useTableSort.js';
11
- import { useTableSelection } from './_internal/useTableSelection.js';
12
- import { getNestedValue } from './_internal/getNestedValue.js';
6
+ import { useControllableState } from '../_shared/data/useControllableState.js';
7
+ import { useDebouncedValue } from '../_shared/data/useDebouncedValue.js';
8
+ import { useTableSearch } from '../_shared/data/useTableSearch.js';
9
+ import { useTableFilter } from '../_shared/data/useTableFilter.js';
10
+ import { useTableSort, cycleSortFor } from '../_shared/data/useTableSort.js';
11
+ import { useTableSelection } from '../_shared/data/useTableSelection.js';
12
+ import { getNestedValue } from '../_shared/data/getNestedValue.js';
13
13
  import {
14
14
  useColumnAutoDetect,
15
15
  resolveAlign,
16
16
  resolveMonospace,
17
17
  resolveTabularNums,
18
- } from './_internal/useColumnAutoDetect.js';
18
+ } from '../_shared/data/useColumnAutoDetect.js';
19
19
  import type {
20
20
  TableProps,
21
21
  TableColumn,
@@ -40,10 +40,24 @@ const DEFAULT_LABELS: Required<TableLabels> = {
40
40
  filterColumn: 'Filter',
41
41
  filterApply: 'Apply',
42
42
  filterClear: 'Clear',
43
+ filterMin: 'Min',
44
+ filterMax: 'Max',
45
+ filterFrom: 'From',
46
+ filterTo: 'To',
47
+ filterAll: 'All',
48
+ filterTrue: 'True',
49
+ filterFalse: 'False',
43
50
  density: 'Density',
44
51
  densityCompact: 'Compact',
45
52
  densityComfortable: 'Comfortable',
46
53
  densitySpacious: 'Spacious',
54
+ columnsButton: 'Columns',
55
+ columnsTitle: 'Manage columns',
56
+ columnsDescription:
57
+ 'Show or hide columns. Required columns cannot be toggled.',
58
+ columnsShowAll: 'Show all',
59
+ columnsHideAll: 'Hide all',
60
+ columnsDone: 'Done',
47
61
  };
48
62
 
49
63
  const DEFAULT_GET_ROW_ID = <T extends object>(_row: T, index: number) => String(index);
@@ -131,11 +131,20 @@ export interface TableColumn<T extends object> {
131
131
  searchable?: boolean;
132
132
 
133
133
  /**
134
- * Enable per-column quick filter (text-based in v1).
134
+ * Enable per-column quick filter UI in the column header.
135
135
  * @default false
136
136
  */
137
137
  filterable?: boolean;
138
138
 
139
+ /**
140
+ * Override the filter UI type. By default the type is auto-detected
141
+ * from `useColumnAutoDetect` (number → range, date → range,
142
+ * boolean → select, anything else → text contains). Force a
143
+ * specific UI when the autodetect picks the wrong one — e.g. an
144
+ * ID column stored as a number that should filter by text contains.
145
+ */
146
+ filterType?: TableFilterType;
147
+
139
148
  /**
140
149
  * Custom cell renderer. Receives the full row + the resolved
141
150
  * (potentially nested) value, the row index, selection state,
@@ -151,9 +160,41 @@ export interface TableColumn<T extends object> {
151
160
  */
152
161
  access?: AccessRequirement;
153
162
 
154
- /** Reserved for v1-bis (column-visibility dialog). */
163
+ /**
164
+ * Pin this column during horizontal scroll. `'left'` (DataGrid v1)
165
+ * uses CSS `position: sticky; left: 0`. `'right'` is reserved for
166
+ * v1-bis (CSS edge cases around last-column borders). Table
167
+ * (non-virtualized) ignores this prop today — only DataGrid honors
168
+ * it (Sprint 4.2).
169
+ */
170
+ sticky?: 'left' | 'right';
171
+
172
+ /**
173
+ * Allow user to hide this column from the column-visibility dialog.
174
+ * Pass `false` for structurally required columns (e.g. an ID column).
175
+ * @default true
176
+ */
155
177
  hideable?: boolean;
178
+ /** Hidden on first render (the user can re-show via the dialog). */
156
179
  defaultHidden?: boolean;
180
+
181
+ /**
182
+ * Allow user to resize this column by dragging the right edge of
183
+ * its header. Pass `false` to opt out per-column. The grid-level
184
+ * `enableColumnResize` flag must also be on (default `true`).
185
+ * @default true
186
+ */
187
+ resizable?: boolean;
188
+ /** Maximum width (px) honored by the resize drag. */
189
+ maxWidth?: number;
190
+
191
+ /**
192
+ * Allow user to reorder this column by dragging its header. Pass
193
+ * `false` to pin its position. The grid-level `enableColumnReorder`
194
+ * flag must also be on (default `true`).
195
+ * @default true
196
+ */
197
+ reorderable?: boolean;
157
198
  }
158
199
 
159
200
  // ───── State models ─────
@@ -172,15 +213,36 @@ export interface TableSortItem {
172
213
  */
173
214
  export type TableSortModel = TableSortItem[];
174
215
 
216
+ /**
217
+ * Filter operator. The value shape depends on the operator:
218
+ * - `contains` → `value: string` (text substring match)
219
+ * - `equals` → `value: unknown` (exact match, used for booleans)
220
+ * - `between` → `value: [min, max]` where each end can be `null`
221
+ * for an open range. Used for number ranges (`value: [10, 50]`)
222
+ * and date ranges (`value: ['2024-01-01', '2024-12-31']` as ISO).
223
+ */
224
+ export type TableFilterOperator = 'contains' | 'equals' | 'between';
225
+
175
226
  export interface TableFilterItem {
176
227
  field: string;
177
- /** Text substring match. Future ops: `equals`, `in`, `gte`, `lte`, `between`. */
178
- op: 'contains';
179
- value: string;
228
+ op: TableFilterOperator;
229
+ value: unknown;
180
230
  }
181
231
 
182
232
  export type TableFilterModel = TableFilterItem[];
183
233
 
234
+ /**
235
+ * Column-level filter type hint. Determines which filter UI to
236
+ * render in the column header Popover. Auto-detected from
237
+ * `useColumnAutoDetect` when omitted:
238
+ *
239
+ * - `number` → number range filter
240
+ * - `date` → date range filter
241
+ * - `boolean` → true/false/all select
242
+ * - `string` (or anything else) → text contains
243
+ */
244
+ export type TableFilterType = 'text' | 'number' | 'boolean' | 'date';
245
+
184
246
  export type TableRowSelectionMode = 'none' | 'single' | 'multiple';
185
247
 
186
248
  // ───── i18n labels ─────
@@ -212,11 +274,27 @@ export interface TableLabels {
212
274
  filterColumn?: string;
213
275
  filterApply?: string;
214
276
  filterClear?: string;
277
+ /** Number / date range filter inputs. */
278
+ filterMin?: string;
279
+ filterMax?: string;
280
+ filterFrom?: string;
281
+ filterTo?: string;
282
+ /** Boolean filter radio options. */
283
+ filterAll?: string;
284
+ filterTrue?: string;
285
+ filterFalse?: string;
215
286
  /** Density toggle. */
216
287
  density?: string;
217
288
  densityCompact?: string;
218
289
  densityComfortable?: string;
219
290
  densitySpacious?: string;
291
+ /** Column visibility dialog. */
292
+ columnsButton?: string;
293
+ columnsTitle?: string;
294
+ columnsDescription?: string;
295
+ columnsShowAll?: string;
296
+ columnsHideAll?: string;
297
+ columnsDone?: string;
220
298
  }
221
299
 
222
300
  // ───── Slot props ─────