@rozie-ui/data-table-lit 0.4.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.
@@ -1,6 +1,7 @@
1
1
  import { LitElement, css, html } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
2
+ import { customElement, property, query } from 'lit/decorators.js';
3
3
  import { SignalWatcher } from '@lit-labs/preact-signals';
4
+ import { rozieAttr } from '@rozie/runtime-lit';
4
5
 
5
6
  @customElement('rozie-editor-checkbox')
6
7
  export default class EditorCheckbox extends SignalWatcher(LitElement) {
@@ -32,12 +33,33 @@ export default class EditorCheckbox extends SignalWatcher(LitElement) {
32
33
  * `() => void` — revert the edit (Escape). Null-guarded at call sites.
33
34
  */
34
35
  @property({ type: Function }) cancel: ((...args: any[]) => any) | null = null;
36
+ /**
37
+ * Focus this editor's primary control when true — the host sets it for the one editor that should hold focus; reactive.
38
+ */
39
+ @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
40
+ /**
41
+ * The column's human header, forwarded by the slot scope — used as the control's accessible name in place of the internal column id.
42
+ */
43
+ @property({ type: String, reflect: true }) columnLabel: string = '';
44
+ @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
45
+ private __rozieFirstUpdateDone = false;
35
46
 
36
47
  private _disconnectCleanups: Array<() => void> = [];
37
48
  // Re-parenting guard: set true once the deferred teardown has actually
38
49
  // run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
39
50
  private _rozieTornDown = false;
40
51
 
52
+ firstUpdated(): void {
53
+ if (this.autofocus) this._refInputEl?.focus();
54
+ }
55
+
56
+ updated(changedProperties: Map<string, unknown>): void {
57
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('autofocus'))) { const __watchVal = (() => this.autofocus)(); ((v: any) => {
58
+ if (v) this._refInputEl?.focus();
59
+ })(__watchVal); }
60
+ this.__rozieFirstUpdateDone = true;
61
+ }
62
+
41
63
  disconnectedCallback(): void {
42
64
  super.disconnectedCallback();
43
65
  queueMicrotask(() => {
@@ -50,7 +72,7 @@ export default class EditorCheckbox extends SignalWatcher(LitElement) {
50
72
 
51
73
  render() {
52
74
  return html`
53
- <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" aria-label=${this.columnId} ?checked=${!!this.value} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} data-rozie-s-3d792482 />
75
+ <input class="rdt-cell-editor" type="checkbox" data-editing-cell="" aria-label=${rozieAttr(this.a11yLabel())} ?checked=${!!this.value} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} data-rozie-ref="inputEl" data-rozie-s-3d792482 />
54
76
  `;
55
77
  }
56
78
 
@@ -66,4 +88,15 @@ export default class EditorCheckbox extends SignalWatcher(LitElement) {
66
88
  this.cancel && this.cancel();
67
89
  }
68
90
  };
91
+
92
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
93
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
94
+ // reading an implementation detail aloud. table-core keeps the authored header on
95
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
96
+ // a header rendered by a function has no static text, so fall back to the id rather
97
+ // than invent one.
98
+ a11yLabel = () => {
99
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
100
+ return this.columnId;
101
+ };
69
102
  }
package/src/EditorDate.ts CHANGED
@@ -1,6 +1,28 @@
1
1
  import { LitElement, css, html } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
2
+ import { customElement, property, query } from 'lit/decorators.js';
3
3
  import { SignalWatcher, signal } from '@lit-labs/preact-signals';
4
+ import { rozieAttr } from '@rozie/runtime-lit';
5
+ // C-07: the native `<input type="date">` accepts ONLY `YYYY-MM-DD` and renders BLANK for
6
+ // anything else, silently. The seed used to be `String($props.value)`, which is not the ISO
7
+ // coercion this component's own docs: string promises — so a `Date`, an ISO datetime string,
8
+ // an epoch number or a localised string all produced an empty editor. `toIsoDateString`
9
+ // (helpers/dateValue.ts) is the real coercion, pure and unit-tested.
10
+ import { toIsoDateString } from './helpers/dateValue';
11
+
12
+ // ── N-04: the draft is DERIVED, not seeded setup-once ───────────────────────────────────
13
+ // A top-level `$data.draft = <read of $props.x>` is setup-once, and on ANGULAR the emitter
14
+ // places setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
15
+ // DEFAULT — Angular sets inputs after construction. Measured directly: this editor opened
16
+ // EMPTY on angular for every row, including one whose value was already `YYYY-MM-DD`, while
17
+ // vue and lit seeded correctly. That is why the shape of the incoming value (C-07) could not
18
+ // be the whole story, and why the existing EditorDate coverage never saw either problem: it
19
+ // fills the input before asserting anything.
20
+ //
21
+ // Reading the prop through a derived function instead is correct on all six by construction,
22
+ // with no flash of an empty control on the fine-grained targets and no per-target branch. It
23
+ // is the pattern `FilterSelect` in this same package already uses (`selectValue()`), which is
24
+ // exactly why FilterSelect was the one drop-in unaffected. `touched` is what keeps the user's
25
+ // typing from being overwritten by the live prop read once they have started editing.
4
26
 
5
27
  @customElement('rozie-editor-date')
6
28
  export default class EditorDate extends SignalWatcher(LitElement) {
@@ -32,7 +54,18 @@ export default class EditorDate extends SignalWatcher(LitElement) {
32
54
  * `() => void` — revert the edit (Escape). Null-guarded at call sites.
33
55
  */
34
56
  @property({ type: Function }) cancel: ((...args: any[]) => any) | null = null;
57
+ /**
58
+ * Focus this editor's primary control when true — the host sets it for the one editor that should hold focus; reactive.
59
+ */
60
+ @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
61
+ /**
62
+ * The column's human header, forwarded by the slot scope — used as the control's accessible name in place of the internal column id.
63
+ */
64
+ @property({ type: String, reflect: true }) columnLabel: string = '';
35
65
  private _draft = signal('');
66
+ private _touched = signal(false);
67
+ @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
68
+ private __rozieFirstUpdateDone = false;
36
69
 
37
70
  private _disconnectCleanups: Array<() => void> = [];
38
71
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -40,9 +73,14 @@ export default class EditorDate extends SignalWatcher(LitElement) {
40
73
  private _rozieTornDown = false;
41
74
 
42
75
  firstUpdated(): void {
43
- // Seed the draft once from the incoming value (setup-once). A native date input
44
- // only accepts `YYYY-MM-DD`; normalize null/undefined to ''.
45
- this._draft.value = this.value != null ? String(this.value) : '';
76
+ if (this.autofocus) this._refInputEl?.focus();
77
+ }
78
+
79
+ updated(changedProperties: Map<string, unknown>): void {
80
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('autofocus'))) { const __watchVal = (() => this.autofocus)(); ((v: any) => {
81
+ if (v) this._refInputEl?.focus();
82
+ })(__watchVal); }
83
+ this.__rozieFirstUpdateDone = true;
46
84
  }
47
85
 
48
86
  disconnectedCallback(): void {
@@ -57,17 +95,20 @@ export default class EditorDate extends SignalWatcher(LitElement) {
57
95
 
58
96
  render() {
59
97
  return html`
60
- <input class="rdt-cell-editor" type="date" data-editing-cell="" aria-label=${this.columnId} .value=${this._draft.value} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-s-7abe1a56 />
98
+ <input class="rdt-cell-editor" type="date" data-editing-cell="" aria-label=${rozieAttr(this.a11yLabel())} .value=${this.draftValue()} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onInput($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-ref="inputEl" data-rozie-s-7abe1a56 />
61
99
  `;
62
100
  }
63
101
 
102
+ draftValue = () => this._touched.value ? this._draft.value : toIsoDateString(this.value);
103
+
64
104
  onInput = (e: any) => {
65
105
  this._draft.value = e && e.target ? e.target.value : '';
106
+ this._touched.value = true;
66
107
  };
67
108
 
68
109
  doCommit = () => {
69
110
  // commit the ISO date string the native control already produced.
70
- this.commit && this.commit(this._draft.value);
111
+ this.commit && this.commit(this.draftValue());
71
112
  };
72
113
 
73
114
  doCancel = () => {
@@ -76,6 +117,7 @@ export default class EditorDate extends SignalWatcher(LitElement) {
76
117
 
77
118
  onChange = (e: any) => {
78
119
  this._draft.value = e && e.target ? e.target.value : '';
120
+ this._touched.value = true;
79
121
  };
80
122
 
81
123
  onKeydown = (e: any) => {
@@ -91,4 +133,15 @@ export default class EditorDate extends SignalWatcher(LitElement) {
91
133
  onBlur = () => {
92
134
  this.doCommit();
93
135
  };
136
+
137
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
138
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
139
+ // reading an implementation detail aloud. table-core keeps the authored header on
140
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
141
+ // a header rendered by a function has no static text, so fall back to the id rather
142
+ // than invent one.
143
+ a11yLabel = () => {
144
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
145
+ return this.columnId;
146
+ };
94
147
  }
@@ -1,6 +1,7 @@
1
1
  import { LitElement, css, html } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
2
+ import { customElement, property, query } 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-editor-number')
6
7
  export default class EditorNumber extends SignalWatcher(LitElement) {
@@ -32,7 +33,18 @@ export default class EditorNumber extends SignalWatcher(LitElement) {
32
33
  * `() => void` — revert the edit (Escape). Null-guarded at call sites.
33
34
  */
34
35
  @property({ type: Function }) cancel: ((...args: any[]) => any) | null = null;
36
+ /**
37
+ * Focus this editor's primary control when true — the host sets it for the one editor that should hold focus; reactive.
38
+ */
39
+ @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
40
+ /**
41
+ * The column's human header, forwarded by the slot scope — used as the control's accessible name in place of the internal column id.
42
+ */
43
+ @property({ type: String, reflect: true }) columnLabel: string = '';
35
44
  private _draft = signal('');
45
+ private _touched = signal(false);
46
+ @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
47
+ private __rozieFirstUpdateDone = false;
36
48
 
37
49
  private _disconnectCleanups: Array<() => void> = [];
38
50
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -40,8 +52,14 @@ export default class EditorNumber extends SignalWatcher(LitElement) {
40
52
  private _rozieTornDown = false;
41
53
 
42
54
  firstUpdated(): void {
43
- // Seed the draft string once from the incoming value (setup-once).
44
- this._draft.value = this.value != null ? String(this.value) : '';
55
+ if (this.autofocus) this._refInputEl?.focus();
56
+ }
57
+
58
+ updated(changedProperties: Map<string, unknown>): void {
59
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('autofocus'))) { const __watchVal = (() => this.autofocus)(); ((v: any) => {
60
+ if (v) this._refInputEl?.focus();
61
+ })(__watchVal); }
62
+ this.__rozieFirstUpdateDone = true;
45
63
  }
46
64
 
47
65
  disconnectedCallback(): void {
@@ -56,12 +74,25 @@ export default class EditorNumber extends SignalWatcher(LitElement) {
56
74
 
57
75
  render() {
58
76
  return html`
59
- <input class="rdt-cell-editor" type="number" data-editing-cell="" 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-b2792b32 />
77
+ <input class="rdt-cell-editor" type="number" data-editing-cell="" 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-ref="inputEl" data-rozie-s-b2792b32 />
60
78
  `;
61
79
  }
62
80
 
81
+ // Seed the draft string once from the incoming value (setup-once).
82
+ // ── N-04: the draft is DERIVED, not seeded setup-once ───────────────────────────────────
83
+ // A top-level `$data.draft = <read of $props.x>` is setup-once, and on ANGULAR the emitter
84
+ // places setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
85
+ // DEFAULT — Angular sets inputs after construction. Measured on the sibling EditorDate: it
86
+ // opened EMPTY on angular for every row while vue and lit seeded correctly. Reading the prop
87
+ // through a derived function is correct on all six by construction, with no flash of an empty
88
+ // control on the fine-grained targets and no per-target branch — the pattern `FilterSelect`
89
+ // already uses (`selectValue()`), which is why FilterSelect was the one drop-in unaffected.
90
+ // `touched` keeps the live prop read from overwriting the user once they start typing.
91
+ draftValue = () => this._touched.value ? this._draft.value : this.value != null ? String(this.value) : '';
92
+
63
93
  onInput = (e: any) => {
64
94
  this._draft.value = e && e.target ? e.target.value : '';
95
+ this._touched.value = true;
65
96
  };
66
97
 
67
98
  // Coerce to a Number at commit time. Defensive guard: an empty/whitespace draft
@@ -69,7 +100,7 @@ export default class EditorNumber extends SignalWatcher(LitElement) {
69
100
  // non-numeric draft also commits null. Otherwise commit the coerced number.
70
101
  doCommit = () => {
71
102
  if (!this.commit) return;
72
- const raw = this._draft.value;
103
+ const raw = this.draftValue();
73
104
  if (raw == null || String(raw).trim() === '') {
74
105
  this.commit(null);
75
106
  return;
@@ -95,4 +126,15 @@ export default class EditorNumber extends SignalWatcher(LitElement) {
95
126
  onBlur = () => {
96
127
  this.doCommit();
97
128
  };
129
+
130
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
131
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
132
+ // reading an implementation detail aloud. table-core keeps the authored header on
133
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
134
+ // a header rendered by a function has no static text, so fall back to the id rather
135
+ // than invent one.
136
+ a11yLabel = () => {
137
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
138
+ return this.columnId;
139
+ };
98
140
  }
@@ -1,5 +1,5 @@
1
1
  import { LitElement, css, html } from 'lit';
2
- import { customElement, property } from 'lit/decorators.js';
2
+ import { customElement, property, query } from 'lit/decorators.js';
3
3
  import { SignalWatcher, signal } from '@lit-labs/preact-signals';
4
4
  import { rozieAttr, rozieDisplay } from '@rozie/runtime-lit';
5
5
  import { repeat } from 'lit/directives/repeat.js';
@@ -38,7 +38,18 @@ export default class EditorSelect extends SignalWatcher(LitElement) {
38
38
  * The select options — `[{ value, label }]`. Mirrors `<Column editorOptions>`.
39
39
  */
40
40
  @property({ type: Array }) options: any[] = [];
41
+ /**
42
+ * Focus this editor's primary control when true — the host sets it for the one editor that should hold focus; reactive.
43
+ */
44
+ @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
45
+ /**
46
+ * The column's human header, forwarded by the slot scope — used as the control's accessible name in place of the internal column id.
47
+ */
48
+ @property({ type: String, reflect: true }) columnLabel: string = '';
41
49
  private _draft = signal('');
50
+ private _touched = signal(false);
51
+ @query('[data-rozie-ref="selectEl"]') private _refSelectEl!: HTMLElement;
52
+ private __rozieFirstUpdateDone = false;
42
53
 
43
54
  private _disconnectCleanups: Array<() => void> = [];
44
55
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -46,9 +57,14 @@ export default class EditorSelect extends SignalWatcher(LitElement) {
46
57
  private _rozieTornDown = false;
47
58
 
48
59
  firstUpdated(): void {
49
- // Seed the draft once from the incoming value (setup-once). Normalize null/undefined
50
- // to '' so the <select> binds to a string.
51
- this._draft.value = this.value != null ? String(this.value) : '';
60
+ if (this.autofocus) this._refSelectEl?.focus();
61
+ }
62
+
63
+ updated(changedProperties: Map<string, unknown>): void {
64
+ if (this.__rozieFirstUpdateDone && (changedProperties.has('autofocus'))) { const __watchVal = (() => this.autofocus)(); ((v: any) => {
65
+ if (v) this._refSelectEl?.focus();
66
+ })(__watchVal); }
67
+ this.__rozieFirstUpdateDone = true;
52
68
  }
53
69
 
54
70
  disconnectedCallback(): void {
@@ -63,20 +79,34 @@ export default class EditorSelect extends SignalWatcher(LitElement) {
63
79
 
64
80
  render() {
65
81
  return html`
66
- <select class="rdt-cell-editor" data-editing-cell="" aria-label=${this.columnId} .value=${this._draft.value} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onBlur(); }} data-rozie-s-117f1a16>
67
- ${repeat<any>(this.options, (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} data-rozie-s-117f1a16>${rozieDisplay(opt.label)}</option>`)}
82
+ <select class="rdt-cell-editor" data-editing-cell="" aria-label=${rozieAttr(this.a11yLabel())} .value=${this.draftValue()} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onChange($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onBlur(); }} data-rozie-ref="selectEl" data-rozie-s-117f1a16>
83
+ ${repeat<any>(this.options, (opt, _idx) => opt.value, (opt, _idx) => html`<option value=${rozieAttr(opt.value)} ?selected=${opt.value === this.draftValue()} data-rozie-s-117f1a16>${rozieDisplay(opt.label)}</option>`)}
68
84
  </select>
69
85
  `;
70
86
  }
71
87
 
88
+ // Seed the draft once from the incoming value (setup-once). Normalize null/undefined
89
+ // to '' so the <select> binds to a string.
90
+ // ── N-04: the draft is DERIVED, not seeded setup-once ───────────────────────────────────
91
+ // A top-level `$data.draft = <read of $props.x>` is setup-once, and on ANGULAR the emitter
92
+ // places setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
93
+ // DEFAULT — Angular sets inputs after construction. Measured on the sibling EditorDate: it
94
+ // opened EMPTY on angular for every row while vue and lit seeded correctly. Reading the prop
95
+ // through a derived function is correct on all six by construction, with no flash of an empty
96
+ // control on the fine-grained targets and no per-target branch — the pattern `FilterSelect`
97
+ // already uses (`selectValue()`), which is why FilterSelect was the one drop-in unaffected.
98
+ // `touched` keeps the live prop read from overwriting the user once they start typing.
99
+ draftValue = () => this._touched.value ? this._draft.value : this.value != null ? String(this.value) : '';
100
+
72
101
  // Picking/arrow-cycling an option updates the draft only — no commit.
73
102
  onChange = (e: any) => {
74
103
  this._draft.value = e && e.target ? e.target.value : '';
104
+ this._touched.value = true;
75
105
  };
76
106
 
77
107
  // commit/cancel are Function props (default null) — guard before calling.
78
108
  doCommit = () => {
79
- this.commit && this.commit(this._draft.value);
109
+ this.commit && this.commit(this.draftValue());
80
110
  };
81
111
 
82
112
  doCancel = () => {
@@ -96,4 +126,15 @@ export default class EditorSelect extends SignalWatcher(LitElement) {
96
126
  onBlur = () => {
97
127
  this.doCommit();
98
128
  };
129
+
130
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
131
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
132
+ // reading an implementation detail aloud. table-core keeps the authored header on
133
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
134
+ // a header rendered by a function has no static text, so fall back to the id rather
135
+ // than invent one.
136
+ a11yLabel = () => {
137
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
138
+ return this.columnId;
139
+ };
99
140
  }
package/src/EditorText.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { LitElement, css, html } from 'lit';
2
2
  import { customElement, property, query } 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-editor-text')
6
7
  export default class EditorText extends SignalWatcher(LitElement) {
@@ -36,7 +37,12 @@ export default class EditorText extends SignalWatcher(LitElement) {
36
37
  * Focus this editor's primary input when true — the host sets it for the one editor that should hold focus; reactive.
37
38
  */
38
39
  @property({ type: Boolean, reflect: true }) autofocus: boolean = false;
40
+ /**
41
+ * The column's human header, forwarded by the slot scope — used as the control's accessible name in place of the internal column id.
42
+ */
43
+ @property({ type: String, reflect: true }) columnLabel: string = '';
39
44
  private _draft = signal('');
45
+ private _touched = signal(false);
40
46
  @query('[data-rozie-ref="inputEl"]') private _refInputEl!: HTMLElement;
41
47
  private __rozieFirstUpdateDone = false;
42
48
 
@@ -46,10 +52,6 @@ private __rozieFirstUpdateDone = false;
46
52
  private _rozieTornDown = false;
47
53
 
48
54
  firstUpdated(): void {
49
- // Seed the draft once at setup from the incoming value (setup-once, NOT in the
50
- // template). Normalize null/undefined to '' so the input value binds to a string.
51
- this._draft.value = this.value != null ? String(this.value) : '';
52
-
53
55
  if (this.autofocus) this._refInputEl?.focus();
54
56
  }
55
57
 
@@ -72,19 +74,33 @@ private __rozieFirstUpdateDone = false;
72
74
 
73
75
  render() {
74
76
  return html`
75
- <input class="rdt-cell-editor" type="text" data-editing-cell="" 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-ref="inputEl" data-rozie-s-0d17f43a />
77
+ <input class="rdt-cell-editor" type="text" data-editing-cell="" 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-ref="inputEl" data-rozie-s-0d17f43a />
76
78
  `;
77
79
  }
78
80
 
81
+ // Seed the draft once at setup from the incoming value (setup-once, NOT in the
82
+ // template). Normalize null/undefined to '' so the input value binds to a string.
83
+ // ── N-04: the draft is DERIVED, not seeded setup-once ───────────────────────────────────
84
+ // A top-level `$data.draft = <read of $props.x>` is setup-once, and on ANGULAR the emitter
85
+ // places setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
86
+ // DEFAULT — Angular sets inputs after construction. Measured on the sibling EditorDate: it
87
+ // opened EMPTY on angular for every row while vue and lit seeded correctly. Reading the prop
88
+ // through a derived function is correct on all six by construction, with no flash of an empty
89
+ // control on the fine-grained targets and no per-target branch — the pattern `FilterSelect`
90
+ // already uses (`selectValue()`), which is why FilterSelect was the one drop-in unaffected.
91
+ // `touched` keeps the live prop read from overwriting the user once they start typing.
92
+ draftValue = () => this._touched.value ? this._draft.value : this.value != null ? String(this.value) : '';
93
+
79
94
  // Untyped handler param neutralizes to `any`, so reading e.target.value typechecks
80
95
  // ×6 (the global-filter idiom). Never inline `$data.x = $event.target.value`.
81
96
  onInput = (e: any) => {
82
97
  this._draft.value = e && e.target ? e.target.value : '';
98
+ this._touched.value = true;
83
99
  };
84
100
 
85
101
  // commit/cancel are Function props (default null) — guard before calling.
86
102
  doCommit = () => {
87
- this.commit && this.commit(this._draft.value);
103
+ this.commit && this.commit(this.draftValue());
88
104
  };
89
105
 
90
106
  doCancel = () => {
@@ -104,4 +120,20 @@ private __rozieFirstUpdateDone = false;
104
120
  onBlur = () => {
105
121
  this.doCommit();
106
122
  };
123
+
124
+ // Editor-owns-focus contract: focus OUR OWN input when the host says we should hold focus.
125
+ // $onMount covers the initial open (autofocus already true on first render); the LAZY $watch
126
+ // (NOT { immediate: true } — an immediate watch fires PRE-mount, null ref on Lit/Solid) covers
127
+ // a REACTIVE refocus while already mounted (e.g. a row-mode validation failure that flips
128
+ // autofocus back onto this already-open drop-in).
129
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
130
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
131
+ // reading an implementation detail aloud. table-core keeps the authored header on
132
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
133
+ // a header rendered by a function has no static text, so fall back to the id rather
134
+ // than invent one.
135
+ a11yLabel = () => {
136
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
137
+ return this.columnId;
138
+ };
107
139
  }
@@ -1,6 +1,6 @@
1
1
  import { LitElement, css, html } from 'lit';
2
2
  import { customElement, property } from 'lit/decorators.js';
3
- import { SignalWatcher, signal } from '@lit-labs/preact-signals';
3
+ import { SignalWatcher, effect, signal, untracked } from '@lit-labs/preact-signals';
4
4
  import { rozieAttr } from '@rozie/runtime-lit';
5
5
 
6
6
  @customElement('rozie-filter-number-range')
@@ -29,8 +29,14 @@ export default class FilterNumberRange extends SignalWatcher(LitElement) {
29
29
  * The faceted `[min, max]` bounds for this column (`[number, number]` or null) — drives the input placeholders only.
30
30
  */
31
31
  @property({ type: Object }) minMax: unknown = null;
32
+ /**
33
+ * 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.
34
+ */
35
+ @property({ type: String, reflect: true }) columnLabel: string = '';
32
36
  private _minDraft = signal('');
33
37
  private _maxDraft = signal('');
38
+ private _touched = signal(false);
39
+ private __rozieWatchInitial_0 = true;
34
40
 
35
41
  private _disconnectCleanups: Array<() => void> = [];
36
42
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -38,9 +44,9 @@ export default class FilterNumberRange extends SignalWatcher(LitElement) {
38
44
  private _rozieTornDown = false;
39
45
 
40
46
  firstUpdated(): void {
41
- // Seed both drafts once at setup from the incoming [min,max] tuple (setup-once).
42
- this._minDraft.value = Array.isArray(this.value) && this.value[0] != null ? String(this.value[0]) : '';
43
- this._maxDraft.value = Array.isArray(this.value) && this.value[1] != null ? String(this.value[1]) : '';
47
+ this._disconnectCleanups.push(effect(() => { const __watchVal = (() => Array.isArray(this.value) ? String(this.value[0]) + '\u0000' + String(this.value[1]) : '')(); untracked(() => { if (this.__rozieWatchInitial_0) { this.__rozieWatchInitial_0 = false; return; } (() => {
48
+ this._touched.value = false;
49
+ })(); }); }));
44
50
  }
45
51
 
46
52
  disconnectedCallback(): void {
@@ -56,20 +62,68 @@ export default class FilterNumberRange extends SignalWatcher(LitElement) {
56
62
  render() {
57
63
  return html`
58
64
  <span style="display:flex; align-items: center" data-rozie-s-97b2c090>
59
- <input class="rdt-col-filter" part="col-filter" type="number" aria-label=${rozieAttr(this.columnId + ' min')} placeholder=${rozieAttr(this.minPlaceholder())} .value=${this._minDraft.value} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onMinInput($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.applyRange(); }} data-rozie-s-97b2c090 />
65
+ <input class="rdt-col-filter" part="col-filter" type="number" aria-label=${rozieAttr(this.a11yLabel() + ' min')} placeholder=${rozieAttr(this.minPlaceholder())} .value=${this.minDraftValue()} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onMinInput($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-s-97b2c090 />
60
66
  <span data-rozie-s-97b2c090> - </span>
61
- <input class="rdt-col-filter" part="col-filter" type="number" aria-label=${rozieAttr(this.columnId + ' max')} placeholder=${rozieAttr(this.maxPlaceholder())} .value=${this._maxDraft.value} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onMaxInput($event); }} @change=${($event: Event & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.applyRange(); }} data-rozie-s-97b2c090 />
67
+ <input class="rdt-col-filter" part="col-filter" type="number" aria-label=${rozieAttr(this.a11yLabel() + ' max')} placeholder=${rozieAttr(this.maxPlaceholder())} .value=${this.maxDraftValue()} @input=${($event: InputEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onMaxInput($event); }} @keydown=${($event: KeyboardEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onKeydown($event); }} @blur=${($event: FocusEvent & { currentTarget: HTMLInputElement; target: HTMLInputElement }) => { this.onBlur(); }} data-rozie-s-97b2c090 />
62
68
  </span>
63
69
  `;
64
70
  }
65
71
 
72
+ // ── C-08 + N-04: both drafts are DERIVED from `$props.value`, with a `touched` latch ─────
73
+ //
74
+ // C-08 — this component mounts once per rendered filter row and is NOT remounted when the
75
+ // filter changes, and both drafts were seeded setup-once. A programmatic reset or an external
76
+ // `columnFilters` write moved the real filter while these two inputs went on displaying the
77
+ // old numbers. See FilterText for the full reasoning; `FilterSelect` was never affected
78
+ // because it reads `$props.value` live, which is the pattern adopted here.
79
+ //
80
+ // N-04 — the setup-once seed was ALSO wrong on Angular from the start: the emitter places
81
+ // setup-once statements in the CONSTRUCTOR, where an `input()` signal still returns its
82
+ // DEFAULT. Measured on the sibling EditorDate.
83
+ //
84
+ // The watch clears `touched` rather than writing the drafts, so the derived readers pick the
85
+ // new value up on their own. It is keyed on the TUPLE ELEMENTS, not the array identity: the
86
+ // funnel writes a fresh `[min, max]` array for every apply, so an identity key would fire on
87
+ // every apply, and a consumer mutating a tuple in place would be missed by it anyway. The key
88
+ // is a joined STRING rather than an array — an array-returning watch getter is compared by
89
+ // identity on Lit, so a fresh array every read means it can never report "unchanged" (measured:
90
+ // the range half of the C-08 case stayed red on lit alone with an array key).
91
+ minDraftValue = () => this._touched.value ? this._minDraft.value : Array.isArray(this.value) && this.value[0] != null ? String(this.value[0]) : '';
92
+
93
+ maxDraftValue = () => this._touched.value ? this._maxDraft.value : Array.isArray(this.value) && this.value[1] != null ? String(this.value[1]) : '';
94
+
66
95
  // Untyped handler params neutralize to `any` (the global-filter idiom).
96
+ // C-01 — the range used to commit from `@change`, which is NOT the same event across
97
+ // targets for a text/number input: React maps `onChange` onto the native `input`
98
+ // event, so on React it fired on EVERY keystroke, in the same tick as the `@input`
99
+ // handler's `$data` write. `$data` is an async `useState` write there, so the commit
100
+ // re-read the PREVIOUS keystroke's draft and the filter ran one keystroke behind (the
101
+ // first keystroke cleared it). The other five use the real `change` event — blur or
102
+ // Enter — and were already correct.
103
+ //
104
+ // Fixed by committing on the events that mean the same thing everywhere, Enter and
105
+ // blur, which is also the contract FilterText already uses; and by making applyRange
106
+ // take both drafts as arguments so it can never re-read a value written in its own
107
+ // tick. That combination is target-independent rather than a React special case.
67
108
  onMinInput = (e: any) => {
68
109
  this._minDraft.value = e && e.target ? e.target.value : '';
110
+ this._touched.value = true;
69
111
  };
70
112
 
71
113
  onMaxInput = (e: any) => {
72
114
  this._maxDraft.value = e && e.target ? e.target.value : '';
115
+ this._touched.value = true;
116
+ };
117
+
118
+ // Commit the range on Enter or blur — the same commit-on-commit contract FilterText
119
+ // uses, NOT per keystroke. Both read the drafts in a tick where nothing has just
120
+ // written them, so every target reads the settled value.
121
+ onKeydown = (e: any) => {
122
+ if (e && e.key === 'Enter') this.applyRange(this.minDraftValue(), this.maxDraftValue());
123
+ };
124
+
125
+ onBlur = () => {
126
+ this.applyRange(this.minDraftValue(), this.maxDraftValue());
73
127
  };
74
128
 
75
129
  // Plain string-coercion functions for the placeholders (NOT $computed — the
@@ -80,13 +134,27 @@ export default class FilterNumberRange extends SignalWatcher(LitElement) {
80
134
 
81
135
  // Convert a draft to a Number or undefined (empty string → undefined so a
82
136
  // one-sided range works). Both undefined → clear the filter.
83
- applyRange = () => {
84
- const minNum = this._minDraft.value === '' ? undefined : Number(this._minDraft.value);
85
- const maxNum = this._maxDraft.value === '' ? undefined : Number(this._maxDraft.value);
137
+ // Takes both drafts as ARGUMENTS — never re-reads $data — so it is correct on all six
138
+ // targets regardless of when in the tick it runs. Callers that have just written a
139
+ // draft pass the value they wrote.
140
+ applyRange = (minDraft: any, maxDraft: any) => {
141
+ const minNum = minDraft === '' ? undefined : Number(minDraft);
142
+ const maxNum = maxDraft === '' ? undefined : Number(maxDraft);
86
143
  if (minNum === undefined && maxNum === undefined) {
87
144
  this.setFilter && this.setFilter(this.columnId, '');
88
145
  } else {
89
146
  this.setFilter && this.setFilter(this.columnId, [minNum, maxNum]);
90
147
  }
91
148
  };
149
+
150
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
151
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
152
+ // reading an implementation detail aloud. table-core keeps the authored header on
153
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
154
+ // a header rendered by a function has no static text, so fall back to the id rather
155
+ // than invent one.
156
+ a11yLabel = () => {
157
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
158
+ return this.columnId;
159
+ };
92
160
  }
@@ -30,6 +30,10 @@ export default class FilterSelect extends SignalWatcher(LitElement) {
30
30
  * The faceted distinct keys for this column (cross-filtered, keys only — no occurrence counts) used to build the `<option>` list.
31
31
  */
32
32
  @property({ type: Array }) uniqueValues: any[] = [];
33
+ /**
34
+ * 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.
35
+ */
36
+ @property({ type: String, reflect: true }) columnLabel: string = '';
33
37
 
34
38
  private _disconnectCleanups: Array<() => void> = [];
35
39
  // Re-parenting guard: set true once the deferred teardown has actually
@@ -48,9 +52,9 @@ export default class FilterSelect extends SignalWatcher(LitElement) {
48
52
 
49
53
  render() {
50
54
  return html`
51
- <select class="rdt-col-filter" part="col-filter" aria-label=${this.columnId} .value=${this.selectValue()} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onChange($event); }} data-rozie-s-d75b42b2>
55
+ <select class="rdt-col-filter" part="col-filter" aria-label=${rozieAttr(this.a11yLabel())} .value=${this.selectValue()} @change=${($event: Event & { currentTarget: HTMLSelectElement; target: HTMLSelectElement }) => { this.onChange($event); }} data-rozie-s-d75b42b2>
52
56
  <option value="" data-rozie-s-d75b42b2>All</option>
53
- ${repeat<any>(this.uniqueValues, (opt, _idx) => opt, (opt, _idx) => html`<option value=${rozieAttr(opt)} data-rozie-s-d75b42b2>${rozieDisplay(opt)}</option>`)}
57
+ ${repeat<any>(this.uniqueValues, (opt, _idx) => opt, (opt, _idx) => html`<option value=${rozieAttr(opt)} ?selected=${opt === this.selectValue()} data-rozie-s-d75b42b2>${rozieDisplay(opt)}</option>`)}
54
58
  </select>
55
59
  `;
56
60
  }
@@ -71,4 +75,15 @@ export default class FilterSelect extends SignalWatcher(LitElement) {
71
75
  this.setFilter && this.setFilter(this.columnId, v);
72
76
  }
73
77
  };
78
+
79
+ // C-11 — the accessible name must be the column's HUMAN header, not its internal id.
80
+ // `columnId` is a lookup key (`unit_price`, `col_3`); a screen reader announcing it is
81
+ // reading an implementation detail aloud. table-core keeps the authored header on
82
+ // `column.columnDef.header`, which is a string for every declarative `<Column header>`;
83
+ // a header rendered by a function has no static text, so fall back to the id rather
84
+ // than invent one.
85
+ a11yLabel = () => {
86
+ if (typeof this.columnLabel === 'string' && this.columnLabel !== '') return this.columnLabel;
87
+ return this.columnId;
88
+ };
74
89
  }