@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 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.12.0";
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "configKey": "ltExtensions",
3
3
  "name": "@lenne.tech/nuxt-extensions",
4
- "version": "1.12.0",
4
+ "version": "1.14.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "3.6.1"
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.12.0";
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 maxRestoreMs = options?.maxRestoreMs ?? 1500;
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.12.0",
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",