@radix-ng/primitives 1.0.7 → 1.0.9

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.
Files changed (54) hide show
  1. package/fesm2022/radix-ng-primitives-autocomplete.mjs +43 -9
  2. package/fesm2022/radix-ng-primitives-autocomplete.mjs.map +1 -1
  3. package/fesm2022/radix-ng-primitives-checkbox.mjs +89 -8
  4. package/fesm2022/radix-ng-primitives-checkbox.mjs.map +1 -1
  5. package/fesm2022/radix-ng-primitives-combobox.mjs +43 -9
  6. package/fesm2022/radix-ng-primitives-combobox.mjs.map +1 -1
  7. package/fesm2022/radix-ng-primitives-core.mjs +208 -2
  8. package/fesm2022/radix-ng-primitives-core.mjs.map +1 -1
  9. package/fesm2022/radix-ng-primitives-date-field.mjs +50 -9
  10. package/fesm2022/radix-ng-primitives-date-field.mjs.map +1 -1
  11. package/fesm2022/radix-ng-primitives-editable.mjs +31 -5
  12. package/fesm2022/radix-ng-primitives-editable.mjs.map +1 -1
  13. package/fesm2022/radix-ng-primitives-field.mjs +265 -46
  14. package/fesm2022/radix-ng-primitives-field.mjs.map +1 -1
  15. package/fesm2022/radix-ng-primitives-form.mjs +110 -26
  16. package/fesm2022/radix-ng-primitives-form.mjs.map +1 -1
  17. package/fesm2022/radix-ng-primitives-input.mjs +16 -6
  18. package/fesm2022/radix-ng-primitives-input.mjs.map +1 -1
  19. package/fesm2022/radix-ng-primitives-number-field.mjs +32 -7
  20. package/fesm2022/radix-ng-primitives-number-field.mjs.map +1 -1
  21. package/fesm2022/radix-ng-primitives-presence.mjs +4 -2
  22. package/fesm2022/radix-ng-primitives-presence.mjs.map +1 -1
  23. package/fesm2022/radix-ng-primitives-radio.mjs +18 -4
  24. package/fesm2022/radix-ng-primitives-radio.mjs.map +1 -1
  25. package/fesm2022/radix-ng-primitives-select.mjs +26 -8
  26. package/fesm2022/radix-ng-primitives-select.mjs.map +1 -1
  27. package/fesm2022/radix-ng-primitives-signal-forms.mjs +184 -0
  28. package/fesm2022/radix-ng-primitives-signal-forms.mjs.map +1 -0
  29. package/fesm2022/radix-ng-primitives-slider.mjs +24 -8
  30. package/fesm2022/radix-ng-primitives-slider.mjs.map +1 -1
  31. package/fesm2022/radix-ng-primitives-switch.mjs +29 -5
  32. package/fesm2022/radix-ng-primitives-switch.mjs.map +1 -1
  33. package/fesm2022/radix-ng-primitives-time-field.mjs +49 -9
  34. package/fesm2022/radix-ng-primitives-time-field.mjs.map +1 -1
  35. package/fesm2022/radix-ng-primitives-toggle-group.mjs +23 -12
  36. package/fesm2022/radix-ng-primitives-toggle-group.mjs.map +1 -1
  37. package/package.json +11 -1
  38. package/types/radix-ng-primitives-autocomplete.d.ts +21 -2
  39. package/types/radix-ng-primitives-checkbox.d.ts +53 -4
  40. package/types/radix-ng-primitives-combobox.d.ts +51 -2
  41. package/types/radix-ng-primitives-core.d.ts +206 -3
  42. package/types/radix-ng-primitives-date-field.d.ts +27 -2
  43. package/types/radix-ng-primitives-editable.d.ts +15 -2
  44. package/types/radix-ng-primitives-field.d.ts +136 -33
  45. package/types/radix-ng-primitives-form.d.ts +115 -21
  46. package/types/radix-ng-primitives-input.d.ts +8 -1
  47. package/types/radix-ng-primitives-number-field.d.ts +14 -3
  48. package/types/radix-ng-primitives-radio.d.ts +7 -2
  49. package/types/radix-ng-primitives-select.d.ts +11 -3
  50. package/types/radix-ng-primitives-signal-forms.d.ts +97 -0
  51. package/types/radix-ng-primitives-slider.d.ts +9 -7
  52. package/types/radix-ng-primitives-switch.d.ts +15 -2
  53. package/types/radix-ng-primitives-time-field.d.ts +26 -2
  54. package/types/radix-ng-primitives-toggle-group.d.ts +6 -4
@@ -1,11 +1,11 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { input, booleanAttribute, signal, computed, inject, ElementRef, DestroyRef, Directive, effect, afterNextRender } from '@angular/core';
3
- import { createContext, injectId, rdxCheckLabelElement } from '@radix-ng/primitives/core';
3
+ import { createContext, injectId, RDX_DEFAULT_VALIDATION_MODE, isValidationRevealed, RDX_FIELD_VALIDITY, rdxCheckLabelElement } from '@radix-ng/primitives/core';
4
4
  import { injectFormRootContext } from '@radix-ng/primitives/form';
5
5
 
