@rozie-ui/data-table-lit 0.5.0 → 0.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.
package/src/FilterText.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { LitElement, css, html } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
3
  import { SignalWatcher, signal } from '@lit-labs/preact-signals';
4
+ import { rozieAttr } from '@rozie/runtime-lit';
4
5
 
5
6
  @customElement('rozie-filter-text')
6
7
  export default class FilterText extends SignalWatcher(LitElement) {
@@ -24,17 +25,24 @@ export default class FilterText extends SignalWatcher(LitElement) {
24
25
  * `(columnId, value) => void` — apply the column filter (Enter / blur applies, Escape clears). Null-guarded at call sites.
25
26
  */
26
27
  @property({ type: Function }) setFilter: ((...args: any[]) => any) | null = null;
28
+ /**
29
+ * 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.
30
+ */
31
+ @property({ type: String, reflect: true }) columnLabel: string = '';
27
32
  private _draft = signal('');
33
+ private _touched = signal(false);
34
+ private __rozieFirstUpdateDone = false;
28
35
 
29
36
  private _disconnectCleanups: Array<() => void> = [];
30
37
  // Re-parenting guard: set true once the deferred teardown has actually
31
38
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
32
39
  private _rozieTornDown = false;
33
40
 
34
- firstUpdated(): void {
35
- // Seed the draft once at setup from the incoming value (setup-once, NOT in the
36
- // template). Normalize null/undefined to '' so the input value binds to a string.
37
- this._draft.value = this.value != null ? String(this.value) : '';
41
+ updated(changedProperties: Map<string, unknown>): void {
42
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('value'))) { const __watchVal = (() => this.value)(); (() => {
43
+ this._touched.value = false;
44
+ })(); }
45
+ this.__rozieFirstUpdateDone = true;
38
46
  }
39
47
 
40
48
  disconnectedCallback(): void {
@@ -49,23 +57,45 @@ export default class FilterText extends SignalWatcher(LitElement) {
49
57
 
50
58
  render() {
51
59
  return html`
52
- <input class="rdt-col-filter" part="col-filter" type="text" aria-label=${this.columnId} .value=${this._draft.value} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-s-18cbb44e />
60
+ <input class="rdt-col-filter" part="col-filter" type="text" aria-label=${rozieAttr(this.a11yLabel())} .value=${this.draftValue()} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-s-18cbb44e />
53
61
  `;
54
62
  }
55
63
 
64
+ // ── C-08 + N-04: the draft is DERIVED from `$props.value`, with a `touched` latch ────────
65
+ //
66
+ // C-08 — this component mounts once per rendered filter row and is NOT remounted when the
67
+ // filter changes, and its draft was seeded setup-once. So a programmatic reset (a "Clear
68
+ // filters" button, `setFilter(id, '')` from another control, a consumer writing
69
+ // `columnFilters` directly) moved the real filter while this input went on displaying the old
70
+ // text: the UI stated a filter that was no longer applied. `FilterSelect` was never affected —
71
+ // it reads `$props.value` live through `selectValue()`, which is the pattern adopted here.
72
+ //
73
+ // N-04 — the setup-once seed was ALSO wrong on Angular from the start: the emitter places
74
+ // setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
75
+ // DEFAULT, because Angular sets inputs after construction. Measured on the sibling EditorDate,
76
+ // which opened empty on angular for every row while vue and lit seeded correctly. A derived
77
+ // read is correct on all six by construction, with no flash and no per-target branch.
78
+ //
79
+ // `touched` is what stops the live read from overwriting the user mid-type; the watch clears
80
+ // it, so an OUTSIDE change always wins and the input follows it. `@input` never moves
81
+ // `$props.value` (nothing is applied until Enter/blur), so the two can never fight.
82
+ draftValue = () => this._touched.value ? this._draft.value : this.value != null ? String(this.value) : '';
83
+
56
84
  // Untyped handler param neutralizes to `any`, so reading e.target.value typechecks
57
85
  // ×6 (the global-filter idiom). Never inline `$data.x = $event.target.value`.
58
86
  onInput = (e: any) => {
59
87
  this._draft.value = e && e.target ? e.target.value : '';
88
+ this._touched.value = true;
60
89
  };
61
90
 
62
91
  // setFilter is a Function prop (default null) — guard before calling.
63
92
  applyFilter = () => {
64
- this.setFilter && this.setFilter(this.columnId, this._draft.value);
93
+ this.setFilter && this.setFilter(this.columnId, this.draftValue());
65
94
  };
66
95
 
67
96
  clearFilter = () => {
68
97
  this._draft.value = '';
98
+ this._touched.value = false;
69
99
  this.setFilter && this.setFilter(this.columnId, '');
70
100
  };
71
101
 
@@ -82,4 +112,15 @@ export default class FilterText extends SignalWatcher(LitElement) {
82
112
  onBlur = () => {
83
113
  this.applyFilter();
84
114
  };
115
+
116
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
117
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
118
+ // reading an implementation detail aloud. table-core keeps the authored header on
119
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
120
+ // a header rendered by a function has no static text, so fall back to the id rather
121
+ // than invent one.
122
+ a11yLabel = () => {
123
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
124
+ return this.columnId;
125
+ };
85
126
  }
package/src/GroupBar.ts CHANGED
@@ -25,6 +25,18 @@ export default class GroupBar extends SignalWatcher(LitElement) {
25
25
  border-color: var(--rdt-group-drop-zone-border-over, rgba(37, 99, 235, 0.7));
26
26
  background: var(--rdt-group-drop-zone-bg-over, rgba(37, 99, 235, 0.08));
27
27
  }
28
+ .rdt-group-token-add[data-rozie-s-546c469a] {
29
+ font: inherit;
30
+ color: inherit;
31
+ border: none;
32
+ background: none;
33
+ cursor: pointer;
34
+ }
35
+ .rdt-group-token-add[data-rozie-s-546c469a]:focus-visible,
36
+ .rdt-group-drop-zone[data-rozie-s-546c469a] [data-group-token][data-rozie-s-546c469a]:focus-visible {
37
+ outline: var(--rdt-focus-ring, 2px solid #2563eb);
38
+ outline-offset: 1px;
39
+ }
28
40
  .rdt-group-drop-hint[data-rozie-s-546c469a] {
29
41
  opacity: 0.55;
30
42
  font-size: 0.8125em;
@@ -107,12 +119,12 @@ export default class GroupBar extends SignalWatcher(LitElement) {
107
119
  return html`
108
120
  <div class="rdt-group-bar" data-rozie-s-546c469a>
109
121
 
110
- ${repeat<any>(this.groupableColumns, (col, _idx) => col.id, (col, _idx) => html`<span class="rdt-group-token" part="group-token" draggable="true" @dragstart=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onChipDragStart($event, col.id); }} @dragend=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragEnd(); }} data-rozie-s-546c469a>${rozieDisplay(col.label)}</span>`)}
122
+ ${repeat<any>(this.groupableColumns, (col, _idx) => col.id, (col, _idx) => html`<button class="rdt-group-token rdt-group-token-add" type="button" part="group-token" draggable="true" aria-pressed=${rozieAttr(this.grouping.indexOf(col.id) !== -1 ? 'true' : 'false')} aria-label=${rozieAttr('Group by ' + col.label)} @dragstart=${($event: Event & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onChipDragStart($event, col.id); }} @dragend=${($event: Event & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.onDragEnd(); }} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.toggleKey(col.id); }} data-rozie-s-546c469a>${rozieDisplay(col.label)}</button>`)}
111
123
 
112
124
 
113
- <span class="${Object.entries({ "rdt-group-drop-zone": true, 'is-over': this._isOver.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" data-group-drop-zone="" @dragover=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragOver($event); }} @dragleave=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragLeave($event); }} @drop=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDrop($event); }} data-rozie-s-546c469a>
125
+ <span class="${Object.entries({ "rdt-group-drop-zone": true, 'is-over': this._isOver.value }).filter(([, v]) => v).map(([k]) => k).join(' ')}" data-group-drop-zone="" role="list" aria-label="Active grouping" @dragover=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragOver($event); }} @dragleave=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragLeave($event); }} @drop=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDrop($event); }} data-rozie-s-546c469a>
114
126
 
115
- ${!this.grouping.length ? html`<span class="rdt-group-drop-hint" data-rozie-s-546c469a>Drag columns here to group</span>` : nothing}${repeat<any>(this.grouping, (gk, _idx) => gk, (gk, _idx) => html`<span class="${Object.entries({ "rdt-group-token": true, 'is-drop-target': this._dragKind.value === 'token' && this._dropKey.value === gk && this._draggingId.value !== gk }).filter(([, v]) => v).map(([k]) => k).join(' ')}" part="group-token" data-group-token="" draggable="true" @dragstart=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onTokenDragStart($event, gk); }} @dragover=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onTokenDragOver($event, gk); }} @dragend=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragEnd(); }} data-rozie-s-546c469a>
127
+ ${!this.grouping.length ? html`<span class="rdt-group-drop-hint" data-rozie-s-546c469a>Drag columns here to group</span>` : nothing}${repeat<any>(this.grouping, (gk, _idx) => gk, (gk, _idx) => html`<span class="${Object.entries({ "rdt-group-token": true, 'is-drop-target': this._dragKind.value === 'token' && this._dropKey.value === gk && this._draggingId.value !== gk }).filter(([, v]) => v).map(([k]) => k).join(' ')}" part="group-token" data-group-token="" draggable="true" role="listitem" tabindex="0" aria-label=${rozieAttr(this.labelFor(gk) + ' grouping, position ' + (this.grouping.indexOf(gk) + 1) + ' of ' + this.grouping.length + '. Alt+Arrow to reorder, Delete to remove.')} @dragstart=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onTokenDragStart($event, gk); }} @dragover=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onTokenDragOver($event, gk); }} @dragend=${($event: Event & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onDragEnd(); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSpanElement; target: HTMLSpanElement }) => { this.onTokenKeydown($event, gk); }} data-rozie-s-546c469a>
116
128
  ${rozieDisplay(this.labelFor(gk))}
117
129
  <button class="rdt-group-token-remove" type="button" aria-label=${rozieAttr('Remove ' + this.labelFor(gk) + ' grouping')} @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.removeKey(gk); }} data-rozie-s-546c469a>×</button>
118
130
  </span>`)}
