@lenne.tech/nuxt-extensions 1.12.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 +50 -0
- package/README.md +93 -0
- package/dist/module.d.mts +1 -1
- package/dist/module.json +1 -1
- package/dist/module.mjs +13 -1
- package/dist/runtime/plugins/form-label-association.client.d.ts +127 -0
- package/dist/runtime/plugins/form-label-association.client.js +179 -0
- package/dist/runtime/plugins/pre-hydration-input.client.js +2 -1
- package/dist/runtime/types/index.d.ts +1 -1
- package/dist/runtime/types/module.d.ts +75 -0
- package/package.json +3 -1
package/CLAUDE.md
CHANGED
|
@@ -76,6 +76,10 @@ export default defineNuxtConfig({
|
|
|
76
76
|
enabled: true,
|
|
77
77
|
basePath: '/ai', // Must match the nest-server AI controller
|
|
78
78
|
},
|
|
79
|
+
formLabelAssociation: {
|
|
80
|
+
enabled: true, // repair <label for> broken by an SSR/client useId() divergence
|
|
81
|
+
maxRepairMs: 1500, // how long to keep repairing deferred subtrees
|
|
82
|
+
},
|
|
79
83
|
preHydrationInput: {
|
|
80
84
|
enabled: true, // keep text typed before hydration instead of losing it
|
|
81
85
|
maxRestoreMs: 1500, // how long to keep repairing deferred subtrees
|
|
@@ -175,6 +179,52 @@ export default defineNuxtRouteMiddleware((to) => {
|
|
|
175
179
|
});
|
|
176
180
|
```
|
|
177
181
|
|
|
182
|
+
## Form Label Repair
|
|
183
|
+
|
|
184
|
+
**The defect:** Nuxt UI's `FormField` derives the label's `for` and the control's `id` from one
|
|
185
|
+
`useId()` call. When `useId()` diverges between server and client, the control's `id` is
|
|
186
|
+
force-patched on hydration (vuejs/core#9083) while the label's `for` — a prop on reka-ui's
|
|
187
|
+
`Label` component — is not. The label keeps the stale value and the association breaks.
|
|
188
|
+
|
|
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).
|
|
194
|
+
|
|
195
|
+
**What this means when you debug a consuming project:**
|
|
196
|
+
|
|
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.
|
|
225
|
+
- This is a **stopgap** for a framework-level divergence. Opt out with
|
|
226
|
+
`ltExtensions.formLabelAssociation.enabled: false`.
|
|
227
|
+
|
|
178
228
|
## Pre-Hydration Input Preservation
|
|
179
229
|
|
|
180
230
|
**The defect:** until Vue hydrates a server-rendered `<input>`, it carries no framework
|
package/README.md
CHANGED
|
@@ -42,6 +42,7 @@ npm install tus-js-client
|
|
|
42
42
|
- **i18n Support** - English and German translations (works without i18n too)
|
|
43
43
|
- **Auto-imports** - All composables and components are auto-imported
|
|
44
44
|
- **Pre-hydration input preservation** - text typed before hydration is kept, not erased
|
|
45
|
+
- **Form label repair** - restores `<label for>` associations that hydration breaks
|
|
45
46
|
|
|
46
47
|
## Environment Variables
|
|
47
48
|
|
|
@@ -157,6 +158,98 @@ export default defineNuxtConfig({
|
|
|
157
158
|
});
|
|
158
159
|
```
|
|
159
160
|
|
|
161
|
+
## Form Label Repair
|
|
162
|
+
|
|
163
|
+
Restores `<label for>` associations that hydration breaks. Active by default.
|
|
164
|
+
|
|
165
|
+
### The problem
|
|
166
|
+
|
|
167
|
+
Nuxt UI's `FormField` derives the label's `for` and the control's `id` from a single
|
|
168
|
+
`useId()` call, so they cannot disagree — unless `useId()` itself returns different values on
|
|
169
|
+
server and client, which happens when the two walk a different number of async boundaries.
|
|
170
|
+
|
|
171
|
+
Measured against `@nuxt/ui` 4.11.x:
|
|
172
|
+
|
|
173
|
+
| | label `for` | control `id` |
|
|
174
|
+
|---|---|---|
|
|
175
|
+
| SSR payload | `v-0-4-2` | `v-0-4-2` |
|
|
176
|
+
| after hydration | `v-0-4-2` | `v-0-1-2` |
|
|
177
|
+
|
|
178
|
+
Vue 3.5.39 did not cause this, it exposed it
|
|
179
|
+
([vuejs/core#9083](https://github.com/vuejs/core/pull/9083) force-patches an element's
|
|
180
|
+
dynamic props on hydration). The control's `id` is such a prop; the label's `for`, passed
|
|
181
|
+
through reka-ui's `Label` component, is not — so only the label keeps the stale value.
|
|
182
|
+
|
|
183
|
+
### What it costs when it breaks
|
|
184
|
+
|
|
185
|
+
The control loses its programmatic label. Without a placeholder it has no accessible name at
|
|
186
|
+
all and a screen reader announces "edit text, blank"; **with** a placeholder that becomes the
|
|
187
|
+
name instead, so the visible label is no longer part of it and speech input stops working.
|
|
188
|
+
Clicking the label focuses nothing either — which on a checkbox or radio is the primary hit
|
|
189
|
+
target, not a convenience. Tests using `getByRole('textbox', { name })` stop finding fields.
|
|
190
|
+
|
|
191
|
+
### What the repair does
|
|
192
|
+
|
|
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) |
|
|
201
|
+
|
|
202
|
+
**It refuses to guess.** A repair that fires where it should not is worse than the defect: a
|
|
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:
|
|
206
|
+
|
|
207
|
+
| Case | Why |
|
|
208
|
+
|------|-----|
|
|
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 |
|
|
214
|
+
| Labels your application wrote | Only Nuxt UI's own field labels (`data-slot="label"`) are touched |
|
|
215
|
+
|
|
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.
|
|
223
|
+
|
|
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.
|
|
226
|
+
|
|
227
|
+
### Opting out
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
export default defineNuxtConfig({
|
|
231
|
+
ltExtensions: {
|
|
232
|
+
formLabelAssociation: {
|
|
233
|
+
enabled: false,
|
|
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
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
```
|
|
241
|
+
|
|
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.
|
|
252
|
+
|
|
160
253
|
## Pre-Hydration Input Preservation
|
|
161
254
|
|
|
162
255
|
Text typed before a page has hydrated is kept instead of being silently thrown away.
|
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";
|
|
@@ -36,6 +36,9 @@ const defaultOptions = {
|
|
|
36
36
|
enabled: true,
|
|
37
37
|
defaultLocale: "de"
|
|
38
38
|
},
|
|
39
|
+
formLabelAssociation: {
|
|
40
|
+
enabled: true
|
|
41
|
+
},
|
|
39
42
|
i18n: {
|
|
40
43
|
autoMerge: true
|
|
41
44
|
},
|
|
@@ -66,6 +69,7 @@ const module$1 = defineNuxtModule({
|
|
|
66
69
|
systemSetup: { ...defaultOptions.auth.systemSetup, ...options.auth?.systemSetup }
|
|
67
70
|
},
|
|
68
71
|
errorTranslation: { ...defaultOptions.errorTranslation, ...options.errorTranslation },
|
|
72
|
+
formLabelAssociation: { ...defaultOptions.formLabelAssociation, ...options.formLabelAssociation },
|
|
69
73
|
i18n: { ...defaultOptions.i18n, ...options.i18n },
|
|
70
74
|
preHydrationInput: { ...defaultOptions.preHydrationInput, ...options.preHydrationInput },
|
|
71
75
|
tus: { ...defaultOptions.tus, ...options.tus }
|
|
@@ -124,6 +128,11 @@ const module$1 = defineNuxtModule({
|
|
|
124
128
|
enabled: resolvedOptions.errorTranslation?.enabled ?? true,
|
|
125
129
|
defaultLocale: resolvedOptions.errorTranslation?.defaultLocale || "de"
|
|
126
130
|
},
|
|
131
|
+
formLabelAssociation: {
|
|
132
|
+
enabled: resolvedOptions.formLabelAssociation?.enabled ?? true,
|
|
133
|
+
maxRepairMs: resolvedOptions.formLabelAssociation?.maxRepairMs ?? 1500,
|
|
134
|
+
observeDeferred: resolvedOptions.formLabelAssociation?.observeDeferred ?? true
|
|
135
|
+
},
|
|
127
136
|
preHydrationInput: {
|
|
128
137
|
enabled: resolvedOptions.preHydrationInput?.enabled ?? true,
|
|
129
138
|
maxRestoreMs: resolvedOptions.preHydrationInput?.maxRestoreMs ?? 1500
|
|
@@ -219,6 +228,9 @@ const module$1 = defineNuxtModule({
|
|
|
219
228
|
if (resolvedOptions.preHydrationInput.enabled !== false) {
|
|
220
229
|
addPlugin(resolve("./runtime/plugins/pre-hydration-input.client"));
|
|
221
230
|
}
|
|
231
|
+
if (resolvedOptions.formLabelAssociation.enabled !== false) {
|
|
232
|
+
addPlugin(resolve("./runtime/plugins/form-label-association.client"));
|
|
233
|
+
}
|
|
222
234
|
nuxt.options.build.transpile.push(resolve("./runtime"));
|
|
223
235
|
nuxt.options.build.transpile.push("tus-js-client");
|
|
224
236
|
console.log(`[${name}] Module loaded with config:`, {
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repair `<label for>` associations that hydration leaves pointing at nothing.
|
|
3
|
+
*
|
|
4
|
+
* ## The defect
|
|
5
|
+
*
|
|
6
|
+
* Nuxt UI's `FormField` generates ONE id with `useId()` and uses it for both halves of
|
|
7
|
+
* the association: `<Label :for="id">` and, via `provide`/`inject`, the control's `id`.
|
|
8
|
+
* One source, so the two cannot disagree — unless `useId()` itself returns different
|
|
9
|
+
* values on the server and on the client.
|
|
10
|
+
*
|
|
11
|
+
* It does. Measured on a Nuxt 4 app page (SVL, DEV-3093), against `@nuxt/ui` 4.11.x:
|
|
12
|
+
*
|
|
13
|
+
* | | label `for` | control `id` |
|
|
14
|
+
* | -- | -- | -- |
|
|
15
|
+
* | SSR payload | `v-0-4-2` | `v-0-4-2` |
|
|
16
|
+
* | after hydration | `v-0-4-2` | `v-0-1-2` |
|
|
17
|
+
*
|
|
18
|
+
* The middle segment is Vue's per-branch id prefix, which is extended at every async
|
|
19
|
+
* boundary. Server and client walked a different number of them, so the ids diverge.
|
|
20
|
+
*
|
|
21
|
+
* **Vue >= 3.5.39 did not cause this — it exposed it.** "force patch dynamic props when
|
|
22
|
+
* hydrating" (vuejs/core#9083, merged 2026-06-25) makes a hydrated element adopt its
|
|
23
|
+
* freshly computed client value, where previously the server value simply stayed.
|
|
24
|
+
*
|
|
25
|
+
* Note WHY only the label is left behind, because it points at the real fix: the control's
|
|
26
|
+
* `id` is a compiled element binding (`<input :id="id">`), so it is in the element's
|
|
27
|
+
* `dynamicProps` and gets force-patched. The label's `for` is a PROP on reka-ui's `Label`
|
|
28
|
+
* component and arrives at the `<label>` through `$attrs`, which is not part of that set.
|
|
29
|
+
* The asymmetry is the component boundary, not the id generation. Fixing the divergence
|
|
30
|
+
* upstream — or having `FormField` pass an SSR-stable id — removes the need for this file.
|
|
31
|
+
*
|
|
32
|
+
* ## Why it is worth a plugin
|
|
33
|
+
*
|
|
34
|
+
* A control whose label does not resolve loses its programmatic label: the visible text is
|
|
35
|
+
* no longer its accessible name (WCAG 1.3.1). Where the field has no placeholder it has no
|
|
36
|
+
* accessible name at all and a screen reader announces "edit text, blank" (4.1.2). Where it
|
|
37
|
+
* has one, the placeholder becomes the name instead — which is arguably worse, because the
|
|
38
|
+
* name no longer contains the visible label and speech input stops working (2.5.3 Label in
|
|
39
|
+
* Name). Clicking the label also no longer focuses the control, and for a checkbox or radio
|
|
40
|
+
* that is not a convenience but the primary hit target.
|
|
41
|
+
*
|
|
42
|
+
* And any test that addresses fields the way assistive technology does
|
|
43
|
+
* (`getByRole('textbox', { name })`, the recommended Playwright locator) stops finding
|
|
44
|
+
* them, which is how this surfaced: 39 specs at once.
|
|
45
|
+
*
|
|
46
|
+
* ## What this does, and what it deliberately does not
|
|
47
|
+
*
|
|
48
|
+
* After mount — and for a bounded window afterwards — every Nuxt UI field label whose
|
|
49
|
+
* target does not resolve is re-pointed at the control inside its own field.
|
|
50
|
+
*
|
|
51
|
+
* It **refuses to guess**, which is the rule that matters most here. A repair that fires
|
|
52
|
+
* where it should not is worse than the defect: a dangling label is inert and visible, a
|
|
53
|
+
* mis-pointed one is confidently wrong and silent, and on a radio or checkbox a click on
|
|
54
|
+
* the caption would then change a value the user never chose. So the repair only happens
|
|
55
|
+
* when the field contains exactly ONE eligible control. A group of radios or checkboxes
|
|
56
|
+
* shares one field root and is therefore skipped, not guessed at.
|
|
57
|
+
*
|
|
58
|
+
* `for` is repaired rather than `aria-labelledby` added, because `for` carries BOTH the
|
|
59
|
+
* accessible name and click-to-focus. Adding `aria-labelledby` would restore the name
|
|
60
|
+
* and leave the label dead to the mouse.
|
|
61
|
+
*
|
|
62
|
+
* This is a repair, not a cure: the right fix is for the id not to diverge in the first
|
|
63
|
+
* place (see the component-boundary note above). It is applied here because the divergence
|
|
64
|
+
* sits in the framework stack rather than in any one application, and because a nameless
|
|
65
|
+
* form is not something to leave in place while that is resolved upstream.
|
|
66
|
+
*
|
|
67
|
+
* Default on; opt out with `ltExtensions.formLabelAssociation.enabled: false`.
|
|
68
|
+
*/
|
|
69
|
+
/**
|
|
70
|
+
* Re-point every dangling field label at the control in its own field.
|
|
71
|
+
*
|
|
72
|
+
* Exported for the unit test: it runs against a DOM fixture rather than a live app, so the
|
|
73
|
+
* pairing rules stay verifiable without a browser. Not part of the package's public API.
|
|
74
|
+
*
|
|
75
|
+
* @param root Where to look. Ids are resolved against the owning document regardless, since
|
|
76
|
+
* `id` is document-scoped — so a scoped root narrows which labels are visited, not which
|
|
77
|
+
* ids count as resolvable.
|
|
78
|
+
* @returns how many labels were repaired.
|
|
79
|
+
*/
|
|
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
|
+
};
|
|
126
|
+
declare const _default: import("nuxt/app").Plugin<Record<string, unknown>> & import("nuxt/app").ObjectPlugin<Record<string, unknown>>;
|
|
127
|
+
export default _default;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { defineNuxtPlugin, useRuntimeConfig } from "#imports";
|
|
2
|
+
const COMPONENT_ROOT = '[data-slot="root"]';
|
|
3
|
+
const FIELD_LABEL = 'label[data-slot="label"][for]';
|
|
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
|
+
}
|
|
15
|
+
function findControlForLabel(field) {
|
|
16
|
+
const eligible = [...field.querySelectorAll(CONTROL)].filter((control) => !control.matches(NOT_A_USER_CONTROL) && !isVisuallyRemoved(control));
|
|
17
|
+
return eligible.length === 1 && eligible[0].id !== "" ? eligible[0] : null;
|
|
18
|
+
}
|
|
19
|
+
function isSafeToBind(label, control) {
|
|
20
|
+
if (control.labels?.length) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
return label.ownerDocument.getElementById(control.id) === control;
|
|
24
|
+
}
|
|
25
|
+
export function repairDanglingLabels(root) {
|
|
26
|
+
let repaired = 0;
|
|
27
|
+
for (const label of root.querySelectorAll(FIELD_LABEL)) {
|
|
28
|
+
const field = label.closest(COMPONENT_ROOT);
|
|
29
|
+
if (field === null) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const target = label.ownerDocument.getElementById(label.htmlFor);
|
|
33
|
+
if (target !== null && field.contains(target)) {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const control = findControlForLabel(field);
|
|
37
|
+
if (control === null || control.id === label.htmlFor || !isSafeToBind(label, control)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
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 = "";
|
|
70
|
+
repaired += 1;
|
|
71
|
+
}
|
|
72
|
+
return repaired;
|
|
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
|
+
}
|
|
103
|
+
export default defineNuxtPlugin({
|
|
104
|
+
// After the app's own plugins, so a page that assigns ids itself has already run.
|
|
105
|
+
enforce: "post",
|
|
106
|
+
name: "lt-form-label-association",
|
|
107
|
+
setup(nuxtApp) {
|
|
108
|
+
const options = useRuntimeConfig().public.ltExtensions?.formLabelAssociation;
|
|
109
|
+
if (options?.enabled === false) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
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;
|
|
115
|
+
let sweeping = null;
|
|
116
|
+
let closing = null;
|
|
117
|
+
let observer = null;
|
|
118
|
+
let warned = false;
|
|
119
|
+
const sweep = () => {
|
|
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);
|
|
124
|
+
console.warn(
|
|
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.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
const sweepFor = () => {
|
|
130
|
+
sweep();
|
|
131
|
+
if (maxRepairMs <= 0) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (sweeping === null) {
|
|
135
|
+
sweeping = setInterval(sweep, 250);
|
|
136
|
+
}
|
|
137
|
+
if (closing !== null) {
|
|
138
|
+
clearTimeout(closing);
|
|
139
|
+
}
|
|
140
|
+
closing = setTimeout(() => {
|
|
141
|
+
if (sweeping !== null) {
|
|
142
|
+
clearInterval(sweeping);
|
|
143
|
+
sweeping = null;
|
|
144
|
+
}
|
|
145
|
+
closing = null;
|
|
146
|
+
}, maxRepairMs);
|
|
147
|
+
};
|
|
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
|
+
});
|
|
175
|
+
nuxtApp.hook("page:finish", () => {
|
|
176
|
+
requestAnimationFrame(sweepFor);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
@@ -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);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type { LtAuthClientConfig, LtAuthMode, LtAuthResponse, LtAuthState, LtPasskeyAuthResult, LtPasskeyRegisterResult, LtUser, UseLtAuthReturn } from './auth.js';
|
|
2
2
|
export type { LtFileInfo, LtUploadItem, LtUploadOptions, LtUploadProgress, LtUploadStatus, UseLtFileReturn, UseLtTusUploadReturn } from './upload.js';
|
|
3
|
-
export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
|
|
3
|
+
export type { LtAiModuleOptions, LtAuthCookieNamesOptions, LtAuthModuleOptions, LtErrorTranslationModuleOptions, LtExtensionsModuleOptions, LtExtensionsPublicRuntimeConfig, LtFormLabelAssociationOptions, LtI18nModuleOptions, LtPreHydrationInputOptions, LtSystemSetupModuleOptions, LtTusModuleOptions, } from './module.js';
|
|
4
4
|
export type { LtErrorTranslationResponse, LtParsedError, UseLtErrorTranslationReturn } from './error.js';
|
|
5
5
|
export type { LtAiAction, LtAiAvailableConnection, LtAiBudgetLimit, LtAiBudgetSummary, LtAiConnection, LtAiConnectionInput, LtAiConnectionPreference, LtAiEffectiveSlot, LtAiInteraction, LtAiMessage, LtAiMode, LtAiPlaceholder, LtAiPrompt, LtAiPromptHint, LtAiPromptHintInput, LtAiPromptInput, LtAiPromptRunInput, LtAiResponse, LtAiSlot, LtAiSlotInput, LtAiStreamEvent, LtAiStreamHandlers, LtAiUsage, LtAiUsageInfo, LtAiUsageScope, UseLtAiAdminReturn, UseLtAiChatOptions, UseLtAiChatReturn, UseLtAiConnectionsReturn, UseLtAiPlaceholdersReturn, UseLtAiPromptsReturn, UseLtAiReturn, UseLtAiUsageReturn, } from './ai.js';
|
|
@@ -127,6 +127,8 @@ export interface LtExtensionsModuleOptions {
|
|
|
127
127
|
auth?: LtAuthModuleOptions;
|
|
128
128
|
/** Error translation configuration */
|
|
129
129
|
errorTranslation?: LtErrorTranslationModuleOptions;
|
|
130
|
+
/** Form label association repair configuration */
|
|
131
|
+
formLabelAssociation?: LtFormLabelAssociationOptions;
|
|
130
132
|
/** i18n configuration */
|
|
131
133
|
i18n?: LtI18nModuleOptions;
|
|
132
134
|
/** Pre-hydration input preservation configuration */
|
|
@@ -134,6 +136,74 @@ export interface LtExtensionsModuleOptions {
|
|
|
134
136
|
/** TUS upload module configuration */
|
|
135
137
|
tus?: LtTusModuleOptions;
|
|
136
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* Repair of `<label for>` associations broken by an SSR/client `useId()` divergence.
|
|
141
|
+
*
|
|
142
|
+
* Nuxt UI's `FormField` derives the label's `for` and the control's `id` from ONE `useId()`
|
|
143
|
+
* call, so they cannot disagree — unless `useId()` itself returns different values on server
|
|
144
|
+
* and client, which it does whenever the two walk a different number of async boundaries.
|
|
145
|
+
* The label then keeps the server value while the control adopts the client one.
|
|
146
|
+
*
|
|
147
|
+
* The control loses its programmatic label. Without a placeholder it has no accessible name
|
|
148
|
+
* at all and a screen reader announces "edit text, blank"; with one, the placeholder becomes
|
|
149
|
+
* the name instead, so the visible label is no longer part of it and speech input stops
|
|
150
|
+
* working. Either way clicking the label focuses nothing — which on a checkbox or radio is
|
|
151
|
+
* the primary hit target, not a convenience.
|
|
152
|
+
*
|
|
153
|
+
* Vue >= 3.5.39 did not cause this, it exposed it: vuejs/core#9083 force-patches an element's
|
|
154
|
+
* dynamic props during hydration, and the control's `id` is one while the label's `for` —
|
|
155
|
+
* passed through reka-ui's `Label` component and `$attrs` — is not.
|
|
156
|
+
*
|
|
157
|
+
* The repair refuses to guess: it only fires when the field contains exactly one eligible
|
|
158
|
+
* control, so a radio or checkbox group is skipped rather than having every caption bound to
|
|
159
|
+
* its first option.
|
|
160
|
+
*
|
|
161
|
+
* @example
|
|
162
|
+
* ```ts
|
|
163
|
+
* export default defineNuxtConfig({
|
|
164
|
+
* ltExtensions: {
|
|
165
|
+
* formLabelAssociation: { enabled: false },
|
|
166
|
+
* },
|
|
167
|
+
* });
|
|
168
|
+
* ```
|
|
169
|
+
*/
|
|
170
|
+
export interface LtFormLabelAssociationOptions {
|
|
171
|
+
/**
|
|
172
|
+
* Enable the repair. Defaults to `true`.
|
|
173
|
+
*
|
|
174
|
+
* Turn it off only with a reason. Two real ones: an application that assigns `for` itself
|
|
175
|
+
* and depends on those exact values, or a test suite asserting on literal `for` / `id`
|
|
176
|
+
* strings rather than on the accessible name. The alternative is a form whose fields carry
|
|
177
|
+
* no programmatic label.
|
|
178
|
+
*/
|
|
179
|
+
enabled?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* How long after mount to keep repairing, in milliseconds. Defaults to `1500`; `0` sweeps
|
|
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.
|
|
185
|
+
*
|
|
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.
|
|
190
|
+
*/
|
|
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;
|
|
206
|
+
}
|
|
137
207
|
/**
|
|
138
208
|
* Pre-hydration input preservation.
|
|
139
209
|
*
|
|
@@ -219,6 +289,11 @@ export interface LtExtensionsPublicRuntimeConfig {
|
|
|
219
289
|
enabled: boolean;
|
|
220
290
|
defaultLocale: string;
|
|
221
291
|
};
|
|
292
|
+
formLabelAssociation: {
|
|
293
|
+
enabled: boolean;
|
|
294
|
+
maxRepairMs: number;
|
|
295
|
+
observeDeferred: boolean;
|
|
296
|
+
};
|
|
222
297
|
preHydrationInput: {
|
|
223
298
|
enabled: boolean;
|
|
224
299
|
maxRestoreMs: number;
|
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",
|