@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 +64 -0
- package/README.md +41 -1
- package/dist/chunks/index-407b6zs0.js +41 -0
- package/dist/chunks/index-407b6zs0.js.map +10 -0
- package/dist/lib/focus-control.js +7 -39
- package/dist/lib/focus-control.js.map +3 -4
- package/dist/lib/repeatable-group.d.ts +70 -0
- package/dist/lib/repeatable-group.d.ts.map +1 -0
- package/dist/lib/repeatable-group.js +59 -0
- package/dist/lib/repeatable-group.js.map +10 -0
- package/docs/roadmap.md +4 -0
- package/package.json +8 -2
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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=
|
|
10
|
+
//# debugId=3CD7CAE6F251A45964756E2164756E21
|
|
43
11
|
//# sourceMappingURL=focus-control.js.map
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": [
|
|
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": "
|
|
8
|
-
"debugId": "
|
|
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.
|
|
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",
|