6
- const attr$4 = (value) => (value ? '' : undefined);
7
- const addId = (ids, id) => (ids.includes(id) ? ids : [...ids, id]);
8
- const removeId = (ids, id) => ids.filter((item) => item !== id);
6
+ const attr$5 = (value) => (value ? '' : undefined);
7
+ const addId$1 = (ids, id) => (ids.includes(id) ? ids : [...ids, id]);
8
+ const removeId$1 = (ids, id) => ids.filter((item) => item !== id);
9
9
  const fieldRootContext = () => {
10
10
  const root = injectFieldRoot();
11
11
  return {
@@ -13,10 +13,13 @@ const fieldRootContext = () => {
13
13
  name: root.name,
14
14
  descriptionIds: root.descriptionIds,
15
15
  errorIds: root.errorIds,
16
- /** Combined external messages (state provider's, then enclosing Form's), for `RdxFieldError`. */
16
+ /** Combined messages for `RdxFieldError`: client (provider + form name-routing, when revealed) then
17
+ * server (`errors` input, always). */
17
18
  messages: root.messages,
18
19
  /** Notify an enclosing Form that this field's control was edited (composite-control opt-in). */
19
20
  notifyEdited: () => root.notifyEdited(),
21
+ validState: root.validState,
22
+ formSubmitAttempted: root.formSubmitAttempted,
20
23
  invalidState: root.invalidState,
21
24
  disabledState: root.disabledState,
22
25
  requiredState: root.requiredState,
@@ -25,10 +28,10 @@ const fieldRootContext = () => {
25
28
  filledState: root.filledState,
26
29
  focusedState: root.focusedState,
27
30
  setControlId: (id) => root.controlId.set(id),
28
- addDescriptionId: (id) => root.descriptionIds.update((ids) => addId(ids, id)),
29
- removeDescriptionId: (id) => root.descriptionIds.update((ids) => removeId(ids, id)),
30
- addErrorId: (id) => root.errorIds.update((ids) => addId(ids, id)),
31
- removeErrorId: (id) => root.errorIds.update((ids) => removeId(ids, id)),
31
+ addDescriptionId: (id) => root.descriptionIds.update((ids) => addId$1(ids, id)),
32
+ removeDescriptionId: (id) => root.descriptionIds.update((ids) => removeId$1(ids, id)),
33
+ addErrorId: (id) => root.errorIds.update((ids) => addId$1(ids, id)),
34
+ removeErrorId: (id) => root.errorIds.update((ids) => removeId$1(ids, id)),
32
35
  setFocused: (value) => root.focusedValue.set(value),
33
36
  setFilled: (value) => root.filledValue.set(value),
34
37
  setDirty: (value) => root.dirtyValue.set(value),
@@ -40,6 +43,7 @@ const fieldRootContext = () => {
40
43
  * provider so adapters can restore it on teardown.
41
44
  */
42
45
  setStateProvider: (provider) => root.setStateProvider(provider),
46
+ clearStateProvider: (provider, previous) => root.clearStateProvider(provider, previous),
43
47
  hasStateProvider: root.hasStateProvider
44
48
  };
45
49
  };
@@ -108,6 +112,14 @@ class RdxFieldRoot {
108
112
  */
109
113
  this.name = input(/* @ts-ignore */
110
114
  ...(ngDevMode ? [undefined, { debugName: "name" }] : /* istanbul ignore next */ []));
115
+ /**
116
+ * Overrides when this field reveals its validity (error styling + message). Falls back to the
117
+ * enclosing `rdxFormRoot`'s `validationMode`, then `'onBlur'`. Server errors always show regardless.
118
+ *
119
+ * @group Props
120
+ */
121
+ this.validationMode = input(/* @ts-ignore */
122
+ ...(ngDevMode ? [undefined, { debugName: "validationMode" }] : /* istanbul ignore next */ []));
111
123
  /** The enclosing Form, if any. All Form-related behavior is a no-op when this is `null`. */
112
124
  this.formContext = injectFormRootContext(true);
113
125
  this.controlId = signal(injectId('rdx-field-control-'), /* @ts-ignore */
@@ -133,37 +145,108 @@ class RdxFieldRoot {
133
145
  /** Error content from a registered state provider (e.g. a Signal Forms adapter). */
134
146
  this.providerErrors = computed(() => this.stateProvider()?.errors?.() ?? [], /* @ts-ignore */
135
147
  ...(ngDevMode ? [{ debugName: "providerErrors" }] : /* istanbul ignore next */ []));
136
- /** External messages from the enclosing Form matched by this field's `name`. */
137
- this.externalErrors = computed(() => this.formContext?.errorsFor(this.name()) ?? [], /* @ts-ignore */
138
- ...(ngDevMode ? [{ debugName: "externalErrors" }] : /* istanbul ignore next */ []));
139
- /** Provider messages first (`message ?? kind`), then the Form's external messages. */
140
- this.messages = computed(() => [
141
- ...this.providerErrors().map((error) => error.message ?? error.kind),
142
- ...this.externalErrors()
143
- ], /* @ts-ignore */
148
+ /** **Client** validation errors routed by a form-level provider (`rdxSignalForm`) gated by `validationMode`. */
149
+ this.clientErrors = computed(() => this.formContext?.clientErrorsFor(this.name()) ?? [], /* @ts-ignore */
150
+ ...(ngDevMode ? [{ debugName: "clientErrors" }] : /* istanbul ignore next */ []));
151
+ /** **Server/external** errors from the Form's `errors` input shown eagerly (never gated). */
152
+ this.serverErrors = computed(() => this.formContext?.externalErrorsFor(this.name()) ?? [], /* @ts-ignore */
153
+ ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
154
+ /** Whether the enclosing Form has had a submit attempted; `false` when standalone. A presentation
155
+ * seam an adapter can read to reveal errors after a submit attempt (Base UI's submit-attempt state). */
156
+ this.formSubmitAttempted = computed(() => this.formContext?.submitAttempted() ?? false, /* @ts-ignore */
157
+ ...(ngDevMode ? [{ debugName: "formSubmitAttempted" }] : /* istanbul ignore next */ []));
158
+ /**
159
+ * Client messages first (per-field `rdxSignalField` provider, then form-level `rdxSignalForm`
160
+ * name-routing) — only once `validationMode` reveals client validity — then the Form's **server**
161
+ * messages (the `errors` input), which always show. **Deduped by text**: a field can receive the same
162
+ * Signal Forms error from both a per-field provider and the form-level routing (they read the same
163
+ * field state), so deduping renders it once while still surfacing distinct messages from each source.
164
+ */
165
+ this.messages = computed(() => {
166
+ // **Client** messages (per-field `rdxSignalField` provider + form-level `rdxSignalForm` routing)
167
+ // surface only once the field's `validationMode` reveals them; **server** messages (the `errors`
168
+ // input) always show. So a neutral field stays empty and the polite live region announces on reveal.
169
+ const client = this.validationRevealed()
170
+ ? [...this.providerErrors().map((error) => error.message ?? error.kind), ...this.clientErrors()]
171
+ : [];
172
+ const seen = new Set();
173
+ const result = [];
174
+ for (const message of [...client, ...this.serverErrors()]) {
175
+ if (message && !seen.has(message)) {
176
+ seen.add(message);
177
+ result.push(message);
178
+ }
179
+ }
180
+ return result;
181
+ }, /* @ts-ignore */
144
182
  ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
145
- // External errors (provider content or Form server errors) force invalid regardless of the input or
146
- // a provider's `invalid` accessor a server error must show even when client-side validity passes.
147
- this.invalidState = computed(() => {
148
- if (this.externalErrors().length > 0 || this.providerErrors().length > 0) {
149
- return true;
183
+ /** Effective validation-display mode: this field's override the enclosing Form's the default. */
184
+ this.effectiveValidationMode = computed(() => this.validationMode() ?? this.formContext?.validationMode() ?? RDX_DEFAULT_VALIDATION_MODE, /* @ts-ignore */
185
+ ...(ngDevMode ? [{ debugName: "effectiveValidationMode" }] : /* istanbul ignore next */ []));
186
+ /** Whether client-side validity is revealed yet, per {@link effectiveValidationMode} + interaction. */
187
+ this.validationRevealed = computed(() => isValidationRevealed(this.effectiveValidationMode(), {
188
+ touched: this.touchedState(),
189
+ dirty: this.dirtyState(),
190
+ submitAttempted: this.formSubmitAttempted()
191
+ }), /* @ts-ignore */
192
+ ...(ngDevMode ? [{ debugName: "validationRevealed" }] : /* istanbul ignore next */ []));
193
+ /**
194
+ * Client-side invalidity (gated by `validationMode`): the per-field provider's error content / `invalid`
195
+ * (`rdxSignalField`), a form-level provider's name-routed errors (`rdxSignalForm`), or the `[invalid]`
196
+ * input. Excludes server errors (which are eager).
197
+ */
198
+ this.clientInvalidState = computed(() => this.providerErrors().length > 0 ||
199
+ this.clientErrors().length > 0 ||
200
+ this.resolve('invalid', () => this.invalid()), /* @ts-ignore */
201
+ ...(ngDevMode ? [{ debugName: "clientInvalidState" }] : /* istanbul ignore next */ []));
202
+ /**
203
+ * Tri-state *displayed* validity (`boolean | null`) — the source for `data-valid` / `data-invalid`.
204
+ * Server errors show immediately; client-side validity (including `rdxSignalForm` name-routing) stays
205
+ * **neutral** (`null`) until the field's {@link effectiveValidationMode} reveals it, then `false`/`true`.
206
+ */
207
+ this.validState = computed(() => {
208
+ if (this.serverErrors().length > 0) {
209
+ return false;
210
+ }
211
+ if (!this.validationRevealed()) {
212
+ return null;
150
213
  }
151
- return this.resolve('invalid', () => this.invalid());
214
+ return this.clientInvalidState() ? false : true;
152
215
  }, /* @ts-ignore */
216
+ ...(ngDevMode ? [{ debugName: "validState" }] : /* istanbul ignore next */ []));
217
+ /**
218
+ * Boolean **displayed** invalidity (= `validState() === false`), used by `rdxFieldError` (hidden) and
219
+ * `rdxFieldControl`'s `aria-describedby` error linking. A neutral (`null`) `validState` reads as not
220
+ * invalid here, so the error region stays hidden until the field's mode reveals it.
221
+ */
222
+ this.invalidState = computed(() => this.validState() === false, /* @ts-ignore */
153
223
  ...(ngDevMode ? [{ debugName: "invalidState" }] : /* istanbul ignore next */ []));
224
+ /**
225
+ * Boolean **actual** invalidity — ungated by the display mode. The enclosing Form aggregates this into
226
+ * `anyInvalid` (→ submit-block / focus-first-invalid), so a field that is really invalid but displayed
227
+ * neutral still blocks submit. The form's presentation `data-invalid` comes from the *displayed*
228
+ * aggregate (`anyDisplayedInvalid`) instead — not from this. Server errors + provider error content +
229
+ * the provider/input `invalid`.
230
+ */
231
+ this.actualInvalidState = computed(() => this.serverErrors().length > 0 || this.clientInvalidState(), /* @ts-ignore */
232
+ ...(ngDevMode ? [{ debugName: "actualInvalidState" }] : /* istanbul ignore next */ []));
154
233
  this.disabledState = computed(() => this.resolve('disabled', () => this.disabled()), /* @ts-ignore */
155
234
  ...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
156
235
  this.requiredState = computed(() => this.resolve('required', () => this.required()), /* @ts-ignore */
157
236
  ...(ngDevMode ? [{ debugName: "requiredState" }] : /* istanbul ignore next */ []));
158
- this.dirtyState = computed(() => this.resolve('dirty', () => this.dirty() || this.dirtyValue()), /* @ts-ignore */
237
+ // `touched`/`dirty` also OR in the enclosing Form's per-name state (`rdxSignalForm` name-routing), so a
238
+ // field with only a bare `[formField]` (no `rdxSignalField`/`rdxFieldControl`) still reveals on blur.
239
+ this.dirtyState = computed(() => this.resolve('dirty', () => this.dirty() || this.dirtyValue()) ||
240
+ (this.formContext?.dirtyFor(this.name()) ?? false), /* @ts-ignore */
159
241
  ...(ngDevMode ? [{ debugName: "dirtyState" }] : /* istanbul ignore next */ []));
160
- this.touchedState = computed(() => this.resolve('touched', () => this.touched() || this.touchedValue()), /* @ts-ignore */
242
+ this.touchedState = computed(() => this.resolve('touched', () => this.touched() || this.touchedValue()) ||
243
+ (this.formContext?.touchedFor(this.name()) ?? false), /* @ts-ignore */
161
244
  ...(ngDevMode ? [{ debugName: "touchedState" }] : /* istanbul ignore next */ []));
162
245
  this.filledState = computed(() => this.resolve('filled', () => this.filled() ?? this.filledValue()), /* @ts-ignore */
163
246
  ...(ngDevMode ? [{ debugName: "filledState" }] : /* istanbul ignore next */ []));
164
247
  this.focusedState = computed(() => this.resolve('focused', () => this.focused() ?? this.focusedValue()), /* @ts-ignore */
165
248
  ...(ngDevMode ? [{ debugName: "focusedState" }] : /* istanbul ignore next */ []));
166
- this.dataAttr = attr$4;
249
+ this.dataAttr = attr$5;
167
250
  this.host = inject(ElementRef).nativeElement;
168
251
  // Register with an enclosing Form (if any) for aggregate state, submit guard, and reset; a
169
252
  // standalone field never enters this branch and behaves exactly as before.
@@ -171,7 +254,11 @@ class RdxFieldRoot {
171
254
  if (formContext) {
172
255
  const registration = {
173
256
  name: () => this.name(),
174
- invalid: () => this.invalidState(),
257
+ // `invalid` reports *actual* validity (ungated) `form.anyInvalid` + the submit guard must
258
+ // reflect real state even before a field reveals it. `displayValid` reports the *gated*
259
+ // tri-state so the form's presentation `data-invalid` stays neutral on load.
260
+ invalid: () => this.actualInvalidState(),
261
+ displayValid: () => this.validState(),
175
262
  dirty: () => this.dirtyState(),
176
263
  touched: () => this.touchedState(),
177
264
  focus: () => this.focusControl(),
@@ -215,26 +302,47 @@ class RdxFieldRoot {
215
302
  this.stateProvider.set(provider);
216
303
  return previous;
217
304
  }
305
+ /**
306
+ * Identity-checked teardown — roll back to `previous` only if `provider` is still active. Prevents
307
+ * an old adapter's destroy from clobbering a newer adapter that registered after it (create-before-
308
+ * destroy during a structural view swap).
309
+ */
310
+ clearStateProvider(provider, previous) {
311
+ if (this.stateProvider() === provider) {
312
+ this.stateProvider.set(previous);
313
+ }
314
+ }
218
315
  /**
219
316
  * Prefer the registered provider's value for `key` when it exposes one,
220
- * otherwise fall back to the root inputs / DOM-derived signals.
317
+ * otherwise fall back to the root inputs / DOM-derived signals. `errors` (content) and `valid`
318
+ * (tri-state `boolean | null`) are resolved separately, so they're excluded from this boolean key.
221
319
  */
222
320
  resolve(key, fallback) {
223
321
  const accessor = this.stateProvider()?.[key];
224
322
  return accessor ? accessor() : fallback();
225
323
  }
226
324
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldRoot, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
227
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxFieldRoot, isStandalone: true, selector: "[rdxFieldRoot]", inputs: { invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, dirty: { classPropertyName: "dirty", publicName: "dirty", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null }, filled: { classPropertyName: "filled", publicName: "filled", isSignal: true, isRequired: false, transformFunction: null }, focused: { classPropertyName: "focused", publicName: "focused", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "input": "notifyEdited()", "change": "notifyEdited()" }, properties: { "attr.data-invalid": "dataAttr(invalidState())", "attr.data-valid": "dataAttr(!invalidState())", "attr.data-disabled": "dataAttr(disabledState())", "attr.data-required": "dataAttr(requiredState())", "attr.data-dirty": "dataAttr(dirtyState())", "attr.data-touched": "dataAttr(touchedState())", "attr.data-filled": "dataAttr(filledState())", "attr.data-focused": "dataAttr(focusedState())" } }, providers: [provideFieldRootContext(fieldRootContext)], exportAs: ["rdxFieldRoot"], ngImport: i0 }); }
325
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxFieldRoot, isStandalone: true, selector: "[rdxFieldRoot]", inputs: { invalid: { classPropertyName: "invalid", publicName: "invalid", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: true, isRequired: false, transformFunction: null }, dirty: { classPropertyName: "dirty", publicName: "dirty", isSignal: true, isRequired: false, transformFunction: null }, touched: { classPropertyName: "touched", publicName: "touched", isSignal: true, isRequired: false, transformFunction: null }, filled: { classPropertyName: "filled", publicName: "filled", isSignal: true, isRequired: false, transformFunction: null }, focused: { classPropertyName: "focused", publicName: "focused", isSignal: true, isRequired: false, transformFunction: null }, name: { classPropertyName: "name", publicName: "name", isSignal: true, isRequired: false, transformFunction: null }, validationMode: { classPropertyName: "validationMode", publicName: "validationMode", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "input": "notifyEdited()", "change": "notifyEdited()" }, properties: { "attr.data-invalid": "dataAttr(validState() === false)", "attr.data-valid": "dataAttr(validState() === true)", "attr.data-disabled": "dataAttr(disabledState())", "attr.data-required": "dataAttr(requiredState())", "attr.data-dirty": "dataAttr(dirtyState())", "attr.data-touched": "dataAttr(touchedState())", "attr.data-filled": "dataAttr(filledState())", "attr.data-focused": "dataAttr(focusedState())" } }, providers: [
326
+ provideFieldRootContext(fieldRootContext),
327
+ // Expose the tri-state display validity so controls inside the field reflect it (single source).
328
+ { provide: RDX_FIELD_VALIDITY, useFactory: () => inject(RdxFieldRoot).validState }
329
+ ], exportAs: ["rdxFieldRoot"], ngImport: i0 }); }
228
330
  }
229
331
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldRoot, decorators: [{
230
332
  type: Directive,
231
333
  args: [{
232
334
  selector: '[rdxFieldRoot]',
233
335
  exportAs: 'rdxFieldRoot',
234
- providers: [provideFieldRootContext(fieldRootContext)],
336
+ providers: [
337
+ provideFieldRootContext(fieldRootContext),
338
+ // Expose the tri-state display validity so controls inside the field reflect it (single source).
339
+ { provide: RDX_FIELD_VALIDITY, useFactory: () => inject(RdxFieldRoot).validState }
340
+ ],
235
341
  host: {
236
- '[attr.data-invalid]': 'dataAttr(invalidState())',
237
- '[attr.data-valid]': 'dataAttr(!invalidState())',
342
+ // Tri-state: `data-invalid` when displayed-invalid, `data-valid` only when displayed-valid,
343
+ // and **neither** when neutral (`validState()` is `null`).
344
+ '[attr.data-invalid]': 'dataAttr(validState() === false)',
345
+ '[attr.data-valid]': 'dataAttr(validState() === true)',
238
346
  '[attr.data-disabled]': 'dataAttr(disabledState())',
239
347
  '[attr.data-required]': 'dataAttr(requiredState())',
240
348
  '[attr.data-dirty]': 'dataAttr(dirtyState())',
@@ -245,12 +353,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
245
353
  '(change)': 'notifyEdited()'
246
354
  }
247
355
  }]
248
- }], ctorParameters: () => [], propDecorators: { invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], dirty: [{ type: i0.Input, args: [{ isSignal: true, alias: "dirty", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }], filled: [{ type: i0.Input, args: [{ isSignal: true, alias: "filled", required: false }] }], focused: [{ type: i0.Input, args: [{ isSignal: true, alias: "focused", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }] } });
356
+ }], ctorParameters: () => [], propDecorators: { invalid: [{ type: i0.Input, args: [{ isSignal: true, alias: "invalid", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], dirty: [{ type: i0.Input, args: [{ isSignal: true, alias: "dirty", required: false }] }], touched: [{ type: i0.Input, args: [{ isSignal: true, alias: "touched", required: false }] }], filled: [{ type: i0.Input, args: [{ isSignal: true, alias: "filled", required: false }] }], focused: [{ type: i0.Input, args: [{ isSignal: true, alias: "focused", required: false }] }], name: [{ type: i0.Input, args: [{ isSignal: true, alias: "name", required: false }] }], validationMode: [{ type: i0.Input, args: [{ isSignal: true, alias: "validationMode", required: false }] }] } });
249
357
  function injectFieldRoot() {
250
358
  return inject(RdxFieldRoot);
251
359
  }
252
360
 
253
- const attr$3 = (value) => (value ? '' : undefined);
361
+ const attr$4 = (value) => (value ? '' : undefined);
254
362
  /**
255
363
  * Connects a form control to the field label, description, error, and state.
256
364
  *
@@ -276,7 +384,7 @@ class RdxFieldControl {
276
384
  return ids.length ? ids.join(' ') : undefined;
277
385
  }, /* @ts-ignore */
278
386
  ...(ngDevMode ? [{ debugName: "describedBy" }] : /* istanbul ignore next */ []));
279
- this.dataAttr = attr$3;
387
+ this.dataAttr = attr$4;
280
388
  effect(() => {
281
389
  this.rootContext.setControlId(this.id());
282
390
  });
@@ -305,7 +413,7 @@ class RdxFieldControl {
305
413
  return element.value ?? '';
306
414
  }
307
415
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldControl, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
308
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxFieldControl, isStandalone: true, selector: "[rdxFieldControl]", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "focus": "onFocus()", "blur": "onBlur()", "input": "syncFilled()", "change": "syncFilled()" }, properties: { "attr.id": "id()", "attr.aria-describedby": "describedBy()", "attr.aria-invalid": "rootContext.invalidState() ? \"true\" : undefined", "attr.aria-required": "!isNativeFormControl() && rootContext.requiredState() ? \"true\" : undefined", "attr.aria-disabled": "!isNativeFormControl() && rootContext.disabledState() ? \"true\" : undefined", "attr.disabled": "isNativeFormControl() && rootContext.disabledState() ? \"\" : undefined", "attr.required": "isNativeFormControl() && rootContext.requiredState() ? \"\" : undefined", "attr.data-invalid": "dataAttr(rootContext.invalidState())", "attr.data-valid": "dataAttr(!rootContext.invalidState())", "attr.data-disabled": "dataAttr(rootContext.disabledState())", "attr.data-required": "dataAttr(rootContext.requiredState())", "attr.data-dirty": "dataAttr(rootContext.dirtyState())", "attr.data-touched": "dataAttr(rootContext.touchedState())", "attr.data-filled": "dataAttr(rootContext.filledState())", "attr.data-focused": "dataAttr(rootContext.focusedState())" } }, exportAs: ["rdxFieldControl"], ngImport: i0 }); }
416
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxFieldControl, isStandalone: true, selector: "[rdxFieldControl]", inputs: { id: { classPropertyName: "id", publicName: "id", isSignal: true, isRequired: false, transformFunction: null } }, host: { listeners: { "focus": "onFocus()", "blur": "onBlur()", "input": "syncFilled()", "change": "syncFilled()" }, properties: { "attr.id": "id()", "attr.aria-describedby": "describedBy()", "attr.aria-invalid": "rootContext.validState() === false ? \"true\" : undefined", "attr.aria-required": "!isNativeFormControl() && rootContext.requiredState() ? \"true\" : undefined", "attr.aria-disabled": "!isNativeFormControl() && rootContext.disabledState() ? \"true\" : undefined", "attr.disabled": "isNativeFormControl() && rootContext.disabledState() ? \"\" : undefined", "attr.required": "isNativeFormControl() && rootContext.requiredState() ? \"\" : undefined", "attr.data-invalid": "dataAttr(rootContext.validState() === false)", "attr.data-valid": "dataAttr(rootContext.validState() === true)", "attr.data-disabled": "dataAttr(rootContext.disabledState())", "attr.data-required": "dataAttr(rootContext.requiredState())", "attr.data-dirty": "dataAttr(rootContext.dirtyState())", "attr.data-touched": "dataAttr(rootContext.touchedState())", "attr.data-filled": "dataAttr(rootContext.filledState())", "attr.data-focused": "dataAttr(rootContext.focusedState())" } }, exportAs: ["rdxFieldControl"], ngImport: i0 }); }
309
417
  }
310
418
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldControl, decorators: [{
311
419
  type: Directive,
@@ -315,15 +423,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
315
423
  host: {
316
424
  '[attr.id]': 'id()',
317
425
  '[attr.aria-describedby]': 'describedBy()',
318
- '[attr.aria-invalid]': 'rootContext.invalidState() ? "true" : undefined',
426
+ // `aria-invalid`/`data-*` follow the field's tri-state `validState`: while it is neutral (`null`,
427
+ // e.g. a field whose `validationMode` defers display (e.g. `onBlur`) before interaction) the control emits no `aria-invalid`
428
+ // and neither `data-valid` nor `data-invalid`, matching the field root.
429
+ '[attr.aria-invalid]': 'rootContext.validState() === false ? "true" : undefined',
319
430
  // On a native control the native `required`/`disabled` attributes already convey the state, so
320
431
  // `aria-required`/`aria-disabled` are only emitted on non-native (custom) controls.
321
432
  '[attr.aria-required]': '!isNativeFormControl() && rootContext.requiredState() ? "true" : undefined',
322
433
  '[attr.aria-disabled]': '!isNativeFormControl() && rootContext.disabledState() ? "true" : undefined',
323
434
  '[attr.disabled]': 'isNativeFormControl() && rootContext.disabledState() ? "" : undefined',
324
435
  '[attr.required]': 'isNativeFormControl() && rootContext.requiredState() ? "" : undefined',
325
- '[attr.data-invalid]': 'dataAttr(rootContext.invalidState())',
326
- '[attr.data-valid]': 'dataAttr(!rootContext.invalidState())',
436
+ '[attr.data-invalid]': 'dataAttr(rootContext.validState() === false)',
437
+ '[attr.data-valid]': 'dataAttr(rootContext.validState() === true)',
327
438
  '[attr.data-disabled]': 'dataAttr(rootContext.disabledState())',
328
439
  '[attr.data-required]': 'dataAttr(rootContext.requiredState())',
329
440
  '[attr.data-dirty]': 'dataAttr(rootContext.dirtyState())',
@@ -338,7 +449,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
338
449
  }]
339
450
  }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }] } });
