@rozie-ui/data-table-solid 0.5.0 → 0.6.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.
@@ -24,11 +24,15 @@ interface FilterSelectProps {
24
24
  * The faceted distinct keys for this column (cross-filtered, keys only — no occurrence counts) used to build the `<option>` list.
25
25
  */
26
26
  uniqueValues?: any[];
27
+ /**
28
+ * The column's human header, forwarded by the `#filter` slot scope — used as the control's accessible name in place of the internal column id.
29
+ */
30
+ columnLabel?: string;
27
31
  }
28
32
 
29
33
  export default function FilterSelect(_props: FilterSelectProps): JSX.Element {
30
- const _merged = mergeProps({ columnId: '', column: null, value: null, setFilter: null, uniqueValues: (() => [])() as any[] }, _props);
31
- const [local, attrs] = splitProps(_merged, ['columnId', 'column', 'value', 'setFilter', 'uniqueValues']);
34
+ const _merged = mergeProps({ columnId: '', column: null, value: null, setFilter: null, uniqueValues: (() => [])() as any[], columnLabel: '' }, _props);
35
+ const [local, attrs] = splitProps(_merged, ['columnId', 'column', 'value', 'setFilter', 'uniqueValues', 'columnLabel']);
32
36
 
33
37
  // The <select> value binding coerced to a string. $props.value is typed `unknown`
34
38
  // (opaque slot-scope), which the strict bundled-leaf tsc rejects against the native
@@ -49,9 +53,20 @@ export default function FilterSelect(_props: FilterSelectProps): JSX.Element {
49
53
  }
50
54
  }
51
55
 
56
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
57
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
58
+ // reading an implementation detail aloud. table-core keeps the authored header on
59
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
60
+ // a header rendered by a function has no static text, so fall back to the id rather
61
+ // than invent one.
62
+ function a11yLabel() {
63
+ if (typeof local.columnLabel === 'string' && local.columnLabel !== '') return local.columnLabel;
64
+ return local.columnId;
65
+ }
66
+
52
67
  return (
53
68
  <>
54
- <select part="col-filter" aria-label={local.columnId} class={"rdt-col-filter"} value={selectValue()} onChange={($event: Event & { currentTarget: HTMLSelectElement; target: Element }) => { onChange($event); }} data-rozie-s-d75b42b2="">
69
+ <select part="col-filter" aria-label={rozieAttr(a11yLabel())} class={"rdt-col-filter"} value={selectValue()} onChange={($event: Event & { currentTarget: HTMLSelectElement; target: Element }) => { onChange($event); }} data-rozie-s-d75b42b2="">
55
70
  <option value="" data-rozie-s-d75b42b2="">All</option>
56
71
  <Key each={local.uniqueValues as readonly any[]} by={(opt) => opt}>{(opt) => <option value={rozieAttr(opt())} data-rozie-s-d75b42b2="">{rozieDisplay(opt())}</option>}</Key>
57
72
  </select>
@@ -1,5 +1,6 @@
1
1
  import type { JSX } from 'solid-js';
2
- import { createSignal, mergeProps, splitProps } from 'solid-js';
2
+ import { createEffect, createSignal, mergeProps, on, splitProps, untrack } from 'solid-js';
3
+ import { rozieAttr } from '@rozie/runtime-solid';
3
4
 
4
5
  interface FilterTextProps {
5
6
  /**
@@ -18,30 +19,57 @@ interface FilterTextProps {
18
19
  * `(columnId, value) => void` — apply the column filter (Enter / blur applies, Escape clears). Null-guarded at call sites.
19
20
  */
20
21
  setFilter?: ((...args: any[]) => any) | null;
22
+ /**
23
+ * The column's human header, forwarded by the `#filter` slot scope — used as the control's accessible name in place of the internal column id.
24
+ */
25
+ columnLabel?: string;
21
26
  }
22
27
 
23
28
  export default function FilterText(_props: FilterTextProps): JSX.Element {
24
- const _merged = mergeProps({ columnId: '', column: null, value: null, setFilter: null }, _props);
25
- const [local, attrs] = splitProps(_merged, ['columnId', 'column', 'value', 'setFilter']);
29
+ const _merged = mergeProps({ columnId: '', column: null, value: null, setFilter: null, columnLabel: '' }, _props);
30
+ const [local, attrs] = splitProps(_merged, ['columnId', 'column', 'value', 'setFilter', 'columnLabel']);
26
31
 
27
32
  const [draft, setDraft] = createSignal('');
33
+ const [touched, setTouched] = createSignal(false);
34
+ createEffect(on(() => (() => local.value)(), (v) => untrack(() => (() => {
35
+ setTouched(false);
36
+ })()), { defer: true }));
28
37
 
29
- // Seed the draft once at setup from the incoming value (setup-once, NOT in the
30
- // template). Normalize null/undefined to '' so the input value binds to a string.
31
- setDraft(local.value != null ? String(local.value) : '');
32
-
38
+ // ── C-08 + N-04: the draft is DERIVED from `$props.value`, with a `touched` latch ────────
39
+ //
40
+ // C-08 — this component mounts once per rendered filter row and is NOT remounted when the
41
+ // filter changes, and its draft was seeded setup-once. So a programmatic reset (a "Clear
42
+ // filters" button, `setFilter(id, '')` from another control, a consumer writing
43
+ // `columnFilters` directly) moved the real filter while this input went on displaying the old
44
+ // text: the UI stated a filter that was no longer applied. `FilterSelect` was never affected —
45
+ // it reads `$props.value` live through `selectValue()`, which is the pattern adopted here.
46
+ //
47
+ // N-04 — the setup-once seed was ALSO wrong on Angular from the start: the emitter places
48
+ // setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
49
+ // DEFAULT, because Angular sets inputs after construction. Measured on the sibling EditorDate,
50
+ // which opened empty on angular for every row while vue and lit seeded correctly. A derived
51
+ // read is correct on all six by construction, with no flash and no per-target branch.
52
+ //
53
+ // `touched` is what stops the live read from overwriting the user mid-type; the watch clears
54
+ // it, so an OUTSIDE change always wins and the input follows it. `@input` never moves
55
+ // `$props.value` (nothing is applied until Enter/blur), so the two can never fight.
56
+ function draftValue() {
57
+ return touched() ? draft() : local.value != null ? String(local.value) : '';
58
+ }
33
59
  // Untyped handler param neutralizes to `any`, so reading e.target.value typechecks
34
60
  // ×6 (the global-filter idiom). Never inline `$data.x = $event.target.value`.
35
61
  function onInput(e: any) {
36
62
  setDraft(e && e.target ? e.target.value : '');
63
+ setTouched(true);
37
64
  }
38
65
 
39
66
  // setFilter is a Function prop (default null) — guard before calling.
40
67
  function applyFilter() {
41
- local.setFilter && local.setFilter(local.columnId, draft());
68
+ local.setFilter && local.setFilter(local.columnId, draftValue());
42
69
  }
43
70
  function clearFilter() {
44
71
  setDraft('');
72
+ setTouched(false);
45
73
  local.setFilter && local.setFilter(local.columnId, '');
46
74
  }
47
75
  function onKeydown(e: any) {
@@ -57,9 +85,20 @@ export default function FilterText(_props: FilterTextProps): JSX.Element {
57
85
  applyFilter();
58
86
  }
59
87
 
88
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
89
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
90
+ // reading an implementation detail aloud. table-core keeps the authored header on
91
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
92
+ // a header rendered by a function has no static text, so fall back to the id rather
93
+ // than invent one.
94
+ function a11yLabel() {
95
+ if (typeof local.columnLabel === 'string' && local.columnLabel !== '') return local.columnLabel;
96
+ return local.columnId;
97
+ }
98
+
60
99
  return (
61
100
  <>
62
- <input part="col-filter" type="text" aria-label={local.columnId} class={"rdt-col-filter"} value={draft()} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInput($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeydown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onBlur(); }} data-rozie-s-18cbb44e="" />
101
+ <input part="col-filter" type="text" aria-label={rozieAttr(a11yLabel())} class={"rdt-col-filter"} value={draftValue()} onInput={($event: InputEvent & { currentTarget: HTMLInputElement; target: Element }) => { onInput($event); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: Element }) => { onKeydown($event); }} onBlur={($event: FocusEvent & { currentTarget: HTMLInputElement; target: Element }) => { onBlur(); }} data-rozie-s-18cbb44e="" />
63
102
  </>
64
103
  );
65
104
  }
