@nxgt/material-core 0.10.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -1,5 +1,69 @@
1
1
  # @nxgt/material-core
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#574](https://github.com/softistx/nxgt-material/pull/574) [`c00f8c3`](https://github.com/softistx/nxgt-material/commit/c00f8c366e35b7769560bb86b964ae0705bcc60f) Thanks [@SteveGT96](https://github.com/SteveGT96)! - Add `RepeatableGroup` and `RepeatableGroupFormField` (Storybook
8
+ `Form fields/RepeatableGroup`): rows of fields a user adds, removes and, with
9
+ `reorderable`, moves up and down. `RepeatableGroupFormField` wraps the form
10
+ library's `useFieldArray` (react-hook-form in React, vee-validate in Vue) and
11
+ renders each row from `renderRow` (React) or the `row` slot (Vue), whose
12
+ `path` names the row's fields. The rows are a list (`ul` / `li`) in a group
13
+ named by `label`; each row's buttons are named after it ("Remove period 2",
14
+ "Move period 2 up"), from the new `material.repeatable-group.*` keys in
15
+ English and French. An added row's first field takes the focus, a removed row
16
+ hands it to its neighbour or to Add, and a moved row keeps it on its move
17
+ button; each change is announced politely. `max` makes Add `aria-disabled`,
18
+ described by "Maximum reached (n)"; `min` disables Remove; `emptyTitle` /
19
+ `emptyDescription` show an `EmptyState` with no row. The array's own error
20
+ shows under the rows and describes the group; each row's fields show their
21
+ own. One column on a phone, a responsive grid wider up, no inline style.
22
+
23
+ `@nxgt/material-core` gains `/repeatable-group`: the focus rules
24
+ (`focusAfterRemove`, `focusAfterMove`, `focusRowTarget`), the limits
25
+ (`canAddRow`, `canRemoveRow`, `rowMoveTarget`) and the `RowAnnouncement`
26
+ descriptor both libraries use.
27
+
28
+ ```tsx
29
+ <RepeatableGroupFormField
30
+ control={control}
31
+ name="periods"
32
+ label="Periods"
33
+ rowLabel="period"
34
+ max={6}
35
+ reorderable
36
+ newRow={() => ({ name: '' })}
37
+ renderRow={({ index }) => (
38
+ <TextFormField control={control} name={`periods.${index}.name`} label="Name" />
39
+ )}
40
+ />
41
+ ```
42
+
43
+ ```vue
44
+ <RepeatableGroupFormField
45
+ name="periods"
46
+ label="Periods"
47
+ row-label="period"
48
+ :max="6"
49
+ reorderable
50
+ :new-row="() => ({ name: '' })"
51
+ >
52
+ <template #row="{ path }">
53
+ <TextFormField :name="`${path}.name`" label="Name" />
54
+ </template>
55
+ </RepeatableGroupFormField>
56
+ ```
57
+
58
+ Fixes [#516](https://github.com/softistx/nxgt-material/issues/516).
59
+
60
+ Migration: an app with a local field-array list marked temporary for this
61
+ request ([#516](https://github.com/softistx/nxgt-material/issues/516)) — a `useFieldArray` whose rows are mapped by hand, with its own
62
+ Add and per-row Remove buttons — can delete it and render
63
+ `RepeatableGroupFormField` instead: the array's `name`, `newRow` for what Add
64
+ appends, and the row's fields moved into `renderRow` / the `row` slot. Pass no
65
+ `shouldFocus` to `append`: the group moves the focus itself.
66
+
3
67
  ## 0.10.0
4
68
 
5
69
  ### Minor Changes
package/README.md CHANGED
@@ -8,7 +8,8 @@ half (page geometry, scroll spy, text search), the currency field's decimals,
8
8
  the file-size formatter, the slider's tick marks, the emoji list's groups,
9
9
  the date range presets, a debounce, accent-insensitive text matching and a race-free async search (`/debounce`, `/text-match`, `/async-search`), the file preview's kind detection, the fields' blur
10
10
  rule, a field's first reachable control (`/focus-control`) and its `aria-describedby`
11
- (`/described-by`), the page behind a modal layer made inert
11
+ (`/described-by`), where the focus goes in a repeatable row group
12
+ (`/repeatable-group`), the page behind a modal layer made inert
12
13
  (`/inert-others`), Escape pressed while an overlay closes
13
14
  (`/escape-forward`), the Toaster's guard against a tap's emulated hover
14
15
  (`/touch-hover`), the focus a toast's action hands back (`/toast-focus`), a
@@ -88,6 +89,7 @@ built-in.
88
89
  | `/currency-digits` | `currencyFractionDigits` — the decimals `CurrencyField` shows |
89
90
  | `/field-blur-layers` | `layerOf`, `openerOf`, `FieldLayers` — whose overlay a layer of the page is, for a field's blur |
90
91
  | `/focus-control` | `firstControl`, `focusHandle`, `FocusHandle` — the first control of a field a user can reach, and a stand-in element that focuses it for a form library |
92
+ | `/repeatable-group` | `focusAfterRemove`, `focusAfterMove`, `focusRowTarget`, `rowMoveTarget`, `canAddRow`, `canRemoveRow`, `RowFocusTarget`, `RowMoveDirection`, `RowAnnouncement`, `ROW_SLOT`, `ROW_FIELDS_SLOT`, `ADD_ATTRIBUTE` — where the focus goes after a row of a `RepeatableGroup` is added, removed or moved, and what its live region says |
91
93
  | `/escape-forward` | `forwardEscape` — Escape pressed while an overlay closes, pressed again for the overlay below once it has gone |
92
94
  | `/described-by` | `describedBy` — an `aria-describedby` value from the caller's ids and a field's help and error ids |
93
95
  | `/inert-others` | `inertOthers`, `INERT_MARKER` — the rest of the page `inert` behind a modal layer, live regions kept, nested layers counted |
@@ -811,6 +813,44 @@ firstControl(roots)?.focus(); // the first date input, not a hidden one
811
813
  field.ref(focusHandle(roots)); // React Hook Form: shouldFocusError reaches it
812
814
  ```
813
815
 
816
+ ### Rows added, removed and moved: `/repeatable-group`
817
+
818
+ What both libraries' `RepeatableGroup` decides, as plain functions.
819
+ `focusAfterRemove(removed, remaining)` names where the focus goes once a row
820
+ is gone: the row that took its place, else the one before, else the Add
821
+ button. `focusAfterMove(to, direction, count)` keeps it on the move button
822
+ that was pressed, or on the row's other one when the row reached either end.
823
+ `focusRowTarget(root, target)` moves the focus there, reading the DOM when
824
+ called (rows are `[data-slot="repeatable-group-row"]`, the move buttons carry
825
+ `data-move`, the Add button `data-repeatable-group-add`). `canAddRow(count, max)`, `canRemoveRow(count, min)` and
826
+ `rowMoveTarget(index, direction, count)` are the limits (`RowMoveDirection`
827
+ is `'up' | 'down'`), and `RowAnnouncement` is what the live region says
828
+ (1-based positions). The DOM markers are exported as `ROW_SLOT`
829
+ (`'repeatable-group-row'`), `ROW_FIELDS_SLOT` (`'repeatable-group-fields'`)
830
+ and `ADD_ATTRIBUTE` (`'data-repeatable-group-add'`).
831
+
832
+ ```ts
833
+ import {
834
+ canAddRow,
835
+ canRemoveRow,
836
+ focusAfterRemove,
837
+ focusRowTarget,
838
+ type RowAnnouncement,
839
+ rowMoveTarget,
840
+ } from '@nxgt/material-core/repeatable-group';
841
+
842
+ const addable = canAddRow(rows.length, 6); // false at 6 rows
843
+ const removable = canRemoveRow(rows.length, 1); // false at 1 row
844
+ const up = rowMoveTarget(index, 'up', rows.length); // null for the first row
845
+
846
+ remove(index);
847
+ await nextTick(); // or after React commits
848
+ focusRowTarget(groupElement, focusAfterRemove(index, rows.length));
849
+
850
+ const said: RowAnnouncement = { kind: 'removed', index: index + 1 };
851
+ status.textContent = said.kind === 'removed' ? `Removed period ${said.index}` : '';
852
+ ```
853
+
814
854
  ### Escape while an overlay closes: `/escape-forward`
815
855
 
816
856
  Radix's and reka's dismissable layers give Escape to the highest layer only,
@@ -0,0 +1,41 @@
1
+ // src/lib/focus-control.ts
2
+ var TABBABLE = 'input:not([type="hidden"]):not(:disabled):not([tabindex="-1"]), textarea:not(:disabled):not([tabindex="-1"]), select:not(:disabled):not([tabindex="-1"]), button:not(:disabled):not([tabindex="-1"]), [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
3
+ function firstControl2(roots) {
4
+ for (const root of roots) {
5
+ const controls = root.matches(TABBABLE) ? [root] : root.querySelectorAll(TABBABLE);
6
+ for (const control of controls) {
7
+ if (control.getClientRects().length)
8
+ return control;
9
+ }
10
+ }
11
+ return null;
12
+ }
13
+ function focusHandle2(roots) {
14
+ return {
15
+ focus: () => {
16
+ firstControl2(roots)?.focus();
17
+ setTimeout(() => {
18
+ const list = [...roots];
19
+ const active = list[0]?.ownerDocument.activeElement ?? null;
20
+ if (!list.some((root) => root.contains(active))) {
21
+ firstControl2(list)?.focus();
22
+ }
23
+ });
24
+ },
25
+ select: () => {
26
+ const control = firstControl2(roots);
27
+ if (control && "select" in control) {
28
+ control.select();
29
+ }
30
+ },
31
+ setCustomValidity: () => {
32
+ return;
33
+ },
34
+ reportValidity: () => true
35
+ };
36
+ }
37
+
38
+ export { firstControl2, focusHandle2 };
39
+
40
+ //# debugId=084F2A6FA5DCD68964756E2164756E21
41
+ //# sourceMappingURL=index-407b6zs0.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/lib/focus-control.ts"],
4
+ "sourcesContent": [
5
+ "/** What a user can Tab to inside a field. */\nconst TABBABLE =\n\t'input:not([type=\"hidden\"]):not(:disabled):not([tabindex=\"-1\"]), textarea:not(:disabled):not([tabindex=\"-1\"]), select:not(:disabled):not([tabindex=\"-1\"]), button:not(:disabled):not([tabindex=\"-1\"]), [contenteditable=\"true\"], [tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * The first control of `roots` a user can reach, in document order: one a\n * user can Tab to that is rendered — not a `display: none` input a library\n * keeps for forms. A root that is itself such a control is its own first\n * control. `null` when none is. Reads the DOM when called.\n */\nexport function firstControl(roots: Iterable<Element>): HTMLElement | null {\n\tfor (const root of roots) {\n\t\tconst controls = root.matches(TABBABLE)\n\t\t\t? [root as HTMLElement]\n\t\t\t: root.querySelectorAll<HTMLElement>(TABBABLE);\n\t\tfor (const control of controls) {\n\t\t\tif (control.getClientRects().length) return control;\n\t\t}\n\t}\n\treturn null;\n}\n\n/** What a form library calls on the element a field registered. */\nexport type FocusHandle = {\n\tfocus: () => void;\n\tselect: () => void;\n\tsetCustomValidity: (message: string) => void;\n\treportValidity: () => boolean;\n};\n\n/**\n * A stand-in element for a field made of several controls, for a form\n * library that focuses an invalid field by calling `focus()` on what the\n * field registered (React Hook Form's `field.ref`). `focus()` focuses the\n * first control of `roots` a user can reach, looked up when it is called —\n * `roots` is read live, so pass the field's own set — and again a turn\n * later when focus is not in the field by then: the error that triggered it\n * may re-render the field and replace its control. `select()` selects that\n * control's text when it has any. Native validation is not used:\n * `setCustomValidity` does nothing and `reportValidity` returns `true`.\n */\nexport function focusHandle(roots: Iterable<Element>): FocusHandle {\n\treturn {\n\t\tfocus: () => {\n\t\t\tfirstControl(roots)?.focus();\n\t\t\tsetTimeout(() => {\n\t\t\t\tconst list = [...roots];\n\t\t\t\tconst active = list[0]?.ownerDocument.activeElement ?? null;\n\t\t\t\tif (!list.some((root) => root.contains(active))) {\n\t\t\t\t\tfirstControl(list)?.focus();\n\t\t\t\t}\n\t\t\t});\n\t\t},\n\t\tselect: () => {\n\t\t\tconst control = firstControl(roots);\n\t\t\tif (control && 'select' in control) {\n\t\t\t\t(control as HTMLInputElement).select();\n\t\t\t}\n\t\t},\n\t\tsetCustomValidity: () => undefined,\n\t\treportValidity: () => true,\n\t};\n}\n"
6
+ ],
7
+ "mappings": ";AACA,IAAM,WACL;AAQM,SAAS,aAAY,CAAC,OAA8C;AAAA,EAC1E,WAAW,QAAQ,OAAO;AAAA,IACzB,MAAM,WAAW,KAAK,QAAQ,QAAQ,IACnC,CAAC,IAAmB,IACpB,KAAK,iBAA8B,QAAQ;AAAA,IAC9C,WAAW,WAAW,UAAU;AAAA,MAC/B,IAAI,QAAQ,eAAe,EAAE;AAAA,QAAQ,OAAO;AAAA,IAC7C;AAAA,EACD;AAAA,EACA,OAAO;AAAA;AAsBD,SAAS,YAAW,CAAC,OAAuC;AAAA,EAClE,OAAO;AAAA,IACN,OAAO,MAAM;AAAA,MACZ,cAAa,KAAK,GAAG,MAAM;AAAA,MAC3B,WAAW,MAAM;AAAA,QAChB,MAAM,OAAO,CAAC,GAAG,KAAK;AAAA,QACtB,MAAM,SAAS,KAAK,IAAI,cAAc,iBAAiB;AAAA,QACvD,IAAI,CAAC,KAAK,KAAK,CAAC,SAAS,KAAK,SAAS,MAAM,CAAC,GAAG;AAAA,UAChD,cAAa,IAAI,GAAG,MAAM;AAAA,QAC3B;AAAA,OACA;AAAA;AAAA,IAEF,QAAQ,MAAM;AAAA,MACb,MAAM,UAAU,cAAa,KAAK;AAAA,MAClC,IAAI,WAAW,YAAY,SAAS;AAAA,QAClC,QAA6B,OAAO;AAAA,MACtC;AAAA;AAAA,IAED,mBAAmB,MAAG;AAAA,MAAG;AAAA;AAAA,IACzB,gBAAgB,MAAM;AAAA,EACvB;AAAA;",
8
+ "debugId": "084F2A6FA5DCD68964756E2164756E21",
9
+ "names": []
10
+ }
@@ -1,43 +1,11 @@
1
- // src/lib/focus-control.ts
2
- var TABBABLE = 'input:not([type="hidden"]):not(:disabled):not([tabindex="-1"]), textarea:not(:disabled):not([tabindex="-1"]), select:not(:disabled):not([tabindex="-1"]), button:not(:disabled):not([tabindex="-1"]), [contenteditable="true"], [tabindex]:not([tabindex="-1"])';
3
- function firstControl(roots) {
4
- for (const root of roots) {
5
- const controls = root.matches(TABBABLE) ? [root] : root.querySelectorAll(TABBABLE);
6
- for (const control of controls) {
7
- if (control.getClientRects().length)
8
- return control;
9
- }
10
- }
11
- return null;
12
- }
13
- function focusHandle(roots) {
14
- return {
15
- focus: () => {
16
- firstControl(roots)?.focus();
17
- setTimeout(() => {
18
- const list = [...roots];
19
- const active = list[0]?.ownerDocument.activeElement ?? null;
20
- if (!list.some((root) => root.contains(active))) {
21
- firstControl(list)?.focus();
22
- }
23
- });
24
- },
25
- select: () => {
26
- const control = firstControl(roots);
27
- if (control && "select" in control) {
28
- control.select();
29
- }
30
- },
31
- setCustomValidity: () => {
32
- return;
33
- },
34
- reportValidity: () => true
35
- };
36
- }
1
+ import {
2
+ firstControl2,
3
+ focusHandle2
4
+ } from "../chunks/index-407b6zs0.js";
37
5
  export {
38
- firstControl,
39
- focusHandle
6
+ firstControl2 as firstControl,
7
+ focusHandle2 as focusHandle
40
8
  };
41
9
 
42
- //# debugId=F6055B3989727FBE64756E2164756E21
10
+ //# debugId=3CD7CAE6F251A45964756E2164756E21
43
11
  //# sourceMappingURL=focus-control.js.map
@@ -1,10 +1,9 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../src/lib/focus-control.ts"],
3
+ "sources": [],
4
4
  "sourcesContent": [
5
- "/** What a user can Tab to inside a field. */\nconst TABBABLE =\n\t'input:not([type=\"hidden\"]):not(:disabled):not([tabindex=\"-1\"]), textarea:not(:disabled):not([tabindex=\"-1\"]), select:not(:disabled):not([tabindex=\"-1\"]), button:not(:disabled):not([tabindex=\"-1\"]), [contenteditable=\"true\"], [tabindex]:not([tabindex=\"-1\"])';\n\n/**\n * The first control of `roots` a user can reach, in document order: one a\n * user can Tab to that is rendered — not a `display: none` input a library\n * keeps for forms. A root that is itself such a control is its own first\n * control. `null` when none is. Reads the DOM when called.\n */\nexport function firstControl(roots: Iterable<Element>): HTMLElement | null {\n\tfor (const root of roots) {\n\t\tconst controls = root.matches(TABBABLE)\n\t\t\t? [root as HTMLElement]\n\t\t\t: root.querySelectorAll<HTMLElement>(TABBABLE);\n\t\tfor (const control of controls) {\n\t\t\tif (control.getClientRects().length) return control;\n\t\t}\n\t}\n\treturn null;\n}\n\n/** What a form library calls on the element a field registered. */\nexport type FocusHandle = {\n\tfocus: () => void;\n\tselect: () => void;\n\tsetCustomValidity: (message: string) => void;\n\treportValidity: () => boolean;\n};\n\n/**\n * A stand-in element for a field made of several controls, for a form\n * library that focuses an invalid field by calling `focus()` on what the\n * field registered (React Hook Form's `field.ref`). `focus()` focuses the\n * first control of `roots` a user can reach, looked up when it is called —\n * `roots` is read live, so pass the field's own set — and again a turn\n * later when focus is not in the field by then: the error that triggered it\n * may re-render the field and replace its control. `select()` selects that\n * control's text when it has any. Native validation is not used:\n * `setCustomValidity` does nothing and `reportValidity` returns `true`.\n */\nexport function focusHandle(roots: Iterable<Element>): FocusHandle {\n\treturn {\n\t\tfocus: () => {\n\t\t\tfirstControl(roots)?.focus();\n\t\t\tsetTimeout(() => {\n\t\t\t\tconst list = [...roots];\n\t\t\t\tconst active = list[0]?.ownerDocument.activeElement ?? null;\n\t\t\t\tif (!list.some((root) => root.contains(active))) {\n\t\t\t\t\tfirstControl(list)?.focus();\n\t\t\t\t}\n\t\t\t});\n\t\t},\n\t\tselect: () => {\n\t\t\tconst control = firstControl(roots);\n\t\t\tif (control && 'select' in control) {\n\t\t\t\t(control as HTMLInputElement).select();\n\t\t\t}\n\t\t},\n\t\tsetCustomValidity: () => undefined,\n\t\treportValidity: () => true,\n\t};\n}\n"
6
5
  ],
7
- "mappings": ";AACA,IAAM,WACL;AAQM,SAAS,YAAY,CAAC,OAA8C;AAAA,EAC1E,WAAW,QAAQ,OAAO;AAAA,IACzB,MAAM,WAAW,KAAK,QAAQ,QAAQ,IACnC,CAAC,IAAmB,IACpB,KAAK,iBAA8B,QAAQ;AAAA,IAC9C,WAAW,WAAW,UAAU;AAAA,MAC/B,IAAI,QAAQ,eAAe,EAAE;AAAA,QAAQ,OAAO;AAAA,IAC7C;AAAA,EACD;AAAA,EACA,OAAO;AAAA;AAsBD,SAAS,WAAW,CAAC,OAAuC;AAAA,EAClE,OAAO;AAAA,IACN,OAAO,MAAM;AAAA,MACZ,aAAa,KAAK,GAAG,MAAM;AAAA,MAC3B,WAAW,MAAM;AAAA,QAChB,MAAM,OAAO,CAAC,GAAG,KAAK;AAAA,QACtB,MAAM,SAAS,KAAK,IAAI,cAAc,iBAAiB;AAAA,QACvD,IAAI,CAAC,KAAK,KAAK,CAAC,SAAS,KAAK,SAAS,MAAM,CAAC,GAAG;AAAA,UAChD,aAAa,IAAI,GAAG,MAAM;AAAA,QAC3B;AAAA,OACA;AAAA;AAAA,IAEF,QAAQ,MAAM;AAAA,MACb,MAAM,UAAU,aAAa,KAAK;AAAA,MAClC,IAAI,WAAW,YAAY,SAAS;AAAA,QAClC,QAA6B,OAAO;AAAA,MACtC;AAAA;AAAA,IAED,mBAAmB,MAAG;AAAA,MAAG;AAAA;AAAA,IACzB,gBAAgB,MAAM;AAAA,EACvB;AAAA;",
8
- "debugId": "F6055B3989727FBE64756E2164756E21",
6
+ "mappings": "",
7
+ "debugId": "3CD7CAE6F251A45964756E2164756E21",
9
8
  "names": []
10
9
  }
@@ -0,0 +1,70 @@
1
+ /** Which way a row moves: towards the first row, or towards the last. */
2
+ export type RowMoveDirection = 'up' | 'down';
3
+ /**
4
+ * Where the focus goes once a RepeatableGroup's rows have changed: a row's
5
+ * first field, the Add button, or one of a row's move buttons. Indexes are
6
+ * 0-based, in the rows as they are after the change.
7
+ */
8
+ export type RowFocusTarget = {
9
+ kind: 'field';
10
+ index: number;
11
+ } | {
12
+ kind: 'add';
13
+ } | {
14
+ kind: 'move';
15
+ index: number;
16
+ direction: RowMoveDirection;
17
+ };
18
+ /**
19
+ * What the group's polite live region says about a change. `index`, `from`
20
+ * and `to` are 1-based: they are what a user reads ("period 2").
21
+ */
22
+ export type RowAnnouncement = {
23
+ kind: 'added';
24
+ index: number;
25
+ } | {
26
+ kind: 'removed';
27
+ index: number;
28
+ } | {
29
+ kind: 'moved';
30
+ from: number;
31
+ to: number;
32
+ };
33
+ /** Another row can be added: no `max`, or fewer rows than it. */
34
+ export declare const canAddRow: (count: number, max?: number) => boolean;
35
+ /** A row can be removed: no `min`, or more rows than it. */
36
+ export declare const canRemoveRow: (count: number, min?: number) => boolean;
37
+ /**
38
+ * The index the row at `index` moves to, or `null` when it cannot move that
39
+ * way (the first row up, the last one down).
40
+ */
41
+ export declare function rowMoveTarget(index: number, direction: RowMoveDirection, count: number): number | null;
42
+ /**
43
+ * Where the focus goes once the row at `removed` is gone and `remaining`
44
+ * rows are left: the row that took its place, else the row before it (the
45
+ * last one was removed), else the Add button (no row is left).
46
+ */
47
+ export declare function focusAfterRemove(removed: number, remaining: number): RowFocusTarget;
48
+ /**
49
+ * Where the focus goes once a row moved `direction` to `to`: the button that
50
+ * moved it, so the user can press it again — unless the row can no longer
51
+ * move that way (it reached the first or the last place, and that button is
52
+ * now disabled), then the other move button of the same row.
53
+ */
54
+ export declare function focusAfterMove(to: number, direction: RowMoveDirection, count: number): RowFocusTarget;
55
+ /** The `data-slot` of each row and of its fields. */
56
+ export declare const ROW_SLOT = "repeatable-group-row";
57
+ export declare const ROW_FIELDS_SLOT = "repeatable-group-fields";
58
+ /**
59
+ * The attribute that marks the Add button: an attribute of its own, as the
60
+ * button keeps the `data-slot` of the library's Button.
61
+ */
62
+ export declare const ADD_ATTRIBUTE = "data-repeatable-group-add";
63
+ /**
64
+ * Moves the focus to `target` inside the group's `root`: the first control a
65
+ * user can reach in the row's fields, the Add button (`data-repeatable-group-add`), or the row's move
66
+ * button (`data-move="up"` / `"down"`). Reads the DOM when called; returns
67
+ * whether something took the focus.
68
+ */
69
+ export declare function focusRowTarget(root: Element, target: RowFocusTarget): boolean;
70
+ //# sourceMappingURL=repeatable-group.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"repeatable-group.d.ts","sourceRoot":"","sources":["../../src/lib/repeatable-group.ts"],"names":[],"mappings":"AAEA,yEAAyE;AACzE,MAAM,MAAM,gBAAgB,GAAG,IAAI,GAAG,MAAM,CAAC;AAE7C;;;;GAIG;AACH,MAAM,MAAM,cAAc,GACvB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,GACf;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAEhE;;;GAGG;AACH,MAAM,MAAM,eAAe,GACxB;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAChC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAClC;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAE/C,iEAAiE;AACjE,eAAO,MAAM,SAAS,GAAI,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,OACvB,CAAC;AAElC,4DAA4D;AAC5D,eAAO,MAAM,YAAY,GAAI,OAAO,MAAM,EAAE,MAAM,MAAM,KAAG,OAC1B,CAAC;AAElC;;;GAGG;AACH,wBAAgB,aAAa,CAC5B,KAAK,EAAE,MAAM,EACb,SAAS,EAAE,gBAAgB,EAC3B,KAAK,EAAE,MAAM,GACX,MAAM,GAAG,IAAI,CAGf;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC/B,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GACf,cAAc,CAGhB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC7B,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,gBAAgB,EAC3B,KAAK,EAAE,MAAM,GACX,cAAc,CAIhB;AAED,qDAAqD;AACrD,eAAO,MAAM,QAAQ,yBAAyB,CAAC;AAC/C,eAAO,MAAM,eAAe,4BAA4B,CAAC;AACzD;;;GAGG;AACH,eAAO,MAAM,aAAa,8BAA8B,CAAC;AAEzD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,cAAc,GAAG,OAAO,CAK7E"}
@@ -0,0 +1,59 @@
1
+ import {
2
+ firstControl2
3
+ } from "../chunks/index-407b6zs0.js";
4
+
5
+ // src/lib/repeatable-group.ts
6
+ var canAddRow = (count, max) => max === undefined || count < max;
7
+ var canRemoveRow = (count, min) => min === undefined || count > min;
8
+ function rowMoveTarget(index, direction, count) {
9
+ const to = direction === "up" ? index - 1 : index + 1;
10
+ return to < 0 || to >= count ? null : to;
11
+ }
12
+ function focusAfterRemove(removed, remaining) {
13
+ if (remaining <= 0)
14
+ return { kind: "add" };
15
+ return { kind: "field", index: Math.min(removed, remaining - 1) };
16
+ }
17
+ function focusAfterMove(to, direction, count) {
18
+ const stays = rowMoveTarget(to, direction, count) !== null;
19
+ const other = direction === "up" ? "down" : "up";
20
+ return { kind: "move", index: to, direction: stays ? direction : other };
21
+ }
22
+ var ROW_SLOT = "repeatable-group-row";
23
+ var ROW_FIELDS_SLOT = "repeatable-group-fields";
24
+ var ADD_ATTRIBUTE = "data-repeatable-group-add";
25
+ function focusRowTarget(root, target) {
26
+ const el = targetElement(root, target);
27
+ if (!el)
28
+ return false;
29
+ el.focus();
30
+ return el.ownerDocument.activeElement === el;
31
+ }
32
+ function targetElement(root, target) {
33
+ if (target.kind === "add") {
34
+ return root.querySelector(`[${ADD_ATTRIBUTE}]`);
35
+ }
36
+ const rows = root.querySelectorAll(`[data-slot="${ROW_SLOT}"]`);
37
+ const row = rows[target.index];
38
+ if (!row)
39
+ return null;
40
+ if (target.kind === "move") {
41
+ return row.querySelector(`[data-move="${target.direction}"]`);
42
+ }
43
+ const fields = row.querySelector(`[data-slot="${ROW_FIELDS_SLOT}"]`);
44
+ return firstControl2([fields ?? row]);
45
+ }
46
+ export {
47
+ ADD_ATTRIBUTE,
48
+ ROW_FIELDS_SLOT,
49
+ ROW_SLOT,
50
+ canAddRow,
51
+ canRemoveRow,
52
+ focusAfterMove,
53
+ focusAfterRemove,
54
+ focusRowTarget,
55
+ rowMoveTarget
56
+ };
57
+
58
+ //# debugId=EF31DAFB676F33B564756E2164756E21
59
+ //# sourceMappingURL=repeatable-group.js.map
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/lib/repeatable-group.ts"],
4
+ "sourcesContent": [
5
+ "import { firstControl } from './focus-control';\n\n/** Which way a row moves: towards the first row, or towards the last. */\nexport type RowMoveDirection = 'up' | 'down';\n\n/**\n * Where the focus goes once a RepeatableGroup's rows have changed: a row's\n * first field, the Add button, or one of a row's move buttons. Indexes are\n * 0-based, in the rows as they are after the change.\n */\nexport type RowFocusTarget =\n\t| { kind: 'field'; index: number }\n\t| { kind: 'add' }\n\t| { kind: 'move'; index: number; direction: RowMoveDirection };\n\n/**\n * What the group's polite live region says about a change. `index`, `from`\n * and `to` are 1-based: they are what a user reads (\"period 2\").\n */\nexport type RowAnnouncement =\n\t| { kind: 'added'; index: number }\n\t| { kind: 'removed'; index: number }\n\t| { kind: 'moved'; from: number; to: number };\n\n/** Another row can be added: no `max`, or fewer rows than it. */\nexport const canAddRow = (count: number, max?: number): boolean =>\n\tmax === undefined || count < max;\n\n/** A row can be removed: no `min`, or more rows than it. */\nexport const canRemoveRow = (count: number, min?: number): boolean =>\n\tmin === undefined || count > min;\n\n/**\n * The index the row at `index` moves to, or `null` when it cannot move that\n * way (the first row up, the last one down).\n */\nexport function rowMoveTarget(\n\tindex: number,\n\tdirection: RowMoveDirection,\n\tcount: number,\n): number | null {\n\tconst to = direction === 'up' ? index - 1 : index + 1;\n\treturn to < 0 || to >= count ? null : to;\n}\n\n/**\n * Where the focus goes once the row at `removed` is gone and `remaining`\n * rows are left: the row that took its place, else the row before it (the\n * last one was removed), else the Add button (no row is left).\n */\nexport function focusAfterRemove(\n\tremoved: number,\n\tremaining: number,\n): RowFocusTarget {\n\tif (remaining <= 0) return { kind: 'add' };\n\treturn { kind: 'field', index: Math.min(removed, remaining - 1) };\n}\n\n/**\n * Where the focus goes once a row moved `direction` to `to`: the button that\n * moved it, so the user can press it again — unless the row can no longer\n * move that way (it reached the first or the last place, and that button is\n * now disabled), then the other move button of the same row.\n */\nexport function focusAfterMove(\n\tto: number,\n\tdirection: RowMoveDirection,\n\tcount: number,\n): RowFocusTarget {\n\tconst stays = rowMoveTarget(to, direction, count) !== null;\n\tconst other: RowMoveDirection = direction === 'up' ? 'down' : 'up';\n\treturn { kind: 'move', index: to, direction: stays ? direction : other };\n}\n\n/** The `data-slot` of each row and of its fields. */\nexport const ROW_SLOT = 'repeatable-group-row';\nexport const ROW_FIELDS_SLOT = 'repeatable-group-fields';\n/**\n * The attribute that marks the Add button: an attribute of its own, as the\n * button keeps the `data-slot` of the library's Button.\n */\nexport const ADD_ATTRIBUTE = 'data-repeatable-group-add';\n\n/**\n * Moves the focus to `target` inside the group's `root`: the first control a\n * user can reach in the row's fields, the Add button (`data-repeatable-group-add`), or the row's move\n * button (`data-move=\"up\"` / `\"down\"`). Reads the DOM when called; returns\n * whether something took the focus.\n */\nexport function focusRowTarget(root: Element, target: RowFocusTarget): boolean {\n\tconst el = targetElement(root, target);\n\tif (!el) return false;\n\tel.focus();\n\treturn el.ownerDocument.activeElement === el;\n}\n\nfunction targetElement(\n\troot: Element,\n\ttarget: RowFocusTarget,\n): HTMLElement | null {\n\tif (target.kind === 'add') {\n\t\treturn root.querySelector<HTMLElement>(`[${ADD_ATTRIBUTE}]`);\n\t}\n\tconst rows = root.querySelectorAll(`[data-slot=\"${ROW_SLOT}\"]`);\n\tconst row = rows[target.index];\n\tif (!row) return null;\n\tif (target.kind === 'move') {\n\t\treturn row.querySelector<HTMLElement>(`[data-move=\"${target.direction}\"]`);\n\t}\n\tconst fields = row.querySelector(`[data-slot=\"${ROW_FIELDS_SLOT}\"]`);\n\treturn firstControl([fields ?? row]);\n}\n"
6
+ ],
7
+ "mappings": ";;;;;AAyBO,IAAM,YAAY,CAAC,OAAe,QACxC,QAAQ,aAAa,QAAQ;AAGvB,IAAM,eAAe,CAAC,OAAe,QAC3C,QAAQ,aAAa,QAAQ;AAMvB,SAAS,aAAa,CAC5B,OACA,WACA,OACgB;AAAA,EAChB,MAAM,KAAK,cAAc,OAAO,QAAQ,IAAI,QAAQ;AAAA,EACpD,OAAO,KAAK,KAAK,MAAM,QAAQ,OAAO;AAAA;AAQhC,SAAS,gBAAgB,CAC/B,SACA,WACiB;AAAA,EACjB,IAAI,aAAa;AAAA,IAAG,OAAO,EAAE,MAAM,MAAM;AAAA,EACzC,OAAO,EAAE,MAAM,SAAS,OAAO,KAAK,IAAI,SAAS,YAAY,CAAC,EAAE;AAAA;AAS1D,SAAS,cAAc,CAC7B,IACA,WACA,OACiB;AAAA,EACjB,MAAM,QAAQ,cAAc,IAAI,WAAW,KAAK,MAAM;AAAA,EACtD,MAAM,QAA0B,cAAc,OAAO,SAAS;AAAA,EAC9D,OAAO,EAAE,MAAM,QAAQ,OAAO,IAAI,WAAW,QAAQ,YAAY,MAAM;AAAA;AAIjE,IAAM,WAAW;AACjB,IAAM,kBAAkB;AAKxB,IAAM,gBAAgB;AAQtB,SAAS,cAAc,CAAC,MAAe,QAAiC;AAAA,EAC9E,MAAM,KAAK,cAAc,MAAM,MAAM;AAAA,EACrC,IAAI,CAAC;AAAA,IAAI,OAAO;AAAA,EAChB,GAAG,MAAM;AAAA,EACT,OAAO,GAAG,cAAc,kBAAkB;AAAA;AAG3C,SAAS,aAAa,CACrB,MACA,QACqB;AAAA,EACrB,IAAI,OAAO,SAAS,OAAO;AAAA,IAC1B,OAAO,KAAK,cAA2B,IAAI,gBAAgB;AAAA,EAC5D;AAAA,EACA,MAAM,OAAO,KAAK,iBAAiB,eAAe,YAAY;AAAA,EAC9D,MAAM,MAAM,KAAK,OAAO;AAAA,EACxB,IAAI,CAAC;AAAA,IAAK,OAAO;AAAA,EACjB,IAAI,OAAO,SAAS,QAAQ;AAAA,IAC3B,OAAO,IAAI,cAA2B,eAAe,OAAO,aAAa;AAAA,EAC1E;AAAA,EACA,MAAM,SAAS,IAAI,cAAc,eAAe,mBAAmB;AAAA,EACnE,OAAO,cAAa,CAAC,UAAU,GAAG,CAAC;AAAA;",
8
+ "debugId": "EF31DAFB676F33B564756E2164756E21",
9
+ "names": []
10
+ }
package/docs/roadmap.md CHANGED
@@ -76,6 +76,10 @@ Nothing is in progress.
76
76
 
77
77
  ## Shipped
78
78
 
79
+ - **A repeatable row group's focus rules** (`/repeatable-group`) — where the
80
+ focus goes after a row is added, removed or moved, the add and remove
81
+ limits, and what the live region announces, behind both libraries'
82
+ `RepeatableGroup`. 0.11.0.
79
83
  - **Two-step verification helpers and printing one element** (`/two-factor`,
80
84
  `/print-only`) — a TOTP secret grouped to be typed by hand, the recovery
81
85
  codes as a text file, saving a text file, and printing one element of the
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nxgt/material-core",
3
- "version": "0.10.0",
4
- "description": "The framework-free core of @nxgt/material and @nxgt/material-vue: the PDF document model and its react-pdf renderer, print formatters, PDF file helpers, the PDF viewer's pure modules, the currency digits, the file-size formatter, the field-blur layers, a field's first focusable control, the aria-describedby joiner, the inert background of a modal layer, Escape forwarded while an overlay closes, the Toaster's touch-hover guard and its focus hand-back, the page's Content Security Policy nonce, an object built on first use, the zod issue messages, the date range presets, the debounce, accent-insensitive text matching and the race-free async search behind Autocomplete and SearchField, the submit guard, the code error map, the two-factor helpers (a grouped TOTP secret, the recovery codes' file, a text download), printing one element of the page, the file preview's kind detection and text reader, the pdfkit fonts Vite plugin, a CSP-safe Vite assetsInlineLimit and the Node-only pdfkit fonts resolution for Next.js and Nuxt builds",
3
+ "version": "0.11.0",
4
+ "description": "The framework-free core of @nxgt/material and @nxgt/material-vue: the PDF document model and its react-pdf renderer, print formatters, PDF file helpers, the PDF viewer's pure modules, the currency digits, the file-size formatter, the field-blur layers, a field's first focusable control, a repeatable row group's focus and announcement rules, the aria-describedby joiner, the inert background of a modal layer, Escape forwarded while an overlay closes, the Toaster's touch-hover guard and its focus hand-back, the page's Content Security Policy nonce, an object built on first use, the zod issue messages, the date range presets, the debounce, accent-insensitive text matching and the race-free async search behind Autocomplete and SearchField, the submit guard, the code error map, the two-factor helpers (a grouped TOTP secret, the recovery codes' file, a text download), printing one element of the page, the file preview's kind detection and text reader, the pdfkit fonts Vite plugin, a CSP-safe Vite assetsInlineLimit and the Node-only pdfkit fonts resolution for Next.js and Nuxt builds",
5
5
  "license": "MIT",
6
6
  "bugs": {
7
7
  "email": "stevegires@gmail.com"
@@ -103,6 +103,11 @@
103
103
  "import": "./dist/lib/focus-control.js",
104
104
  "default": "./dist/lib/focus-control.js"
105
105
  },
106
+ "./repeatable-group": {
107
+ "types": "./dist/lib/repeatable-group.d.ts",
108
+ "import": "./dist/lib/repeatable-group.js",
109
+ "default": "./dist/lib/repeatable-group.js"
110
+ },
106
111
  "./escape-forward": {
107
112
  "types": "./dist/lib/escape-forward.d.ts",
108
113
  "import": "./dist/lib/escape-forward.js",
@@ -268,6 +273,7 @@
268
273
  "src/lib/currency-digits.ts",
269
274
  "src/lib/field-blur-layers.ts",
270
275
  "src/lib/focus-control.ts",
276
+ "src/lib/repeatable-group.ts",
271
277
  "src/lib/escape-forward.ts",
272
278
  "src/lib/described-by.ts",
273
279
  "src/lib/touch-hover.ts",