340
451
 
341
- const attr$2 = (value) => (value ? '' : undefined);
452
+ const attr$3 = (value) => (value ? '' : undefined);
342
453
  /**
343
454
  * Describes the field control.
344
455
  *
@@ -354,7 +465,7 @@ class RdxFieldDescription {
354
465
  */
355
466
  this.id = input(injectId('rdx-field-description-'), /* @ts-ignore */
356
467
  ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
357
- this.dataAttr = attr$2;
468
+ this.dataAttr = attr$3;
358
469
  effect((onCleanup) => {
359
470
  const id = this.id();
360
471
  this.rootContext.addDescriptionId(id);
@@ -377,7 +488,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
377
488
  }]
378
489
  }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }] } });
379
490
 
380
- const attr$1 = (value) => (value ? '' : undefined);
491
+ const attr$2 = (value) => (value ? '' : undefined);
381
492
  /**
382
493
  * Describes an invalid field control.
383
494
  *
@@ -394,12 +505,13 @@ class RdxFieldError {
394
505
  this.id = input(injectId('rdx-field-error-'), /* @ts-ignore */
395
506
  ...(ngDevMode ? [{ debugName: "id" }] : /* istanbul ignore next */ []));
396
507
  /**
397
- * The field's external messages (state provider's, then enclosing Form's), `[]` when none. Render
398
- * them explicitly via the `exportAs` reference the directive never injects text content itself:
508
+ * The field's validation messages — client (provider / form name-routing, once `validationMode`
509
+ * reveals them) then server (the Form's `errors` input, always); `[]` when none. Render them
510
+ * explicitly via the `exportAs` reference — the directive never injects text content itself:
399
511
  * `<p rdxFieldError #err="rdxFieldError">{{ err.messages().join(' ') }}</p>`.
400
512
  */
