@lenne.tech/nuxt-extensions 1.13.0 → 1.14.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/CLAUDE.md +33 -13
- package/README.md +38 -17
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +3 -2
- package/dist/runtime/plugins/form-label-association.client.d.ts +45 -0
- package/dist/runtime/plugins/form-label-association.client.js +124 -12
- package/dist/runtime/plugins/pre-hydration-input.client.js +2 -1
- package/dist/runtime/types/module.d.ts +22 -4
- package/package.json +3 -1
package/CLAUDE.md
CHANGED
|
@@ -186,22 +186,42 @@ export default defineNuxtRouteMiddleware((to) => {
|
|
|
186
186
|
force-patched on hydration (vuejs/core#9083) while the label's `for` — a prop on reka-ui's
|
|
187
187
|
`Label` component — is not. The label keeps the stale value and the association breaks.
|
|
188
188
|
|
|
189
|
-
**What this module adds:** `runtime/plugins/form-label-association.client.ts`
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
**What this module adds:** `runtime/plugins/form-label-association.client.ts` repairs three
|
|
190
|
+
associations inside Nuxt UI fields whose label no longer resolves — `<label for>`,
|
|
191
|
+
`aria-labelledby` on group roots, and `aria-describedby` on controls — at `app:mounted`, for a
|
|
192
|
+
bounded window afterwards (`maxRepairMs`, default 1500 ms), and via a `MutationObserver` for
|
|
193
|
+
subtrees that hydrate later (`observeDeferred`, default on).
|
|
192
194
|
|
|
193
195
|
**What this means when you debug a consuming project:**
|
|
194
196
|
|
|
195
|
-
- **A `for`
|
|
196
|
-
|
|
197
|
-
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
197
|
+
- **A `for` / `aria-labelledby` / `aria-describedby` that changes after mount is this plugin, not
|
|
198
|
+
a bug.** It logs once per page in development, naming the repaired fields. Repaired labels
|
|
199
|
+
carry `data-lt-label-repaired`.
|
|
200
|
+
- **`aria-describedby` is the one people miss.** The error container's id IS force-patched on
|
|
201
|
+
hydration; the reference to it is not, so validation errors are visible but never announced
|
|
202
|
+
(WCAG 3.3.1 / 3.3.3). If a project reports "screen reader stays silent on form errors", check
|
|
203
|
+
whether this plugin is disabled before looking anywhere else.
|
|
204
|
+
- **It refuses to guess.** The `for` repair fires only when the field holds exactly one eligible
|
|
205
|
+
control. Groups get `aria-labelledby` instead — a `for` on a `<div role="radiogroup">` is inert
|
|
206
|
+
however carefully it is chosen.
|
|
207
|
+
- **`disabled` and `readonly` fields ARE repaired**, and must stay that way. They are rendered
|
|
208
|
+
and announced, so WCAG 1.3.1 / 4.1.2 apply unchanged; WCAG's only carve-out for inactive
|
|
209
|
+
components is contrast. Excluding `disabled` was tried in 1.13.0 and reproduced the very defect
|
|
210
|
+
the plugin repairs on every read-only form — and additionally mis-bound partially disabled radio
|
|
211
|
+
groups, so a click on one caption selected a different option.
|
|
212
|
+
- **`[data-hidden]` alone does NOT mean "reka proxy".** The missing `id` is what means that.
|
|
213
|
+
`UFileUpload` renders the user's real file input through the same mechanism and stamps the
|
|
214
|
+
field id on it — excluding it unqualified takes out the field's only control.
|
|
215
|
+
- **Only Nuxt UI's own field labels are touched** (`label[data-slot="label"][for]`). Labels your
|
|
216
|
+
application wrote are never rewritten, and naming the application set itself is never
|
|
217
|
+
overwritten.
|
|
218
|
+
- **Never assert on a generated `for` or `id` value** in tests. Assert on the accessible name —
|
|
219
|
+
the ids come from `useId()` and are neither stable nor meaningful, and this plugin rewrites them.
|
|
220
|
+
- **If you change a selector in that file, run the mutation check.** `test/upstream-dom-contract.test.ts`
|
|
221
|
+
pins what reka-ui and @nuxt/ui actually render (both are devDependencies for exactly this
|
|
222
|
+
reason); `test/form-label-association.test.ts` pins our own pairing rules. Every guard has a
|
|
223
|
+
case that fails when the guard is removed. The 1.13.0 defect shipped because a fixture was
|
|
224
|
+
invented rather than observed, and the invented selector matched the invented fixture.
|
|
205
225
|
- This is a **stopgap** for a framework-level divergence. Opt out with
|
|
206
226
|
`ltExtensions.formLabelAssociation.enabled: false`.
|
|
207
227
|
|
package/README.md
CHANGED
|
@@ -190,27 +190,39 @@ target, not a convenience. Tests using `getByRole('textbox', { name })` stop fin
|
|
|
190
190
|
|
|
191
191
|
### What the repair does
|
|
192
192
|
|
|
193
|
-
After mount, and for a bounded window afterwards,
|
|
194
|
-
|
|
193
|
+
After mount, and for a bounded window afterwards, three associations are repaired inside each
|
|
194
|
+
Nuxt UI field whose label no longer resolves:
|
|
195
|
+
|
|
196
|
+
| Association | Repaired how | Why it needs its own path |
|
|
197
|
+
|---|---|---|
|
|
198
|
+
| `<label for>` → control | re-pointed at the field's control | `for` carries **both** the accessible name and click-to-focus |
|
|
199
|
+
| `aria-labelledby` on a group | caption id minted, group pointed at it | A radio group's id sits on a `<div role="radiogroup">`, and `for` only resolves against labelable elements — there it is inert however carefully it is chosen |
|
|
200
|
+
| `aria-describedby` → error / help | dangling tokens re-pointed, token-wise | The error container's id **is** force-patched on hydration; the reference to it is not. So the message is visible and never announced (WCAG 3.3.1 / 3.3.3) |
|
|
195
201
|
|
|
196
202
|
**It refuses to guess.** A repair that fires where it should not is worse than the defect: a
|
|
197
|
-
dangling label is inert and visible, a mis-pointed one is confidently wrong and silent, and
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
deliberately:
|
|
203
|
+
dangling label is inert and visible, a mis-pointed one is confidently wrong and silent, and on a
|
|
204
|
+
radio or checkbox a click on the caption would change a value the user never chose. So the `for`
|
|
205
|
+
repair only fires when the field contains exactly **one** eligible control. Skipped deliberately:
|
|
201
206
|
|
|
202
207
|
| Case | Why |
|
|
203
208
|
|------|-----|
|
|
204
|
-
| Radio / checkbox groups | One field root, many controls — binding every caption to
|
|
205
|
-
|
|
|
206
|
-
|
|
|
209
|
+
| Radio / checkbox groups | One field root, many controls — binding every caption to one item would submit a value nobody picked. Named via `aria-labelledby` instead |
|
|
210
|
+
| Reka's submit proxy (`[data-hidden]` with no `id`) | Not the user's control. The missing id is what identifies it — Nuxt UI's `UFileUpload` renders the *real* file input the same way, but stamps the field id on it |
|
|
211
|
+
| Anything `aria-hidden`, `hidden`, `type="hidden"` or `display:none` | Outside the accessibility tree, so a label pointing there would carry no name — the repair would report success and change nothing |
|
|
212
|
+
| A control that already has a working label | Multiple `<label>`s **concatenate** into one accessible name, so a second one produces a name matching no visible text |
|
|
213
|
+
| An id that is not unique in the document | `for` resolves through `getElementById`, which returns the *first* match — the label's click would go to the other element |
|
|
207
214
|
| Labels your application wrote | Only Nuxt UI's own field labels (`data-slot="label"`) are touched |
|
|
208
215
|
|
|
209
|
-
`
|
|
210
|
-
|
|
216
|
+
**`disabled` and `readonly` fields ARE repaired.** They are rendered, stay in the accessibility
|
|
217
|
+
tree and are announced, so WCAG 1.3.1 / 4.1.2 apply unchanged — and WCAG's only carve-out for
|
|
218
|
+
inactive components is contrast (1.4.3 / 1.4.11); there is no naming exemption. For a disabled
|
|
219
|
+
field the repair restores the **name only**, since a disabled control prevents click dispatch and
|
|
220
|
+
is not focusable. On a read-only form that is the whole value: it is a page people read rather
|
|
221
|
+
than operate. Excluding `disabled` was tried in 1.13.0 and reproduced the very defect this plugin
|
|
222
|
+
repairs on every read-only form.
|
|
211
223
|
|
|
212
|
-
**This is a repair, not a cure.** The real fix is for the id not to diverge; this exists
|
|
213
|
-
|
|
224
|
+
**This is a repair, not a cure.** The real fix is for the id not to diverge; this exists because
|
|
225
|
+
the divergence sits in the framework stack rather than in any one application.
|
|
214
226
|
|
|
215
227
|
### Opting out
|
|
216
228
|
|
|
@@ -219,15 +231,24 @@ export default defineNuxtConfig({
|
|
|
219
231
|
ltExtensions: {
|
|
220
232
|
formLabelAssociation: {
|
|
221
233
|
enabled: false,
|
|
222
|
-
//
|
|
223
|
-
// maxRepairMs: 3000,
|
|
234
|
+
// Or keep it on and tune the two knobs:
|
|
235
|
+
// maxRepairMs: 3000, // window for deferred subtrees; clamped to [0, 30000]
|
|
236
|
+
// observeDeferred: false, // drop the MutationObserver for hydrate-on-visible content
|
|
224
237
|
},
|
|
225
238
|
},
|
|
226
239
|
});
|
|
227
240
|
```
|
|
228
241
|
|
|
229
|
-
Two real reasons to: your application assigns `for` itself and depends on those exact
|
|
230
|
-
or your tests assert on literal `for` / `id` strings rather than on the accessible name.
|
|
242
|
+
Two real reasons to turn it off: your application assigns `for` itself and depends on those exact
|
|
243
|
+
values, or your tests assert on literal `for` / `id` strings rather than on the accessible name.
|
|
244
|
+
Fix the second reason instead — assert on the accessible name, which is what a user perceives.
|
|
245
|
+
|
|
246
|
+
### Debugging it in a consuming project
|
|
247
|
+
|
|
248
|
+
- **A `for`, `aria-labelledby` or `aria-describedby` that changes after mount is this plugin.**
|
|
249
|
+
In development it logs once per page, naming the fields it repaired.
|
|
250
|
+
- Repaired labels carry `data-lt-label-repaired`, so an E2E suite can assert the repair count is
|
|
251
|
+
zero once the upstream divergence is fixed — and delete the plugin at that point.
|
|
231
252
|
|
|
232
253
|
## Pre-Hydration Input Preservation
|
|
233
254
|
|
package/dist/module.d.mts
CHANGED
|
@@ -3,7 +3,7 @@ import { LtExtensionsModuleOptions } from '../dist/runtime/types/index.js';
|
|
|
3
3
|
export * from '../dist/runtime/types/index.js';
|
|
4
4
|
|
|
5
5
|
declare const name = "@lenne.tech/nuxt-extensions";
|
|
6
|
-
declare const version = "1.
|
|
6
|
+
declare const version = "1.14.0";
|
|
7
7
|
declare const configKey = "ltExtensions";
|
|
8
8
|
declare const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
|
|
9
9
|
declare const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
|
package/dist/module.json
CHANGED
package/dist/module.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { defineNuxtModule, createResolver, tryResolveModule, addImports, addComponent, addPlugin, addRouteMiddleware } from '@nuxt/kit';
|
|
2
2
|
|
|
3
3
|
const name = "@lenne.tech/nuxt-extensions";
|
|
4
|
-
const version = "1.
|
|
4
|
+
const version = "1.14.0";
|
|
5
5
|
const configKey = "ltExtensions";
|
|
6
6
|
const DEFAULT_LT_AUTH_STATE_COOKIE = "lt-auth-state";
|
|
7
7
|
const DEFAULT_LT_JWT_TOKEN_COOKIE = "lt-jwt-token";
|
|
@@ -130,7 +130,8 @@ const module$1 = defineNuxtModule({
|
|
|
130
130
|
},
|
|
131
131
|
formLabelAssociation: {
|
|
132
132
|
enabled: resolvedOptions.formLabelAssociation?.enabled ?? true,
|
|
133
|
-
maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500
|
|
133
|
+
maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500,
|
|
134
|
+
observeDeferred: resolvedOptions.formLabelAssociation?.observeDeferred ?? true
|
|
134
135
|
},
|
|
135
136
|
preHydrationInput: {
|
|
136
137
|
enabled: resolvedOptions.preHydrationInput?.enabled ?? true,
|
|
@@ -78,5 +78,50 @@
|
|
|
78
78
|
* @returns how many labels were repaired.
|
|
79
79
|
*/
|
|
80
80
|
export declare function repairDanglingLabels(root: Document | ParentNode): number;
|
|
81
|
+
/**
|
|
82
|
+
* Name composite widgets whose label cannot be repaired with `for`.
|
|
83
|
+
*
|
|
84
|
+
* A `<div role="radiogroup">` is not labelable, so the `for` repair above skips it and leaves
|
|
85
|
+
* the group with no accessible name at all — the group's caption stays in the tree as loose
|
|
86
|
+
* text, and a screen reader announces the radiogroup unnamed. `aria-labelledby` is the correct
|
|
87
|
+
* instrument here: it names the group without touching any item, so it cannot mis-select
|
|
88
|
+
* anything, which makes it strictly safer than the `for` repair.
|
|
89
|
+
*
|
|
90
|
+
* Gated exactly like `for`: one candidate root per field, and only when the label's own `for`
|
|
91
|
+
* does not already resolve.
|
|
92
|
+
*
|
|
93
|
+
* @returns how many group roots were named.
|
|
94
|
+
*/
|
|
95
|
+
export declare function repairGroupLabels(root: Document | ParentNode): number;
|
|
96
|
+
/**
|
|
97
|
+
* Re-point `aria-describedby` tokens that hydration left dangling.
|
|
98
|
+
*
|
|
99
|
+
* The SAME divergence that breaks `for` breaks this, for the same reason, and it is arguably
|
|
100
|
+
* worse. `FormField` renders its error / help / description containers with `:id` as a compiled
|
|
101
|
+
* element binding, so those ids ARE in `dynamicProps` and Vue force-patches them to the client
|
|
102
|
+
* value. The matching `aria-describedby` reaches the control through `useFormField().ariaAttrs`
|
|
103
|
+
* inside a `v-bind` spread, which is NOT in `dynamicProps` — so it keeps the server value.
|
|
104
|
+
*
|
|
105
|
+
* The result on exactly the pages this plugin was written for: every validation error, help
|
|
106
|
+
* text and description points at an id that no longer exists. A sighted user sees "Required
|
|
107
|
+
* field"; a screen-reader user gets silence. That is WCAG 3.3.1 / 3.3.3 — the error is visible
|
|
108
|
+
* but never announced on the field it belongs to.
|
|
109
|
+
*
|
|
110
|
+
* Repaired token-wise rather than wholesale, so a describedby token the application wrote and
|
|
111
|
+
* that still resolves is left exactly as it is.
|
|
112
|
+
*
|
|
113
|
+
* @returns how many controls had at least one token repaired.
|
|
114
|
+
*/
|
|
115
|
+
export declare function repairDescribedBy(root: Document | ParentNode): number;
|
|
116
|
+
/**
|
|
117
|
+
* Run every repair over `root` and report what each one did.
|
|
118
|
+
*
|
|
119
|
+
* @returns the per-repair counts, so the dev warning can name the mechanism rather than a total.
|
|
120
|
+
*/
|
|
121
|
+
export declare function repairFieldAssociations(root: Document | ParentNode): {
|
|
122
|
+
describedBy: number;
|
|
123
|
+
groups: number;
|
|
124
|
+
labels: number;
|
|
125
|
+
};
|
|
81
126
|
declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
|
|
82
127
|
export default _default;
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
|
|
2
2
|
const COMPONENT_ROOT = '[data-slot="root"]';
|
|
3
3
|
const FIELD_LABEL = 'label[data-slot="label"][for]';
|
|
4
|
-
const CONTROL =
|
|
5
|
-
const
|
|
4
|
+
const CONTROL = 'input, textarea, select, button[data-slot="base"]';
|
|
5
|
+
const NATIVE_HIDDEN = '[type="hidden"], [hidden]';
|
|
6
|
+
const NOT_IN_A11Y_TREE = '[aria-hidden="true"]';
|
|
7
|
+
const REKA_SUBMIT_PROXY = "[data-hidden]:not([id])";
|
|
8
|
+
const NOT_A_USER_CONTROL = `${NATIVE_HIDDEN}, ${NOT_IN_A11Y_TREE}, ${REKA_SUBMIT_PROXY}`;
|
|
9
|
+
const GROUP_ROOT = '[role="radiogroup"], [role="group"]';
|
|
10
|
+
const DESCRIBING_SLOTS = '[data-slot="error"], [data-slot="help"], [data-slot="description"], [data-slot="hint"]';
|
|
11
|
+
function isVisuallyRemoved(element) {
|
|
12
|
+
const style = element.style;
|
|
13
|
+
return style?.display === "none" || style?.visibility === "hidden";
|
|
14
|
+
}
|
|
6
15
|
function findControlForLabel(field) {
|
|
7
|
-
const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL));
|
|
16
|
+
const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL) && !isVisuallyRemoved(control));
|
|
8
17
|
return eligible.length === 1 && eligible[0].id !== "" ? eligible[0] : null;
|
|
9
18
|
}
|
|
19
|
+
function isSafeToBind(label, control) {
|
|
20
|
+
if (control.labels?.length) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return label.ownerDocument.getElementById(control.id) === control;
|
|
24
|
+
}
|
|
10
25
|
export function repairDanglingLabels(root) {
|
|
11
26
|
let repaired = 0;
|
|
12
27
|
for (const label of root.querySelectorAll(FIELD_LABEL)) {
|
|
@@ -19,14 +34,72 @@ export function repairDanglingLabels(root) {
|
|
|
19
34
|
continue;
|
|
20
35
|
}
|
|
21
36
|
const control = findControlForLabel(field);
|
|
22
|
-
if (control === null || control.id === label.htmlFor) {
|
|
37
|
+
if (control === null || control.id === label.htmlFor || !isSafeToBind(label, control)) {
|
|
23
38
|
continue;
|
|
24
39
|
}
|
|
25
40
|
label.htmlFor = control.id;
|
|
41
|
+
label.dataset.ltLabelRepaired = "";
|
|
42
|
+
repaired += 1;
|
|
43
|
+
}
|
|
44
|
+
return repaired;
|
|
45
|
+
}
|
|
46
|
+
export function repairGroupLabels(root) {
|
|
47
|
+
let repaired = 0;
|
|
48
|
+
for (const label of root.querySelectorAll(FIELD_LABEL)) {
|
|
49
|
+
const field = label.closest(COMPONENT_ROOT);
|
|
50
|
+
if (field === null) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const target = label.ownerDocument.getElementById(label.htmlFor);
|
|
54
|
+
if (target !== null && field.contains(target)) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const groups = field.querySelectorAll(GROUP_ROOT);
|
|
58
|
+
if (groups.length !== 1) {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const group = groups[0];
|
|
62
|
+
if (group.hasAttribute("aria-labelledby") || group.hasAttribute("aria-label")) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (label.id === "") {
|
|
66
|
+
label.id = `${group.id === "" ? label.htmlFor : group.id}-lt-label`;
|
|
67
|
+
}
|
|
68
|
+
group.setAttribute("aria-labelledby", label.id);
|
|
69
|
+
label.dataset.ltLabelRepaired = "";
|
|
26
70
|
repaired += 1;
|
|
27
71
|
}
|
|
28
72
|
return repaired;
|
|
29
73
|
}
|
|
74
|
+
export function repairDescribedBy(root) {
|
|
75
|
+
let repaired = 0;
|
|
76
|
+
for (const control of root.querySelectorAll("[aria-describedby]")) {
|
|
77
|
+
const field = control.closest(COMPONENT_ROOT);
|
|
78
|
+
if (field === null) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const tokens = control.getAttribute("aria-describedby").split(/\s+/).filter(Boolean);
|
|
82
|
+
const dangling = tokens.filter((token) => control.ownerDocument.getElementById(token) === null);
|
|
83
|
+
if (dangling.length === 0) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const candidates = [...field.querySelectorAll(DESCRIBING_SLOTS)].filter((element) => element.id !== "");
|
|
87
|
+
if (candidates.length !== dangling.length) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const replacements = new Map(dangling.map((token, index) => [token, candidates[index].id]));
|
|
91
|
+
control.setAttribute("aria-describedby", tokens.map((token) => replacements.get(token) ?? token).join(" "));
|
|
92
|
+
repaired += 1;
|
|
93
|
+
}
|
|
94
|
+
return repaired;
|
|
95
|
+
}
|
|
96
|
+
export function repairFieldAssociations(root) {
|
|
97
|
+
return {
|
|
98
|
+
describedBy: repairDescribedBy(root),
|
|
99
|
+
groups: repairGroupLabels(root),
|
|
100
|
+
labels: repairDanglingLabels(root)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
30
103
|
export default defineNuxtPlugin({
|
|
31
104
|
// After the app's own plugins, so a page that assigns ids itself has already run.
|
|
32
105
|
enforce: "post",
|
|
@@ -36,30 +109,69 @@ export default defineNuxtPlugin({
|
|
|
36
109
|
if (options?.enabled === false) {
|
|
37
110
|
return;
|
|
38
111
|
}
|
|
39
|
-
const
|
|
112
|
+
const configured = options?.maxRepairMs ?? 1500;
|
|
113
|
+
const maxRepairMs = Number.isFinite(configured) && configured > 0 ? Math.min(configured, 3e4) : 0;
|
|
114
|
+
const observeDeferred = options?.observeDeferred !== false;
|
|
40
115
|
let sweeping = null;
|
|
116
|
+
let closing = null;
|
|
117
|
+
let observer = null;
|
|
118
|
+
let warned = false;
|
|
41
119
|
const sweep = () => {
|
|
42
|
-
const
|
|
43
|
-
if (
|
|
120
|
+
const { describedBy, groups, labels } = repairFieldAssociations(document);
|
|
121
|
+
if (import.meta.dev && !warned && describedBy + groups + labels > 0) {
|
|
122
|
+
warned = true;
|
|
123
|
+
const names = [...document.querySelectorAll("[data-lt-label-repaired]")].map((label) => label.textContent?.trim()).filter(Boolean);
|
|
44
124
|
console.warn(
|
|
45
|
-
`[@lenne.tech/nuxt-extensions] repaired ${
|
|
125
|
+
`[@lenne.tech/nuxt-extensions] repaired ${labels} label association(s), ${groups} group name(s) and ${describedBy} aria-describedby reference(s) broken by an SSR/client useId() divergence${names.length > 0 ? `: ${names.join(", ")}` : ""}. This is a stopgap \u2014 see runtime/plugins/form-label-association.client.ts.`
|
|
46
126
|
);
|
|
47
127
|
}
|
|
48
128
|
};
|
|
49
129
|
const sweepFor = () => {
|
|
50
130
|
sweep();
|
|
51
|
-
if (maxRepairMs <= 0
|
|
131
|
+
if (maxRepairMs <= 0) {
|
|
52
132
|
return;
|
|
53
133
|
}
|
|
54
|
-
sweeping
|
|
55
|
-
|
|
134
|
+
if (sweeping === null) {
|
|
135
|
+
sweeping = setInterval(sweep, 250);
|
|
136
|
+
}
|
|
137
|
+
if (closing !== null) {
|
|
138
|
+
clearTimeout(closing);
|
|
139
|
+
}
|
|
140
|
+
closing = setTimeout(() => {
|
|
56
141
|
if (sweeping !== null) {
|
|
57
142
|
clearInterval(sweeping);
|
|
58
143
|
sweeping = null;
|
|
59
144
|
}
|
|
145
|
+
closing = null;
|
|
60
146
|
}, maxRepairMs);
|
|
61
147
|
};
|
|
62
|
-
|
|
148
|
+
const observeDeferredHydration = () => {
|
|
149
|
+
if (!observeDeferred || observer !== null || typeof MutationObserver === "undefined") {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let queued = false;
|
|
153
|
+
observer = new MutationObserver((records) => {
|
|
154
|
+
if (queued) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
const carriesLabel = records.some(
|
|
158
|
+
(record) => [...record.addedNodes].some((node) => node instanceof Element && (node.matches(FIELD_LABEL) || node.querySelector(FIELD_LABEL) !== null))
|
|
159
|
+
);
|
|
160
|
+
if (!carriesLabel) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
queued = true;
|
|
164
|
+
requestAnimationFrame(() => {
|
|
165
|
+
queued = false;
|
|
166
|
+
sweep();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
170
|
+
};
|
|
171
|
+
nuxtApp.hook("app:mounted", () => {
|
|
172
|
+
sweepFor();
|
|
173
|
+
observeDeferredHydration();
|
|
174
|
+
});
|
|
63
175
|
nuxtApp.hook("page:finish", () => {
|
|
64
176
|
requestAnimationFrame(sweepFor);
|
|
65
177
|
});
|
|
@@ -26,7 +26,8 @@ export default defineNuxtPlugin({
|
|
|
26
26
|
if (options?.enabled === false) {
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
|
-
const
|
|
29
|
+
const configuredRestoreMs = options?.maxRestoreMs ?? 1500;
|
|
30
|
+
const maxRestoreMs = Number.isFinite(configuredRestoreMs) && configuredRestoreMs > 0 ? Math.min(configuredRestoreMs, 3e4) : 0;
|
|
30
31
|
let edited = /* @__PURE__ */ new Map();
|
|
31
32
|
nuxtApp.hook("app:beforeMount", () => {
|
|
32
33
|
edited = collectEditedFields(document);
|
|
@@ -179,13 +179,30 @@ export interface LtFormLabelAssociationOptions {
|
|
|
179
179
|
enabled?: boolean;
|
|
180
180
|
/**
|
|
181
181
|
* How long after mount to keep repairing, in milliseconds. Defaults to `1500`; `0` sweeps
|
|
182
|
-
* once and stops.
|
|
182
|
+
* once and stops. Values above 30000 are clamped, and a non-finite value is treated as `0` —
|
|
183
|
+
* a delay past 2^31 overflows `setTimeout` and fires immediately, silently turning an
|
|
184
|
+
* over-large window into no window at all.
|
|
183
185
|
*
|
|
184
|
-
* Most fields are repaired in the first pass. The window exists for server-rendered
|
|
185
|
-
*
|
|
186
|
-
*
|
|
186
|
+
* Most fields are repaired in the first pass. The window exists for server-rendered subtrees
|
|
187
|
+
* whose hydration is deferred — an unresolved `<Suspense>`, an island — which carry the same
|
|
188
|
+
* divergence but hydrate after `app:mounted`. It does NOT reach `hydrate-on-visible` or
|
|
189
|
+
* `hydrate-on-interaction`, which can fire minutes later; `observeDeferred` covers those.
|
|
187
190
|
*/
|
|
188
191
|
maxRepairMs?: number;
|
|
192
|
+
/**
|
|
193
|
+
* Keep watching for server-rendered subtrees that hydrate after the window closes.
|
|
194
|
+
* Defaults to `true`.
|
|
195
|
+
*
|
|
196
|
+
* `hydrate-on-visible` fires on scroll and `hydrate-on-interaction` on a click — either can
|
|
197
|
+
* be long after mount, and both carry the very same divergence, so no fixed window reaches
|
|
198
|
+
* them. A `MutationObserver` does, and it costs nothing while nothing changes: it reacts
|
|
199
|
+
* only to added subtrees that actually contain a field label, and coalesces a burst into one
|
|
200
|
+
* sweep per frame.
|
|
201
|
+
*
|
|
202
|
+
* Turn it off if your application renders no lazily hydrated server content and you would
|
|
203
|
+
* rather not carry an observer at all; the bounded window then remains the only mechanism.
|
|
204
|
+
*/
|
|
205
|
+
observeDeferred?: boolean;
|
|
189
206
|
}
|
|
190
207
|
/**
|
|
191
208
|
* Pre-hydration input preservation.
|
|
@@ -275,6 +292,7 @@ export interface LtExtensionsPublicRuntimeConfig {
|
|
|
275
292
|
formLabelAssociation: {
|
|
276
293
|
enabled: boolean;
|
|
277
294
|
maxRepairMs: number;
|
|
295
|
+
observeDeferred: boolean;
|
|
278
296
|
};
|
|
279
297
|
preHydrationInput: {
|
|
280
298
|
enabled: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lenne.tech/nuxt-extensions",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -102,6 +102,7 @@
|
|
|
102
102
|
"@better-auth/passkey": "1.7.1",
|
|
103
103
|
"@nuxt/module-builder": "1.0.3",
|
|
104
104
|
"@nuxt/schema": "4.5.2",
|
|
105
|
+
"@nuxt/ui": "4.11.0",
|
|
105
106
|
"@playwright/test": "1.62.1",
|
|
106
107
|
"@types/node": "26.2.0",
|
|
107
108
|
"@vitest/coverage-v8": "4.1.11",
|
|
@@ -111,6 +112,7 @@
|
|
|
111
112
|
"nuxt": "4.5.2",
|
|
112
113
|
"oxfmt": "0.64.0",
|
|
113
114
|
"oxlint": "1.79.0",
|
|
115
|
+
"reka-ui": "2.10.3",
|
|
114
116
|
"tus-js-client": "4.3.1",
|
|
115
117
|
"typescript": "5.9.3",
|
|
116
118
|
"vitest": "4.1.11",
|