@@ -210,6 +222,67 @@ export default class GroupBar extends SignalWatcher(LitElement) {
210
222
  this.applyGrouping && this.applyGrouping(this.grouping.filter((k: any) => k !== key));
211
223
  };
212
224
 
225
+ // ── C-04: the keyboard half of the group bar ────────────────────────────────────────────
226
+ // DECLARED AFTER removeKey ON PURPOSE: toggleKey and onTokenKeydown call it, and the React
227
+ // emitter lowers each to a useCallback whose dependency array is evaluated EAGERLY — declared
228
+ // above removeKey, every React GroupBar died at mount with a TDZ ReferenceError (measured in
229
+ // quick 260922-mkb; the same 87-02 ordering lesson as onEditorDropinKeyDown).
230
+ // Native HTML5 drag-and-drop was the SOLE input path for both adding and reordering, on
231
+ // elements with no tabindex, no role and no click/keydown handler. Remove and Clear were real
232
+ // `<button>`s, so a keyboard user could UNGROUP but could never group or reorder — the bar was
233
+ // a one-way door. HTML5 DnD has no keyboard equivalent by construction, so a parallel,
234
+ // explicitly-keyboard path is the only fix; both halves write through the SAME
235
+ // `applyGrouping` funnel the drop handler uses, so there is one ordering rule, not two.
236
+ //
237
+ // The palette chips become real `<button>`s (Enter/Space for free, focusable for free) rather
238
+ // than spans with `tabindex` + a hand-rolled key handler — the same reasoning that already
239
+ // made Remove and Clear buttons. A `<button draggable="true">` keeps the existing pointer
240
+ // path working unchanged.
241
+ //
242
+ // Reorder is Alt+Arrow on a grouping token, not plain Arrow: the tokens sit in a toolbar that
243
+ // a user also arrows THROUGH, and stealing bare arrows would trap them. Alt is the modifier
244
+ // Windows/macOS list reordering conventionally uses.
245
+ toggleKey = (id: any) => {
246
+ if (!id) return;
247
+ if (this.grouping.indexOf(id) !== -1) {
248
+ this.removeKey(id);
249
+ return;
250
+ }
251
+ this.applyGrouping && this.applyGrouping(this.grouping.concat([id]));
252
+ };
253
+
254
+ // Move a grouping key one position left/right. No-op at the ends (never wraps: a wrap would
255
+ // make a held key cycle forever with no signal that the end was reached).
256
+ moveKey = (key: any, delta: any) => {
257
+ const cur = this.grouping;
258
+ const from = cur.indexOf(key);
259
+ if (from === -1) return;
260
+ const to = from + delta;
261
+ if (to < 0 || to >= cur.length) return;
262
+ const next = cur.slice();
263
+ next.splice(from, 1);
264
+ next.splice(to, 0, key);
265
+ this.applyGrouping && this.applyGrouping(next);
266
+ };
267
+
268
+ onTokenKeydown = (e: any, gk: any) => {
269
+ if (!e) return;
270
+ const key = e.key;
271
+ if (key === 'Delete' || key === 'Backspace') {
272
+ e.preventDefault();
273
+ this.removeKey(gk);
274
+ return;
275
+ }
276
+ if (!e.altKey) return;
277
+ if (key === 'ArrowLeft') {
278
+ e.preventDefault();
279
+ this.moveKey(gk, -1);
280
+ } else if (key === 'ArrowRight') {
281
+ e.preventDefault();
282
+ this.moveKey(gk, 1);
283
+ }
284
+ };
285
+
213
286
  clearAll = () => {
214
287
  this.clearGrouping && this.clearGrouping();
215
288
  };
@@ -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
  }