401
513
  this.messages = this.rootContext.messages;
402
- this.dataAttr = attr$1;
514
+ this.dataAttr = attr$2;
403
515
  effect((onCleanup) => {
404
516
  const id = this.id();
405
517
  this.rootContext.addErrorId(id);
@@ -424,6 +536,113 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
424
536
  }]
425
537
  }], ctorParameters: () => [], propDecorators: { id: [{ type: i0.Input, args: [{ isSignal: true, alias: "id", required: false }] }] } });
426
538
 
539
+ const attr$1 = (value) => (value ? '' : undefined);
540
+ const addId = (ids, id) => (ids.includes(id) ? ids : [...ids, id]);
541
+ const removeId = (ids, id) => ids.filter((item) => item !== id);
542
+ /**
543
+ * The field item context **re-provides** {@link RdxFieldRootContext} so a label, description, and control
544
+ * placed inside the item associate with the **item's** control (own `controlId` / `descriptionIds`),
545
+ * while all validation state (`invalidState` / `dirtyState` / `touchedState` / `filledState` /
546
+ * `focusedState` / `requiredState`, errors and messages) is **delegated to the enclosing `rdxFieldRoot`**.
547
+ * `disabledState` is the item's `disabled` OR'd with the root's. The enclosing root is reached with
548
+ * `skipSelf` (the item provides the same token), so there is no circular injection.
549
+ */
550
+ const fieldItemContext = () => {
551
+ const item = inject(RdxFieldItem);
552
+ const root = item.root;
553
+ return {
554
+ controlId: item.controlId,
555
+ name: root.name,
556
+ descriptionIds: item.descriptionIds,
557
+ errorIds: root.errorIds,
558
+ messages: root.messages,
559
+ notifyEdited: () => root.notifyEdited(),
560
+ validState: root.validState,
561
+ formSubmitAttempted: root.formSubmitAttempted,
562
+ invalidState: root.invalidState,
563
+ disabledState: item.disabledState,
564
+ requiredState: root.requiredState,
565
+ dirtyState: root.dirtyState,
566
+ touchedState: root.touchedState,
567
+ filledState: root.filledState,
568
+ focusedState: root.focusedState,
569
+ setControlId: (id) => item.controlId.set(id),
570
+ addDescriptionId: (id) => item.descriptionIds.update((ids) => addId(ids, id)),
571
+ removeDescriptionId: (id) => item.descriptionIds.update((ids) => removeId(ids, id)),
572
+ addErrorId: (id) => root.errorIds.update((ids) => addId(ids, id)),
573
+ removeErrorId: (id) => root.errorIds.update((ids) => removeId(ids, id)),
574
+ setFocused: (value) => root.focusedValue.set(value),
575
+ setFilled: (value) => root.filledValue.set(value),
576
+ setDirty: (value) => root.dirtyValue.set(value),
577
+ setTouched: (value) => root.touchedValue.set(value),
578
+ setStateProvider: (provider) => root.setStateProvider(provider),
579
+ clearStateProvider: (provider, previous) => root.clearStateProvider(provider, previous),
580
+ hasStateProvider: root.hasStateProvider
581
+ };
582
+ };
583
+ /**
584
+ * Groups an individual item in a checkbox group or radio group with its own label and description.
585
+ *
586
+ * Use inside a `rdxFieldRoot` (typically wrapping a group) when each control needs its own label /
587
+ * description: the item scopes those associations to its control while reflecting the field's validation
588
+ * state. Mirrors Base UI's `Field.Item`.
589
+ *
590
+ * @group Components
591
+ */
592
+ class RdxFieldItem {
593
+ constructor() {
594
+ /** The enclosing field root (items are leaves under the root, so `skipSelf` reaches it). */
595
+ this.root = inject(RdxFieldRoot, { skipSelf: true });
596
+ /**
597
+ * Whether the wrapped control should ignore user interaction. The `disabled` on `rdxFieldRoot` takes
598
+ * precedence (it is OR'd in).
599
+ *
600
+ * @group Props
601
+ * @defaultValue false
602
+ */
603
+ this.disabled = input(false, { ...(ngDevMode ? { debugName: "disabled" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
604
+ /** This item's control id — label / description / control inside the item associate with it. */
605
+ this.controlId = signal(injectId('rdx-field-item-control-'), /* @ts-ignore */
606
+ ...(ngDevMode ? [{ debugName: "controlId" }] : /* istanbul ignore next */ []));
607
+ /** Description ids for this item's control `aria-describedby`. */
608
+ this.descriptionIds = signal([], /* @ts-ignore */
609
+ ...(ngDevMode ? [{ debugName: "descriptionIds" }] : /* istanbul ignore next */ []));
610
+ /** The item's effective disabled state: the root's disabled OR the item's. */
611
+ this.disabledState = computed(() => this.root.disabledState() || this.disabled(), /* @ts-ignore */
612
+ ...(ngDevMode ? [{ debugName: "disabledState" }] : /* istanbul ignore next */ []));
613
+ this.dataAttr = attr$1;
614
+ }
615
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldItem, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
616
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "22.0.2", type: RdxFieldItem, isStandalone: true, selector: "[rdxFieldItem]", inputs: { disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: true, isRequired: false, transformFunction: null } }, host: { properties: { "attr.data-invalid": "dataAttr(root.validState() === false)", "attr.data-valid": "dataAttr(root.validState() === true)", "attr.data-disabled": "dataAttr(disabledState())", "attr.data-required": "dataAttr(root.requiredState())", "attr.data-dirty": "dataAttr(root.dirtyState())", "attr.data-touched": "dataAttr(root.touchedState())", "attr.data-filled": "dataAttr(root.filledState())", "attr.data-focused": "dataAttr(root.focusedState())" } }, providers: [
617
+ provideFieldRootContext(fieldItemContext),
618
+ // Items delegate validity to the root; expose the root's tri-state to controls inside the item.
619
+ { provide: RDX_FIELD_VALIDITY, useFactory: () => inject(RdxFieldItem).root.validState }
620
+ ], exportAs: ["rdxFieldItem"], ngImport: i0 }); }
621
+ }
622
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImport: i0, type: RdxFieldItem, decorators: [{
623
+ type: Directive,
624
+ args: [{
625
+ selector: '[rdxFieldItem]',
626
+ exportAs: 'rdxFieldItem',
627
+ providers: [
628
+ provideFieldRootContext(fieldItemContext),
629
+ // Items delegate validity to the root; expose the root's tri-state to controls inside the item.
630
+ { provide: RDX_FIELD_VALIDITY, useFactory: () => inject(RdxFieldItem).root.validState }
631
+ ],
632
+ host: {
633
+ // Tri-state, mirroring the root: neither attribute while the root's `validState` is neutral.
634
+ '[attr.data-invalid]': 'dataAttr(root.validState() === false)',
635
+ '[attr.data-valid]': 'dataAttr(root.validState() === true)',
636
+ '[attr.data-disabled]': 'dataAttr(disabledState())',
637
+ '[attr.data-required]': 'dataAttr(root.requiredState())',
638
+ '[attr.data-dirty]': 'dataAttr(root.dirtyState())',
639
+ '[attr.data-touched]': 'dataAttr(root.touchedState())',
640
+ '[attr.data-filled]': 'dataAttr(root.filledState())',
641
+ '[attr.data-focused]': 'dataAttr(root.focusedState())'
642
+ }
643
+ }]
644
+ }], propDecorators: { disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }] } });
645
+
427
646
  const attr = (value) => (value ? '' : undefined);
428
647
  /**
429
648
  * Labels the field control.
@@ -466,5 +685,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.2", ngImpor
466
685
  * Generated bundle index. Do not edit.
467
686
  */
468
687
 
469
- export { RdxFieldControl, RdxFieldDescription, RdxFieldError, RdxFieldLabel, RdxFieldRoot, injectFieldRootContext, provideFieldRootContext };
688
+ export { RdxFieldControl, RdxFieldDescription, RdxFieldError, RdxFieldItem, RdxFieldLabel, RdxFieldRoot, injectFieldRootContext, provideFieldRootContext };
470
689
  //# sourceMappingURL=radix-ng-primitives-field.mjs.map