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