package/src/GroupBar.tsx CHANGED
@@ -20,6 +20,18 @@ __rozieInjectStyle('GroupBar-546c469a', `.rdt-group-drop-zone[data-rozie-s-546c4
20
20
  border-color: var(--rdt-group-drop-zone-border-over, rgba(37, 99, 235, 0.7));
21
21
  background: var(--rdt-group-drop-zone-bg-over, rgba(37, 99, 235, 0.08));
22
22
  }
23
+ .rdt-group-token-add[data-rozie-s-546c469a] {
24
+ font: inherit;
25
+ color: inherit;
26
+ border: none;
27
+ background: none;
28
+ cursor: pointer;
29
+ }
30
+ .rdt-group-token-add[data-rozie-s-546c469a]:focus-visible,
31
+ .rdt-group-drop-zone[data-rozie-s-546c469a] [data-group-token][data-rozie-s-546c469a]:focus-visible {
32
+ outline: var(--rdt-focus-ring, 2px solid #2563eb);
33
+ outline-offset: 1px;
34
+ }
23
35
  .rdt-group-drop-hint[data-rozie-s-546c469a] {
24
36
  opacity: 0.55;
25
37
  font-size: 0.8125em;
@@ -173,6 +185,66 @@ export default function GroupBar(_props: GroupBarProps): JSX.Element {
173
185
  function removeKey(key: any) {
174
186
  local.applyGrouping && local.applyGrouping(local.grouping.filter((k: any) => k !== key));
175
187
  }
188
+
189
+ // ── C-04: the keyboard half of the group bar ────────────────────────────────────────────
190
+ // DECLARED AFTER removeKey ON PURPOSE: toggleKey and onTokenKeydown call it, and the React
191
+ // emitter lowers each to a useCallback whose dependency array is evaluated EAGERLY — declared
192
+ // above removeKey, every React GroupBar died at mount with a TDZ ReferenceError (measured in
193
+ // quick 260922-mkb; the same 87-02 ordering lesson as onEditorDropinKeyDown).
194
+ // Native HTML5 drag-and-drop was the SOLE input path for both adding and reordering, on
195
+ // elements with no tabindex, no role and no click/keydown handler. Remove and Clear were real
196
+ // `<button>`s, so a keyboard user could UNGROUP but could never group or reorder — the bar was
197
+ // a one-way door. HTML5 DnD has no keyboard equivalent by construction, so a parallel,
198
+ // explicitly-keyboard path is the only fix; both halves write through the SAME
199
+ // `applyGrouping` funnel the drop handler uses, so there is one ordering rule, not two.
200
+ //
201
+ // The palette chips become real `<button>`s (Enter/Space for free, focusable for free) rather
202
+ // than spans with `tabindex` + a hand-rolled key handler — the same reasoning that already
203
+ // made Remove and Clear buttons. A `<button draggable="true">` keeps the existing pointer
204
+ // path working unchanged.
205
+ //
206
+ // Reorder is Alt+Arrow on a grouping token, not plain Arrow: the tokens sit in a toolbar that
207
+ // a user also arrows THROUGH, and stealing bare arrows would trap them. Alt is the modifier
208
+ // Windows/macOS list reordering conventionally uses.
209
+ function toggleKey(id: any) {
210
+ if (!id) return;
211
+ if (local.grouping.indexOf(id) !== -1) {
212
+ removeKey(id);
213
+ return;
214
+ }
215
+ local.applyGrouping && local.applyGrouping(local.grouping.concat([id]));
216
+ }
217
+
218
+ // Move a grouping key one position left/right. No-op at the ends (never wraps: a wrap would
219
+ // make a held key cycle forever with no signal that the end was reached).
220
+ function moveKey(key: any, delta: any) {
221
+ const cur = local.grouping;
222
+ const from = cur.indexOf(key);
223
+ if (from === -1) return;
224
+ const to = from + delta;
225
+ if (to < 0 || to >= cur.length) return;
226
+ const next = cur.slice();
227
+ next.splice(from, 1);
228
+ next.splice(to, 0, key);
229
+ local.applyGrouping && local.applyGrouping(next);
230
+ }
231
+ function onTokenKeydown(e: any, gk: any) {
232
+ if (!e) return;
233
+ const key = e.key;
234
+ if (key === 'Delete' || key === 'Backspace') {
235
+ e.preventDefault();
236
+ removeKey(gk);
237
+ return;
238
+ }
239
+ if (!e.altKey) return;
240
+ if (key === 'ArrowLeft') {
241
+ e.preventDefault();
242
+ moveKey(gk, -1);
243
+ } else if (key === 'ArrowRight') {
244
+ e.preventDefault();
245
+ moveKey(gk, 1);
246
+ }
247
+ }
176
248
  function clearAll() {
177
249
  local.clearGrouping && local.clearGrouping();
178
250
  }
@@ -189,12 +261,12 @@ export default function GroupBar(_props: GroupBarProps): JSX.Element {
189
261
  <>
190
262
  <div class={"rdt-group-bar"} data-rozie-s-546c469a="">
191
263
 
192
- <Key each={local.groupableColumns as readonly any[]} by={(col) => col.id}>{(col) => <span part="group-token" draggable="true" class={"rdt-group-token"} onDragStart={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onChipDragStart($event, col().id); }} onDragEnd={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragEnd(); }} data-rozie-s-546c469a="">{rozieDisplay(col().label)}</span>}</Key>
264
+ <Key each={local.groupableColumns as readonly any[]} by={(col) => col.id}>{(col) => <button type="button" part="group-token" draggable="true" aria-pressed={rozieAttr(local.grouping.indexOf(col().id) !== -1 ? 'true' : 'false')} aria-label={rozieAttr('Group by ' + col().label)} class={"rdt-group-token rdt-group-token-add"} onDragStart={($event: DragEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onChipDragStart($event, col().id); }} onDragEnd={($event: DragEvent & { currentTarget: HTMLButtonElement; target: Element }) => { onDragEnd(); }} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { toggleKey(col().id); }} data-rozie-s-546c469a="">{rozieDisplay(col().label)}</button>}</Key>
193
265
 
194
266
 
195
- <span data-group-drop-zone="" class={"rdt-group-drop-zone" + " " + rozieClass({ 'is-over': isOver() })} onDragOver={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragOver($event); }} onDragLeave={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragLeave($event); }} onDrop={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDrop($event); }} data-rozie-s-546c469a="">
267
+ <span data-group-drop-zone="" role="list" aria-label="Active grouping" class={"rdt-group-drop-zone" + " " + rozieClass({ 'is-over': isOver() })} onDragOver={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragOver($event); }} onDragLeave={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragLeave($event); }} onDrop={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDrop($event); }} data-rozie-s-546c469a="">
196
268
 
197
- {<Show when={!local.grouping.length}><span class={"rdt-group-drop-hint"} data-rozie-s-546c469a="">Drag columns here to group</span></Show>}<Key each={local.grouping as readonly any[]} by={(gk) => gk}>{(gk) => <span part="group-token" data-group-token="" draggable="true" class={"rdt-group-token" + " " + rozieClass({ 'is-drop-target': dragKind() === 'token' && dropKey() === gk() && draggingId() !== gk() })} onDragStart={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onTokenDragStart($event, gk()); }} onDragOver={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onTokenDragOver($event, gk()); }} onDragEnd={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragEnd(); }} data-rozie-s-546c469a="">
269
+ {<Show when={!local.grouping.length}><span class={"rdt-group-drop-hint"} data-rozie-s-546c469a="">Drag columns here to group</span></Show>}<Key each={local.grouping as readonly any[]} by={(gk) => gk}>{(gk) => <span part="group-token" data-group-token="" draggable="true" role="listitem" aria-label={rozieAttr(labelFor(gk()) + ' grouping, position ' + (local.grouping.indexOf(gk()) + 1) + ' of ' + local.grouping.length + '. Alt+Arrow to reorder, Delete to remove.')} class={"rdt-group-token" + " " + rozieClass({ 'is-drop-target': dragKind() === 'token' && dropKey() === gk() && draggingId() !== gk() })} tabIndex={0} onDragStart={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onTokenDragStart($event, gk()); }} onDragOver={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onTokenDragOver($event, gk()); }} onDragEnd={($event: DragEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onDragEnd(); }} onKeyDown={($event: KeyboardEvent & { currentTarget: HTMLSpanElement; target: Element }) => { onTokenKeydown($event, gk()); }} data-rozie-s-546c469a="">
198
270
  {rozieDisplay(labelFor(gk()))}
199
271
  <button type="button" aria-label={rozieAttr('Remove ' + labelFor(gk()) + ' grouping')} class={"rdt-group-token-remove"} onClick={($event: MouseEvent & { currentTarget: HTMLButtonElement; target: Element }) => { removeKey(gk()); }} data-rozie-s-546c469a="">×</button>
200
272
  </span>}</Key>
@@ -108,4 +108,76 @@ const collectGroupableLeafDefs = (defs: any): any[] => {
108
108
  return out
109
109
  }
110
110
 
111
- export { isSafeKey, wrapAggregationFn, collectNestedDefs, indexDefsById, collectGroupableLeafDefs }
111
+ // ── E-03: the `editor` union is FOUR built-ins plus the 'custom' gate ────────────────────
112
+ // `editorTypeOf` falls through to the plain text `<input>` for ANY unrecognised value, so a
113
+ // consumer writing `editor="date"` — a value the comparison page and the root README both
114
+ // advertised as a fifth built-in until the 260910 audit corrected them — got a text box with
115
+ // no error, no warning, and no hint that `EditorDate` is a DROP-IN reached through
116
+ // `editor="custom"` + an `#editor` fill. Same for any typo. The silent degradation was the
117
+ // defect; the docs half is already corrected.
118
+ //
119
+ // Pure and message-returning rather than warning in place, so the contract (which values are
120
+ // accepted, and what a rejected one is told) is unit-testable; `columnBuilders.rzts` owns the
121
+ // latch and the console.warn.
122
+ const EDITOR_KINDS = ['text', 'number', 'select', 'checkbox', 'custom']
123
+ const editorKindWarning = (id: any, editor: any): string | null => {
124
+ if (editor == null) return null
125
+ if (typeof editor === 'string' && EDITOR_KINDS.indexOf(editor) !== -1) return null
126
+ return (
127
+ '[rozie-data-table] column "' + String(id) + '": editor="' + String(editor) + '" is not a ' +
128
+ 'built-in editor. The built-ins are text | number | select | checkbox; anything else needs ' +
129
+ 'editor="custom" plus an #editor slot fill (that is how <EditorDate> is used). Falling back ' +
130
+ 'to the text input.'
131
+ )
132
+ }
133
+
134
+
135
+ // ── C-09: is this column spec EQUIVALENT to the one already registered? ──────────────────
136
+ // `<Column>`'s re-register `$watch` keys on `$props.editorOptions`, `$props.aggregationFn` and
137
+ // `$props.validate` — all reference types. The documented wiring for them is an INLINE literal
138
+ // (`:editorOptions="[{ value: 'a' }, …]"`, `:validate="(v) => v !== ''"`), and an inline
139
+ // literal is a NEW identity on every consumer render. So the watch fired every render,
140
+ // `registerColumn` whole-object-replaced `$data.colReg`, the parent's re-feed watch keys on
141
+ // `$data.colReg`, the re-feed re-rendered the parent, and the next render produced a new
142
+ // identity again: not a slow path, a feedback loop.
143
+ //
144
+ // Equality is by VALUE, with functions compared on identity first and `toString()` second.
145
+ // Source-text comparison is the point rather than a shortcut: an inline arrow re-created each
146
+ // render has a different identity and identical source, which is exactly the case that has to
147
+ // stop churning, while a genuinely different validator has different source and still
148
+ // re-registers. Depth-bounded so a consumer object graph with a cycle cannot hang the compare.
149
+ const columnSpecsEquivalent = (a: any, b: any, depth?: number): boolean => {
150
+ const d = typeof depth === 'number' ? depth : 0
151
+ if (a === b) return true
152
+ if (d > 4) return false
153
+ const ta = typeof a
154
+ const tb = typeof b
155
+ if (ta !== tb) return false
156
+ if (ta === 'function') {
157
+ try {
158
+ return String(a) === String(b)
159
+ } catch (err) {
160
+ return false
161
+ }
162
+ }
163
+ if (ta === 'number') return a !== a && b !== b // NaN === NaN, for this purpose
164
+ if (a === null || b === null || ta !== 'object') return false
165
+ const aArr = Array.isArray(a)
166
+ if (aArr !== Array.isArray(b)) return false
167
+ if (aArr) {
168
+ if (a.length !== b.length) return false
169
+ for (let i = 0; i < a.length; i++) if (!columnSpecsEquivalent(a[i], b[i], d + 1)) return false
170
+ return true
171
+ }
172
+ const ka = Object.keys(a)
173
+ const kb = Object.keys(b)
174
+ if (ka.length !== kb.length) return false
175
+ for (let i = 0; i < ka.length; i++) {
176
+ const k = ka[i]
177
+ if (!Object.prototype.hasOwnProperty.call(b, k)) return false
178
+ if (!columnSpecsEquivalent(a[k], b[k], d + 1)) return false
179
+ }
180
+ return true
181
+ }
182
+
183
+ export { isSafeKey, wrapAggregationFn, collectNestedDefs, indexDefsById, collectGroupableLeafDefs, EDITOR_KINDS, editorKindWarning, columnSpecsEquivalent }
@@ -0,0 +1,55 @@
1
+ // dateValue.ts — C-07. The ISO-date coercion `EditorDate` binds its native
2
+ // `<input type="date">` to. Pure and sigil-free (no $props/$data/...), so it is unit-testable
3
+ // and identical across all six leaves (helpers/ is vendored into each one by codegen).
4
+ //
5
+ // `EditorDate` seeded its draft with `String($props.value)` while its own `docs:` string —
6
+ // which ships as JSDoc in every leaf `.d.ts` — promised "String-coerced to an ISO
7
+ // `YYYY-MM-DD` string". `String()` is not that. A native date input accepts ONLY
8
+ // `YYYY-MM-DD` and silently renders BLANK for anything else, so every ordinary way of
9
+ // holding a date in a model produced an empty editor with no error:
10
+ // a `Date` object -> "Mon Sep 21 2026 00:00:00 GMT-0700 (…)" -> blank
11
+ // an ISO datetime string -> "2026-09-21T00:00:00.000Z" -> blank
12
+ // an epoch number -> "1789084800000" -> blank
13
+ // a localised string -> "09/21/2026" -> blank
14
+ // Only a value that was ALREADY `YYYY-MM-DD` worked, i.e. the one case needing no coercion.
15
+
16
+ /**
17
+ * Coerce an arbitrary cell value to the `YYYY-MM-DD` string a native date input accepts.
18
+ * Returns `''` for anything that cannot be read as a date — which is what the input shows for
19
+ * "no date", so an unparseable value degrades to empty rather than to a broken control.
20
+ */
21
+ const toIsoDateString = (v: unknown): string => {
22
+ if (v == null || v === '') return ''
23
+
24
+ // Already the target format.
25
+ if (typeof v === 'string') {
26
+ const s = v.trim()
27
+ if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s
28
+ // An ISO datetime: take the DATE PART AS WRITTEN. Parsing it and re-formatting from local
29
+ // parts shifts the day for anyone west of UTC ('2026-09-21T00:00:00Z' would render as the
30
+ // 20th in UTC-5), which is the classic off-by-one this branch exists to avoid.
31
+ const iso = /^(\d{4}-\d{2}-\d{2})T/.exec(s)
32
+ if (iso) return iso[1]
33
+ return fromDate(new Date(s))
34
+ }
35
+
36
+ if (typeof v === 'number') return Number.isFinite(v) ? fromDate(new Date(v)) : ''
37
+ if (typeof v === 'object' && typeof (v as Date).getTime === 'function') return fromDate(v as Date)
38
+ return ''
39
+ }
40
+
41
+ /**
42
+ * Format a Date as `YYYY-MM-DD` from its LOCAL parts. Local, not UTC: the input renders a
43
+ * calendar day, and `toISOString()` would show the previous day for anyone west of UTC on a
44
+ * midnight-local value.
45
+ */
46
+ const fromDate = (d: Date): string => {
47
+ const t = d.getTime()
48
+ if (!Number.isFinite(t)) return ''
49
+ const y = d.getFullYear()
50
+ const m = d.getMonth() + 1
51
+ const day = d.getDate()
52
+ return String(y).padStart(4, '0') + '-' + String(m).padStart(2, '0') + '-' + String(day).padStart(2, '0')
53
+ }
54
+
55
+ export { toIsoDateString }
@@ -1,9 +1,9 @@
1
1
  /*
2
2
  * @rozie-ui/data-table — base token reference.
3
3
  *
4
- * This file is the PUBLIC token reference. It declares 18 `--rozie-data-table-*`
5
- * tokens and wires each onto the short internal `--rdt-*` token the component's
6
- * scoped `<style>` actually reads. The component reads 82 internal `--rdt-*`
4
+ * This file is the PUBLIC token reference. It wires each of the 18 public
5
+ * `--rozie-data-table-*` tokens, with its default, onto the short internal `--rdt-*`
6
+ * token the component's scoped `<style>` actually reads. The component reads 82 internal `--rdt-*`
7
7
  * tokens in total, each with an inline `var(--rdt-x, <fallback>)` default, so the
8
8
  * table renders ZERO-CONFIG without importing this file.
9
9
  *
@@ -12,12 +12,17 @@
12
12
  * max-height. It does NOT reach every cosmetic value. 58 of the 82 internal tokens
13
13
  * have no public counterpart today, including the grid active-cell ring, the range
14
14
  * highlight, the fill handle, the invalid-cell outline, the group tokens, the filter
15
- * row, the per-header column ⋯ menu, most of the pagination bar (only
15
+ * row, the per-header column ⋯ menu (its panel is a composed @rozie-ui/popover, so its
16
+ * background, border and shadow follow --rozie-popover-bg, --rozie-popover-border and
17
+ * --rozie-popover-shadow, set at the same ancestor), most of the pagination bar (only
16
18
  * --rozie-data-table-control-border-color, --rozie-data-table-radius, and
17
19
  * --rozie-data-table-disabled-opacity are public — its background, padding, and
18
20
  * status-text size are not), and the chrome column widths. Those remain internal
19
21
  * and may be renamed; a design system that needs them should set the `--rdt-*`
20
- * name directly and accept that it is not yet a stable API. One Lit-specific rule: set
22
+ * name directly and accept that it is not yet a stable API. Those two counts are
23
+ * `<DataTable>`'s own; the sibling `<GroupBar>` reads 9 further internal tokens
24
+ * (`--rdt-group-drop-zone-*`, `--rdt-group-drop-marker`, `--rdt-focus-ring`), none of
25
+ * them public and all of them likewise inline-defaulted. One Lit-specific rule: set
21
26
  * the public tokens at or ABOVE the `<rozie-data-table>` element (`:root`, a wrapper, or
22
27
  * the element itself), never on the `.rozie-data-table` / `.rozie-data-table-wrap`
23
28
  * classes — on that target both of those render inside the component's shadow root,
@@ -35,96 +40,112 @@
35
40
  * `.rozie-data-table` / `.rozie-data-table-wrap` element). See
36
41
  * themes/shadcn.css, themes/material.css and themes/bootstrap.css for the
37
42
  * design-system bridges.
43
+ *
44
+ * WHERE THE DEFAULTS LIVE. Every default below is the FALLBACK of the `var()` that reads the
45
+ * public token, on the table's own elements — not a declaration of the token. Nothing in this
46
+ * file (or in a bridge) sets a `--rozie-data-table-*` token, so a value set on ANY ancestor
47
+ * (`:root`, `.dark`, a wrapper) or on the table itself reaches the table by plain inheritance,
48
+ * and the default applies only while no scope sets it. (A value an element declares for itself
49
+ * always beats one it would inherit, whatever the selector's specificity — which is why a
50
+ * declared default, anywhere on the path to the table, would shadow some ancestor's value.) A
51
+ * design-system bridge is the same mapping with the design system's variable as the fallback,
52
+ * so that variable too is resolved AT the table and follows the nearest theme scope — a
53
+ * `.dark` or `data-bs-theme` wrapper, not only `<html>`. The rule sits under `:where()` (zero
54
+ * specificity) so a bridge's copy wins in either import order; it also names the Lit host
55
+ * element `rozie-data-table`, the only element of that leaf a document stylesheet can reach.
38
56
  */
39
- .rozie-data-table-wrap,
40
- .rozie-data-table {
41
- /* These public tokens aren't read directly by a CSS var() inside this
42
- component's own scoped <style> — that is by design. This is the public
43
- surface; the "Wire the PUBLIC tokens..." block further down assigns each
44
- one onto the short internal --rdt-* custom property the component
45
- actually reads. */
57
+ :where(.rozie-data-table-wrap, .rozie-data-table, rozie-data-table) {
58
+ /* ─────────────────────────────────────────────────────────────────────────
59
+ * Wire the PUBLIC tokens onto the INTERNAL `--rdt-*` the component reads, each
60
+ * with its default as the fallback. The public tokens are not read by a CSS var()
61
+ * inside the component's own scoped <style> — that is by design: this block is the
62
+ * public surface, and a bridge re-declares the lines it maps with the design
63
+ * system's variable in place of the default. (The component still renders
64
+ * zero-config without this file via its own inline `var(--rdt-x, <fallback>)`
65
+ * defaults.) A public token read by several lines carries the same default in each.
66
+ * ───────────────────────────────────────────────────────────────────────── */
46
67
 
47
68
  /* ── palette (the few values most consumers will set) ───────────────────── */
48
- --rozie-data-table-fg: inherit;
49
- --rozie-data-table-accent: #0066cc;
50
- --rozie-data-table-border-color: rgba(0, 0, 0, 0.08);
51
- --rozie-data-table-control-border-color: rgba(0, 0, 0, 0.2);
52
- --rozie-data-table-radius: 4px;
69
+ /* `currentColor` for `color` is the inherited text colour: the table takes its
70
+ container's colour unless the token is set. The accent is read through the two tokens
71
+ that default to it — the selection checkboxes (next line) and the resize grip while
72
+ dragging — so it follows an accent set on any ancestor. */
73
+ --rdt-color: var(--rozie-data-table-fg, currentColor);
74
+ --rdt-select-accent: var(--rozie-data-table-select-accent, var(--rozie-data-table-accent, #0066cc));
75
+ --rdt-border: 1px solid var(--rozie-data-table-border-color, rgba(0, 0, 0, 0.08));
76
+ --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color, rgba(0, 0, 0, 0.2));
77
+ --rdt-filter-radius: var(--rozie-data-table-radius, 4px);
53
78
 
54
79
  /* ── typography / cell metrics ──────────────────────────────────────────── */
55
- --rozie-data-table-font: 14px system-ui, sans-serif;
56
- --rozie-data-table-cell-padding: 0.5rem 0.75rem;
80
+ --rdt-font: var(--rozie-data-table-font, 14px system-ui, sans-serif);
81
+ --rdt-cell-padding: var(--rozie-data-table-cell-padding, 0.5rem 0.75rem);
57
82
 
58
83
  /* ── header ─────────────────────────────────────────────────────────────── */
59
84
  /* OPAQUE (was rgba(0,0,0,0.03)): a translucent header lets the scrolling body bleed
60
85
  through in sticky mode. #f7f7f7 ≈ the old 3%-black tint over white, but solid —
61
86
  matching the opaque header backgrounds the design-system themes already ship. */
62
- --rozie-data-table-header-bg: #f7f7f7;
63
- --rozie-data-table-header-weight: 600;
87
+ --rdt-header-bg: var(--rozie-data-table-header-bg, #f7f7f7);
88
+ --rdt-header-weight: var(--rozie-data-table-header-weight, 600);
64
89
 
65
90
  /* ── sort indicator ─────────────────────────────────────────────────────── */
66
- --rozie-data-table-sort-indicator-opacity: 0.7;
91
+ --rdt-sort-ind-opacity: var(--rozie-data-table-sort-indicator-opacity, 0.7);
67
92
 
68
93
  /* ── resize handle ──────────────────────────────────────────────────────── */
69
- --rozie-data-table-resize-grip-color: rgba(0, 0, 0, 0.12);
70
- --rozie-data-table-resize-grip-active: var(--rozie-data-table-accent);
94
+ /* The grip while dragging defaults to the accent, resolved at the table. */
95
+ --rdt-resize-grip-color: var(--rozie-data-table-resize-grip-color, rgba(0, 0, 0, 0.12));
96
+ --rdt-resize-grip-active: var(--rozie-data-table-resize-grip-active, var(--rozie-data-table-accent, #0066cc));
71
97
 
72
98
  /* ── pin controls ───────────────────────────────────────────────────────── */
73
- --rozie-data-table-pin-active-bg: rgba(0, 0, 0, 0.1);
99
+ --rdt-pin-btn-active-bg: var(--rozie-data-table-pin-active-bg, rgba(0, 0, 0, 0.1));
74
100
 
75
101
  /* ── column-visibility menu ─────────────────────────────────────────────── */
76
- --rozie-data-table-menu-bg: #fff;
77
- --rozie-data-table-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
78
-
79
- /* ── selection checkboxes ───────────────────────────────────────────────── */
80
- --rozie-data-table-select-accent: var(--rozie-data-table-accent);
102
+ --rdt-colvis-menu-bg: var(--rozie-data-table-menu-bg, #fff);
103
+ --rdt-colvis-menu-shadow: var(--rozie-data-table-menu-shadow, 0 2px 8px rgba(0, 0, 0, 0.12));
104
+ --rdt-colvis-menu-border: 1px solid var(--rozie-data-table-border-color, rgba(0, 0, 0, 0.08));
105
+ --rdt-colvis-menu-radius: var(--rozie-data-table-radius, 4px);
106
+ --rdt-colvis-summary-border: 1px solid var(--rozie-data-table-control-border-color, rgba(0, 0, 0, 0.2));
107
+ --rdt-colvis-summary-radius: var(--rozie-data-table-radius, 4px);
81
108
 
82
109
  /* ── disabled pagination controls ──────────────────────────────────────── */
83
- --rozie-data-table-disabled-opacity: 0.4;
84
-
85
- /* ── virtual scroll (row windowing) ─────────────────────────────────────── */
86
- /* Read with NO fallback at its one use site (`max-height: var(--rozie-data-table-max-height);`
87
- on `.rdt-scroll`) — when unset the property is invalid at computed-value
88
- time and the rule behaves as if `max-height` were never declared there,
89
- i.e. `none` (unbounded). Declaring `none` here is the render-neutral
90
- equivalent of that unset behavior, made explicit and overridable. */
91
- --rozie-data-table-max-height: none;
92
-
93
- /* ─────────────────────────────────────────────────────────────────────────
94
- * Wire the PUBLIC tokens onto the INTERNAL `--rdt-*` the component reads. A
95
- * bridge only needs to remap the public tokens above; these passthroughs do
96
- * the rest. (The component still renders zero-config without this file via its
97
- * own inline `var(--rdt-x, <fallback>)` defaults.)
98
- * ───────────────────────────────────────────────────────────────────────── */
99
- --rdt-font: var(--rozie-data-table-font);
100
- --rdt-color: var(--rozie-data-table-fg);
101
- --rdt-cell-padding: var(--rozie-data-table-cell-padding);
102
- --rdt-border: 1px solid var(--rozie-data-table-border-color);
103
- --rdt-header-bg: var(--rozie-data-table-header-bg);
104
- --rdt-header-weight: var(--rozie-data-table-header-weight);
105
- --rdt-sort-ind-opacity: var(--rozie-data-table-sort-indicator-opacity);
106
-
107
- --rdt-filter-border: 1px solid var(--rozie-data-table-control-border-color);
108
- --rdt-filter-radius: var(--rozie-data-table-radius);
109
- --rdt-page-btn-border: 1px solid var(--rozie-data-table-control-border-color);
110
- --rdt-page-btn-radius: var(--rozie-data-table-radius);
111
- --rdt-page-btn-disabled-opacity: var(--rozie-data-table-disabled-opacity);
112
- --rdt-page-size-border: 1px solid var(--rozie-data-table-control-border-color);
113
- --rdt-page-size-radius: var(--rozie-data-table-radius);
114
-
115
- --rdt-resize-grip-color: var(--rozie-data-table-resize-grip-color);
116
- --rdt-resize-grip-active: var(--rozie-data-table-resize-grip-active);
117
-
118
- --rdt-pin-btn-border: 1px solid var(--rozie-data-table-control-border-color);
119
- --rdt-pin-btn-radius: var(--rozie-data-table-radius);
120
- --rdt-pin-btn-active-bg: var(--rozie-data-table-pin-active-bg);
110
+ --rdt-page-btn-disabled-opacity: var(--rozie-data-table-disabled-opacity, 0.4);
111
+ --rdt-page-btn-border: 1px solid var(--rozie-data-table-control-border-color, rgba(0, 0, 0, 0.2));
112
+ --rdt-page-btn-radius: var(--rozie-data-table-radius, 4px);
113
+ --rdt-page-size-border: 1px solid var(--rozie-data-table-control-border-color, rgba(0, 0, 0, 0.2));
114
+ --rdt-page-size-radius: var(--rozie-data-table-radius, 4px);
115
+ }
121
116
 
122
- --rdt-colvis-summary-border: 1px solid var(--rozie-data-table-control-border-color);
123
- --rdt-colvis-summary-radius: var(--rozie-data-table-radius);
124
- --rdt-colvis-menu-border: 1px solid var(--rozie-data-table-border-color);
125
- --rdt-colvis-menu-radius: var(--rozie-data-table-radius);
126
- --rdt-colvis-menu-bg: var(--rozie-data-table-menu-bg);
127
- --rdt-colvis-menu-shadow: var(--rozie-data-table-menu-shadow);
117
+ /* The same defaults, once more, as private `--rdt-ds-*` values on the document root — for the
118
+ Lit leaf only. Inside a shadow root (and a Lit table nested in another component's shadow
119
+ root is out of reach of every document selector, the host element's included) the
120
+ component's own `:host` wiring reads each public token with `var(--rdt-ds-<token>)` as its
121
+ fallback, and only an INHERITED value can get there. A bridge declares the same names at
122
+ `:root`, which is how its skin reaches a Lit table no selector can match. Internal: not a
123
+ stable API. */
124
+ :where(:root) {
125
+ --rdt-ds-fg: currentColor;
126
+ --rdt-ds-select-accent: #0066cc;
127
+ --rdt-ds-border-color: rgba(0, 0, 0, 0.08);
128
+ --rdt-ds-control-border-color: rgba(0, 0, 0, 0.2);
129
+ --rdt-ds-radius: 4px;
130
+ --rdt-ds-font: 14px system-ui, sans-serif;
131
+ --rdt-ds-cell-padding: 0.5rem 0.75rem;
132
+ --rdt-ds-header-bg: #f7f7f7;
133
+ --rdt-ds-header-weight: 600;
134
+ --rdt-ds-sort-indicator-opacity: 0.7;
135
+ --rdt-ds-resize-grip-color: rgba(0, 0, 0, 0.12);
136
+ --rdt-ds-resize-grip-active: #0066cc;
137
+ --rdt-ds-pin-active-bg: rgba(0, 0, 0, 0.1);
138
+ --rdt-ds-menu-bg: #fff;
139
+ --rdt-ds-menu-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
140
+ --rdt-ds-disabled-opacity: 0.4;
141
+ }
128
142
 
129
- --rdt-select-accent: var(--rozie-data-table-select-accent);
143
+ /* ── virtual scroll (row windowing) ─────────────────────────────────────── */
144
+ /* Read with NO fallback at its one use site (`max-height: var(--rozie-data-table-max-height);`
145
+ on `.rdt-scroll`) — when unset the property is invalid at computed-value time and the rule
146
+ behaves as if `max-height` were never declared there, i.e. `none` (unbounded). No bridge
147
+ maps it, so its default is declared, at zero specificity on the document root: `none` is
148
+ the render-neutral equivalent of that unset behavior, made explicit and overridable. */
149
+ :where(:root) {
150
+ --rozie-data-table-max-height: none;
130
151
  }