@spartan-ng/cli 0.0.1-alpha.657 → 0.0.1-alpha.658

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 (63) hide show
  1. package/package.json +1 -1
  2. package/src/generators/healthcheck/generator.js +2 -0
  3. package/src/generators/healthcheck/generator.js.map +1 -1
  4. package/src/generators/healthcheck/healthchecks/hlm-form-field.d.ts +2 -0
  5. package/src/generators/healthcheck/healthchecks/hlm-form-field.js +35 -0
  6. package/src/generators/healthcheck/healthchecks/hlm-form-field.js.map +1 -0
  7. package/src/generators/migrate-form-field/compat.d.ts +2 -0
  8. package/src/generators/migrate-form-field/compat.js +6 -0
  9. package/src/generators/migrate-form-field/compat.js.map +1 -0
  10. package/src/generators/migrate-form-field/generator.d.ts +4 -0
  11. package/src/generators/migrate-form-field/generator.js +60 -0
  12. package/src/generators/migrate-form-field/generator.js.map +1 -0
  13. package/src/generators/migrate-form-field/schema.d.ts +4 -0
  14. package/src/generators/migrate-form-field/schema.json +19 -0
  15. package/src/generators/ui/libs/autocomplete/files/lib/hlm-autocomplete-input.ts.template +9 -4
  16. package/src/generators/ui/libs/badge/files/lib/hlm-badge.ts.template +7 -8
  17. package/src/generators/ui/libs/card/files/lib/hlm-card-action.ts.template +1 -1
  18. package/src/generators/ui/libs/card/files/lib/hlm-card-content.ts.template +1 -1
  19. package/src/generators/ui/libs/card/files/lib/hlm-card-description.ts.template +1 -1
  20. package/src/generators/ui/libs/card/files/lib/hlm-card-footer.ts.template +1 -4
  21. package/src/generators/ui/libs/card/files/lib/hlm-card-header.ts.template +1 -1
  22. package/src/generators/ui/libs/card/files/lib/hlm-card-title.ts.template +1 -1
  23. package/src/generators/ui/libs/card/files/lib/hlm-card.ts.template +1 -4
  24. package/src/generators/ui/libs/checkbox/files/lib/hlm-checkbox.ts.template +18 -2
  25. package/src/generators/ui/libs/combobox/files/lib/hlm-combobox-chip-input.ts.template +1 -1
  26. package/src/generators/ui/libs/combobox/files/lib/hlm-combobox-chips.ts.template +22 -7
  27. package/src/generators/ui/libs/combobox/files/lib/hlm-combobox-input.ts.template +8 -4
  28. package/src/generators/ui/libs/combobox/files/lib/hlm-combobox-trigger.ts.template +10 -1
  29. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker-multi.ts.template +38 -5
  30. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-picker.ts.template +36 -6
  31. package/src/generators/ui/libs/date-picker/files/lib/hlm-date-range-picker.ts.template +33 -4
  32. package/src/generators/ui/libs/field/files/index.ts.template +3 -3
  33. package/src/generators/ui/libs/field/files/lib/hlm-field-content.ts.template +1 -1
  34. package/src/generators/ui/libs/field/files/lib/hlm-field-description.ts.template +40 -2
  35. package/src/generators/ui/libs/field/files/lib/hlm-field-error.ts.template +89 -27
  36. package/src/generators/ui/libs/field/files/lib/hlm-field-group.ts.template +1 -1
  37. package/src/generators/ui/libs/field/files/lib/hlm-field-legend.ts.template +2 -2
  38. package/src/generators/ui/libs/field/files/lib/hlm-field.ts.template +9 -6
  39. package/src/generators/ui/libs/input/files/lib/hlm-input.ts.template +10 -69
  40. package/src/generators/ui/libs/input-group/files/lib/hlm-input-group.ts.template +15 -5
  41. package/src/generators/ui/libs/label/files/lib/hlm-label.ts.template +4 -1
  42. package/src/generators/ui/libs/native-select/files/lib/hlm-native-select.ts.template +32 -13
  43. package/src/generators/ui/libs/radio-group/files/lib/hlm-radio-group.ts.template +18 -2
  44. package/src/generators/ui/libs/radio-group/files/lib/hlm-radio.ts.template +16 -1
  45. package/src/generators/ui/libs/select/files/lib/hlm-select-trigger.ts.template +5 -2
  46. package/src/generators/ui/libs/slider/files/lib/hlm-slider.ts.template +3 -4
  47. package/src/generators/ui/libs/textarea/files/lib/hlm-textarea.ts.template +10 -71
  48. package/src/generators/ui/primitive-deps.js +0 -1
  49. package/src/generators/ui/primitive-deps.js.map +1 -1
  50. package/src/generators/ui/primitives.d.ts +1 -1
  51. package/src/generators/ui/style-lyra.css +16 -16
  52. package/src/generators/ui/style-maia.css +16 -16
  53. package/src/generators/ui/style-mira.css +16 -16
  54. package/src/generators/ui/style-nova.css +16 -16
  55. package/src/generators/ui/style-vega.css +16 -16
  56. package/src/generators/ui/supported-ui-libraries.json +54 -56
  57. package/src/generators/ui/libs/form-field/files/index.ts.template +0 -9
  58. package/src/generators/ui/libs/form-field/files/lib/hlm-error.ts.template +0 -12
  59. package/src/generators/ui/libs/form-field/files/lib/hlm-form-field.ts.template +0 -39
  60. package/src/generators/ui/libs/form-field/files/lib/hlm-hint.ts.template +0 -12
  61. package/src/generators/ui/libs/form-field/generator.d.ts +0 -3
  62. package/src/generators/ui/libs/form-field/generator.js +0 -9
  63. package/src/generators/ui/libs/form-field/generator.js.map +0 -1
@@ -5,18 +5,20 @@ import {
5
5
  Component,
6
6
  computed,
7
7
  forwardRef,
8
+ inject,
8
9
  input,
9
10
  linkedSignal,
10
11
  output,
11
12
  signal,
12
13
  } from '@angular/core';
13
14
  import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
14
- import { provideIcons } from '@ng-icons/core';
15
+ import { NgIcon, provideIcons } from '@ng-icons/core';
15
16
  import { lucideChevronDown } from '@ng-icons/lucide';
16
17
  import type { BrnDialogState } from '@spartan-ng/brain/dialog';
18
+ import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
17
19
  import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
18
20
  import { HlmCalendar } from '<%- importAlias %>/calendar';
19
- import { HlmIconImports } from '<%- importAlias %>/icon';
21
+ import { HlmIcon } from '<%- importAlias %>/icon';
20
22
  import { HlmPopoverImports } from '<%- importAlias %>/popover';
21
23
  import { hlm } from '<%- importAlias %>/utils';
22
24
  import type { ClassValue } from 'clsx';
@@ -32,20 +34,32 @@ let nextId = 0;
32
34
 
33
35
  @Component({
34
36
  selector: 'hlm-date-picker',
35
- imports: [HlmIconImports, HlmPopoverImports, HlmCalendar],
36
- providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideIcons({ lucideChevronDown })],
37
+ imports: [NgIcon, HlmIcon, BrnFieldControlDescribedBy, HlmPopoverImports, HlmCalendar],
38
+ providers: [HLM_DATE_PICKER_VALUE_ACCESSOR, provideIcons({ lucideChevronDown }), provideBrnLabelable(HlmDatePicker)],
37
39
  changeDetection: ChangeDetectionStrategy.OnPush,
40
+ hostDirectives: [BrnFieldControl],
38
41
  host: {
39
42
  class: 'block',
40
43
  },
41
44
  template: `
42
- <hlm-popover sideOffset="5" [state]="_popoverState()" (stateChanged)="_popoverState.set($event)">
45
+ <hlm-popover
46
+ sideOffset="5"
47
+ [state]="_popoverState()"
48
+ (stateChanged)="_popoverState.set($event)"
49
+ (closed)="_onTouched?.()"
50
+ >
43
51
  <button
44
52
  [id]="buttonId()"
45
53
  type="button"
46
54
  [class]="_computedClass()"
47
55
  [disabled]="_mutableDisabled()"
56
+ [attr.aria-invalid]="_ariaInvalid()"
57
+ [attr.data-invalid]="_ariaInvalid()"
58
+ [attr.data-touched]="_touched?.() ? 'true' : null"
59
+ [attr.data-dirty]="_dirty?.() ? 'true' : null"
60
+ [attr.data-matches-spartan-invalid]="_spartanInvalid?.() ? 'true' : null"
48
61
  hlmPopoverTrigger
62
+ brnFieldControlDescribedBy
49
63
  >
50
64
  <span class="truncate">
51
65
  @if (_formattedDate(); as formattedDate) {
@@ -74,14 +88,28 @@ let nextId = 0;
74
88
  })
75
89
  export class HlmDatePicker<T> implements ControlValueAccessor {
76
90
  private readonly _config = injectHlmDatePickerConfig<T>();
91
+ private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
92
+
93
+ private readonly _invalid = this._fieldControl?.invalid;
94
+ protected readonly _spartanInvalid = this._fieldControl?.spartanInvalid;
95
+ protected readonly _dirty = this._fieldControl?.dirty;
96
+ protected readonly _touched = this._fieldControl?.touched;
97
+
98
+ protected readonly _errorStateClass = computed(() =>
99
+ this._spartanInvalid?.()
100
+ ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40'
101
+ : '',
102
+ );
103
+ protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
77
104
 
78
105
  public readonly userClass = input<ClassValue>('', { alias: 'class' });
79
106
  protected readonly _computedClass = computed(() =>
80
107
  hlm(
81
- 'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 hover:text-accent-foreground inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
108
+ 'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
82
109
  'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
83
110
  'disabled:pointer-events-none disabled:opacity-50',
84
111
  '[&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0',
112
+ this._errorStateClass(),
85
113
  this.userClass(),
86
114
  ),
87
115
  );
@@ -130,6 +158,8 @@ export class HlmDatePicker<T> implements ControlValueAccessor {
130
158
 
131
159
  public readonly dateChange = output<T>();
132
160
 
161
+ public readonly labelableId = this.buttonId;
162
+
133
163
  protected _onChange?: ChangeFn<T>;
134
164
  protected _onTouched?: TouchFn;
135
165
 
@@ -5,6 +5,7 @@ import {
5
5
  Component,
6
6
  computed,
7
7
  forwardRef,
8
+ inject,
8
9
  input,
9
10
  linkedSignal,
10
11
  output,
@@ -15,6 +16,7 @@ import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
15
16
  import { provideIcons } from '@ng-icons/core';
16
17
  import { lucideChevronDown } from '@ng-icons/lucide';
17
18
  import type { BrnDialogState } from '@spartan-ng/brain/dialog';
19
+ import { BrnFieldControl, BrnFieldControlDescribedBy, provideBrnLabelable } from '@spartan-ng/brain/field';
18
20
  import type { ChangeFn, TouchFn } from '@spartan-ng/brain/forms';
19
21
  import { HlmCalendarRange } from '<%- importAlias %>/calendar';
20
22
  import { HlmIconImports } from '<%- importAlias %>/icon';
@@ -33,9 +35,14 @@ let nextId = 0;
33
35
 
34
36
  @Component({
35
37
  selector: 'hlm-date-range-picker',
36
- imports: [HlmIconImports, HlmPopoverImports, HlmCalendarRange],
37
- providers: [HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR, provideIcons({ lucideChevronDown })],
38
+ imports: [HlmIconImports, HlmPopoverImports, HlmCalendarRange, BrnFieldControlDescribedBy],
39
+ providers: [
40
+ HLM_DATE_RANGE_PICKER_VALUE_ACCESSOR,
41
+ provideIcons({ lucideChevronDown }),
42
+ provideBrnLabelable(HlmDateRangePicker),
43
+ ],
38
44
  changeDetection: ChangeDetectionStrategy.OnPush,
45
+ hostDirectives: [BrnFieldControl],
39
46
  host: {
40
47
  class: 'block',
41
48
  },
@@ -44,14 +51,20 @@ let nextId = 0;
44
51
  sideOffset="5"
45
52
  [state]="_popoverState()"
46
53
  (stateChanged)="_popoverState.set($event)"
47
- (closed)="_onClose()"
54
+ (closed)="_onClose(); _onTouched?.()"
48
55
  >
49
56
  <button
50
57
  [id]="buttonId()"
51
58
  type="button"
52
59
  [class]="_computedClass()"
53
60
  [disabled]="_mutableDisabled()"
61
+ [attr.aria-invalid]="_ariaInvalid()"
62
+ [attr.data-invalid]="_ariaInvalid()"
63
+ [attr.data-touched]="_touched?.() ? 'true' : null"
64
+ [attr.data-dirty]="_dirty?.() ? 'true' : null"
65
+ [attr.data-matches-spartan-invalid]="_spartanInvalid?.() ? 'true' : null"
54
66
  hlmPopoverTrigger
67
+ brnFieldControlDescribedBy
55
68
  >
56
69
  <span class="truncate">
57
70
  @if (_formattedDate(); as formattedDate) {
@@ -82,14 +95,28 @@ let nextId = 0;
82
95
  })
83
96
  export class HlmDateRangePicker<T> implements ControlValueAccessor {
84
97
  private readonly _config = injectHlmDateRangePickerConfig<T>();
98
+ private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
99
+
100
+ protected readonly _spartanInvalid = this._fieldControl?.spartanInvalid;
101
+ protected readonly _touched = this._fieldControl?.touched;
102
+ protected readonly _dirty = this._fieldControl?.dirty;
103
+ protected readonly _invalid = this._fieldControl?.invalid;
104
+
105
+ protected readonly _errorStateClass = computed(() =>
106
+ this._spartanInvalid?.()
107
+ ? 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40'
108
+ : '',
109
+ );
110
+ protected readonly _ariaInvalid = computed(() => (this._invalid?.() ? 'true' : null));
85
111
 
86
112
  public readonly userClass = input<ClassValue>('', { alias: 'class' });
87
113
  protected readonly _computedClass = computed(() =>
88
114
  hlm(
89
- 'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 hover:text-accent-foreground inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
115
+ 'ring-offset-background border-input bg-background hover:bg-accent dark:bg-input/30 dark:hover:bg-input/50 inline-flex h-9 w-[280px] cursor-default items-center justify-between gap-2 rounded-md border px-3 py-2 text-left text-sm font-normal whitespace-nowrap transition-all disabled:pointer-events-none disabled:opacity-50',
90
116
  'focus-visible:ring-ring focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
91
117
  'disabled:pointer-events-none disabled:opacity-50',
92
118
  '[&_ng-icon]:pointer-events-none [&_ng-icon]:shrink-0',
119
+ this._errorStateClass(),
93
120
  this.userClass(),
94
121
  ),
95
122
  );
@@ -142,6 +169,8 @@ export class HlmDateRangePicker<T> implements ControlValueAccessor {
142
169
 
143
170
  public readonly dateChange = output<[T, T] | null>();
144
171
 
172
+ public readonly labelableId = this.buttonId;
173
+
145
174
  protected _onChange?: ChangeFn<[T, T] | null>;
146
175
  protected _onTouched?: TouchFn;
147
176
 
@@ -22,13 +22,13 @@ export * from './lib/hlm-field-title';
22
22
 
23
23
  export const HlmFieldImports = [
24
24
  HlmField,
25
- HlmFieldTitle,
26
25
  HlmFieldContent,
27
26
  HlmFieldDescription,
28
27
  HlmFieldError,
29
- HlmFieldLabel,
30
- HlmFieldSeparator,
31
28
  HlmFieldGroup,
29
+ HlmFieldLabel,
32
30
  HlmFieldLegend,
31
+ HlmFieldSeparator,
33
32
  HlmFieldSet,
33
+ HlmFieldTitle,
34
34
  ] as const;
@@ -9,6 +9,6 @@ import { classes } from '<%- importAlias %>/utils';
9
9
  })
10
10
  export class HlmFieldContent {
11
11
  constructor() {
12
- classes(() => 'group/field-content flex flex-1 flex-col gap-1.5 leading-snug');
12
+ classes(() => 'group/field-content flex flex-1 flex-col gap-1 leading-snug');
13
13
  }
14
14
  }
@@ -1,18 +1,56 @@
1
- import { Directive } from '@angular/core';
1
+ import { Directive, effect, EffectRef, inject, input, OnDestroy } from '@angular/core';
2
+ import { BrnFieldA11yService } from '@spartan-ng/brain/field';
2
3
  import { classes } from '<%- importAlias %>/utils';
4
+ import type { ClassValue } from 'clsx';
3
5
 
4
6
  @Directive({
5
7
  selector: '[hlmFieldDescription],hlm-field-description',
6
8
  host: {
7
9
  'data-slot': 'field-description',
10
+ '[attr.id]': 'id()',
8
11
  },
9
12
  })
10
- export class HlmFieldDescription {
13
+ export class HlmFieldDescription implements OnDestroy {
14
+ private static _id = 0;
15
+
16
+ private readonly _a11y = inject(BrnFieldA11yService, { optional: true, host: true });
17
+
18
+ public readonly userClass = input<ClassValue>('', { alias: 'class' });
19
+ public readonly id = input<string>(`hlm-field-description-${HlmFieldDescription._id++}`);
20
+
21
+ private _registeredId?: string;
22
+
23
+ private readonly _cleanup: EffectRef | null = this._a11y
24
+ ? effect(() => {
25
+ const a11y = this._a11y;
26
+ if (!a11y) return;
27
+
28
+ const id = this.id();
29
+ if (this._registeredId && this._registeredId !== id) {
30
+ a11y.unregisterDescription(this._registeredId);
31
+ }
32
+
33
+ if (this._registeredId !== id) {
34
+ a11y.registerDescription(id);
35
+ this._registeredId = id;
36
+ }
37
+ })
38
+ : null;
39
+
11
40
  constructor() {
12
41
  classes(() => [
13
42
  'text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance',
14
43
  'last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5',
15
44
  '[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4',
45
+ this.userClass(),
16
46
  ]);
17
47
  }
48
+
49
+ ngOnDestroy() {
50
+ this._cleanup?.destroy();
51
+
52
+ if (this._registeredId) {
53
+ this._a11y?.unregisterDescription(this._registeredId);
54
+ }
55
+ }
18
56
  }
@@ -1,40 +1,102 @@
1
- import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
2
- import { hlm } from '<%- importAlias %>/utils';
3
- import type { ClassValue } from 'clsx';
1
+ import { BooleanInput } from '@angular/cdk/coercion';
2
+ import {
3
+ booleanAttribute,
4
+ ChangeDetectionStrategy,
5
+ Component,
6
+ computed,
7
+ effect,
8
+ EffectRef,
9
+ inject,
10
+ input,
11
+ OnDestroy,
12
+ } from '@angular/core';
13
+ import { BrnField, BrnFieldA11yService } from '@spartan-ng/brain/field';
14
+ import { classes } from '<%- importAlias %>/utils';
15
+ import { ClassValue } from 'clsx';
4
16
 
5
17
  @Component({
6
18
  selector: 'hlm-field-error',
7
19
  changeDetection: ChangeDetectionStrategy.OnPush,
20
+ host: {
21
+ role: 'alert',
22
+ 'data-slot': 'field-error',
23
+ '[attr.id]': 'id()',
24
+ '[hidden]': '!_display()',
25
+ },
8
26
  template: `
9
- <div role="alert" data-slot="field-error" [class]="_computedClass()">
10
- <ng-content>
11
- @if (_uniqueErrors().length === 1) {
12
- {{ _uniqueErrors()[0]?.message }}
13
- } @else if (_uniqueErrors().length > 1) {
14
- <ul class="ml-4 flex list-disc flex-col gap-1">
15
- @for (error of _uniqueErrors(); track $index) {
16
- @if (error?.message) {
17
- <li>{{ error?.message }}</li>
18
- }
19
- }
20
- </ul>
21
- }
22
- </ng-content>
23
- </div>
27
+ @if (_display()) {
28
+ <ng-content />
29
+ }
24
30
  `,
25
31
  })
26
- export class HlmFieldError {
32
+ export class HlmFieldError implements OnDestroy {
33
+ private static _id = 0;
34
+
35
+ private readonly _field = inject(BrnField, { optional: true });
36
+ private readonly _a11y = inject(BrnFieldA11yService, { optional: true, host: true });
37
+
38
+ private _registeredId?: string;
39
+
40
+ private readonly _hasParentField = !!this._field;
41
+
42
+ /** The unique ID for the field error. If none is supplied, it will be auto-generated. */
43
+ public readonly id = input<string>(`hlm-field-error-${HlmFieldError._id++}`);
44
+
45
+ /** Additional CSS classes to apply to the field error element. */
27
46
  public readonly userClass = input<ClassValue>('', { alias: 'class' });
28
- public readonly error = input<Array<{ message: string } | undefined>>();
29
47
 
30
- protected readonly _uniqueErrors = computed(() => {
31
- const errors = this.error();
32
- if (!errors?.length) {
33
- return [];
34
- }
48
+ /**
49
+ * The name of the specific validator error key to match (e.g. 'required').
50
+ * When omitted, the error is shown if any validation error is present.
51
+ */
52
+ public readonly validator = input<string>();
53
+
54
+ /** Forces the error message to be visible regardless of the control's validation state. */
55
+ public readonly forceShow = input<boolean, BooleanInput>(false, { transform: booleanAttribute });
56
+
57
+ protected readonly _display = computed(() => !this._hasParentField || this.forceShow() || this._hasError());
58
+
59
+ protected readonly _hasError = computed(() => {
60
+ const errors = this._field?.errors();
61
+ if (!errors) return false;
35
62
 
36
- return [...new Map(errors.map((err) => [err?.message, err])).values()];
63
+ const validator = this.validator();
64
+ const spartanInvalid = this._field?.controlState()?.spartanInvalid;
65
+
66
+ if (!spartanInvalid) return false;
67
+
68
+ return validator ? validator in errors : Object.keys(errors).length > 0;
37
69
  });
38
70
 
39
- protected readonly _computedClass = computed(() => hlm('text-destructive text-sm font-normal', this.userClass()));
71
+ private readonly _cleanup: EffectRef | null = this._a11y
72
+ ? effect(() => {
73
+ const a11y = this._a11y;
74
+ if (!a11y) return;
75
+
76
+ const id = this.id();
77
+ const hasError = this._hasError();
78
+
79
+ if (this._registeredId && (this._registeredId !== id || !hasError)) {
80
+ a11y.unregisterError(this._registeredId);
81
+ this._registeredId = undefined;
82
+ }
83
+
84
+ if (hasError && this._registeredId !== id) {
85
+ a11y.registerError(id);
86
+ this._registeredId = id;
87
+ }
88
+ })
89
+ : null;
90
+
91
+ constructor() {
92
+ classes(() => ['text-destructive text-sm font-normal', this.userClass()]);
93
+ }
94
+
95
+ ngOnDestroy() {
96
+ this._cleanup?.destroy();
97
+
98
+ if (this._registeredId) {
99
+ this._a11y?.unregisterError(this._registeredId);
100
+ }
101
+ }
40
102
  }
@@ -11,7 +11,7 @@ export class HlmFieldGroup {
11
11
  constructor() {
12
12
  classes(
13
13
  () =>
14
- 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4',
14
+ 'group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4',
15
15
  );
16
16
  }
17
17
  }
@@ -9,9 +9,9 @@ import { classes } from '<%- importAlias %>/utils';
9
9
  },
10
10
  })
11
11
  export class HlmFieldLegend {
12
+ public readonly variant = input<'label' | 'legend'>('legend');
13
+
12
14
  constructor() {
13
15
  classes(() => 'mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base');
14
16
  }
15
-
16
- public readonly variant = input<'label' | 'legend'>('legend');
17
17
  }
@@ -1,19 +1,20 @@
1
1
  import { Directive, input } from '@angular/core';
2
+ import { BrnField } from '@spartan-ng/brain/field';
2
3
  import { classes } from '<%- importAlias %>/utils';
3
- import { cva, type VariantProps } from 'class-variance-authority';
4
+ import { cva, VariantProps } from 'class-variance-authority';
4
5
 
5
- const fieldVariants = cva('group/field data-[invalid=true]:text-destructive flex w-full gap-3', {
6
+ const fieldVariants = cva('group/field data-[matches-spartan-invalid=true]:text-destructive flex w-full gap-3', {
6
7
  variants: {
7
8
  orientation: {
8
- vertical: ['flex-col [&>*]:w-full [&>.sr-only]:w-auto'],
9
+ vertical: 'flex-col *:w-full [&>.sr-only]:w-auto',
9
10
  horizontal: [
10
11
  'flex-row items-center',
11
- '[&>[data-slot=field-label]]:flex-auto',
12
+ '*:data-[slot=field-label]:flex-auto',
12
13
  'has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
13
14
  ],
14
15
  responsive: [
15
- 'flex-col @md/field-group:flex-row @md/field-group:items-center [&>*]:w-full @md/field-group:[&>*]:w-auto [&>.sr-only]:w-auto',
16
- '@md/field-group:[&>[data-slot=field-label]]:flex-auto',
16
+ 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto [&>.sr-only]:w-auto',
17
+ '@md/field-group:*:data-[slot=field-label]:flex-auto',
17
18
  '@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px',
18
19
  ],
19
20
  },
@@ -27,6 +28,7 @@ export type FieldVariants = VariantProps<typeof fieldVariants>;
27
28
 
28
29
  @Directive({
29
30
  selector: '[hlmField],hlm-field',
31
+ hostDirectives: [{ directive: BrnField, inputs: ['data-invalid', 'forceInvalid'] }],
30
32
  host: {
31
33
  role: 'group',
32
34
  'data-slot': 'field',
@@ -35,6 +37,7 @@ export type FieldVariants = VariantProps<typeof fieldVariants>;
35
37
  })
36
38
  export class HlmField {
37
39
  public readonly orientation = input<FieldVariants['orientation']>('vertical');
40
+
38
41
  constructor() {
39
42
  classes(() => fieldVariants({ orientation: this.orientation() }));
40
43
  }
@@ -1,29 +1,15 @@
1
- import {
2
- computed,
3
- Directive,
4
- effect,
5
- forwardRef,
6
- inject,
7
- Injector,
8
- input,
9
- linkedSignal,
10
- signal,
11
- untracked,
12
- type DoCheck,
13
- } from '@angular/core';
14
- import { FormGroupDirective, NgControl, NgForm } from '@angular/forms';
15
- import { BrnFormFieldControl } from '@spartan-ng/brain/form-field';
16
- import { ErrorStateMatcher, ErrorStateTracker } from '@spartan-ng/brain/forms';
1
+ import { Directive, input, linkedSignal } from '@angular/core';
2
+ import { BrnFieldControlDescribedBy } from '@spartan-ng/brain/field';
3
+ import { BrnInput } from '@spartan-ng/brain/input';
17
4
  import { classes } from '<%- importAlias %>/utils';
18
5
  import { cva, type VariantProps } from 'class-variance-authority';
19
- import type { ClassValue } from 'clsx';
20
6
 
21
7
  export const inputVariants = cva(
22
8
  'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input focus-visible:border-ring focus-visible:ring-ring/50 flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
23
9
  {
24
10
  variants: {
25
11
  error: {
26
- auto: '[&.ng-invalid.ng-touched]:border-destructive [&.ng-invalid.ng-touched]:ring-destructive/20 dark:[&.ng-invalid.ng-touched]:ring-destructive/40',
12
+ auto: 'data-[matches-spartan-invalid=true]:border-destructive data-[matches-spartan-invalid=true]:ring-destructive/20 dark:data-[matches-spartan-invalid=true]:ring-destructive/40',
27
13
  true: 'border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40',
28
14
  },
29
15
  },
@@ -36,62 +22,17 @@ type InputVariants = VariantProps<typeof inputVariants>;
36
22
 
37
23
  @Directive({
38
24
  selector: '[hlmInput]',
39
- providers: [
40
- {
41
- provide: BrnFormFieldControl,
42
- useExisting: forwardRef(() => HlmInput),
43
- },
44
- ],
25
+ hostDirectives: [{ directive: BrnInput, inputs: ['id'] }, BrnFieldControlDescribedBy],
45
26
  })
46
- export class HlmInput implements BrnFormFieldControl, DoCheck {
47
- private readonly _injector = inject(Injector);
48
- private readonly _additionalClasses = signal<ClassValue>('');
49
-
50
- private readonly _errorStateTracker: ErrorStateTracker;
51
-
52
- private readonly _defaultErrorStateMatcher = inject(ErrorStateMatcher);
53
- private readonly _parentForm = inject(NgForm, { optional: true });
54
- private readonly _parentFormGroup = inject(FormGroupDirective, { optional: true });
55
-
27
+ export class HlmInput {
28
+ /** Controls the error visual state of the input.
29
+ * Defaults to 'auto', which infers the state from the associated form control.
30
+ */
56
31
  public readonly error = input<InputVariants['error']>('auto');
57
32
 
58
33
  protected readonly _state = linkedSignal(() => ({ error: this.error() }));
59
34
 
60
- public readonly ngControl: NgControl | null = this._injector.get(NgControl, null);
61
-
62
- public readonly errorState = computed(() => this._errorStateTracker.errorState());
63
-
64
35
  constructor() {
65
- this._errorStateTracker = new ErrorStateTracker(
66
- this._defaultErrorStateMatcher,
67
- this.ngControl,
68
- this._parentFormGroup,
69
- this._parentForm,
70
- );
71
-
72
- classes(() => [inputVariants({ error: this._state().error }), this._additionalClasses()]);
73
-
74
- effect(() => {
75
- const error = this._errorStateTracker.errorState();
76
- untracked(() => {
77
- if (this.ngControl) {
78
- const shouldShowError = error && this.ngControl.invalid && (this.ngControl.touched || this.ngControl.dirty);
79
- this._errorStateTracker.errorState.set(shouldShowError ? true : false);
80
- this.setError(shouldShowError ? true : 'auto');
81
- }
82
- });
83
- });
84
- }
85
-
86
- ngDoCheck() {
87
- this._errorStateTracker.updateErrorState();
88
- }
89
-
90
- setError(error: InputVariants['error']) {
91
- this._state.set({ error });
92
- }
93
-
94
- setClass(classes: string): void {
95
- this._additionalClasses.set(classes);
36
+ classes(() => inputVariants({ error: this._state().error }));
96
37
  }
97
38
  }
@@ -1,4 +1,5 @@
1
- import { Directive } from '@angular/core';
1
+ import { computed, contentChild, Directive, inject } from '@angular/core';
2
+ import { BrnFieldControl } from '@spartan-ng/brain/field';
2
3
  import { classes } from '<%- importAlias %>/utils';
3
4
 
4
5
  @Directive({
@@ -9,6 +10,15 @@ import { classes } from '<%- importAlias %>/utils';
9
10
  },
10
11
  })
11
12
  export class HlmInputGroup {
13
+ private readonly _fieldControl = inject(BrnFieldControl, { optional: true });
14
+ private readonly _fieldControlChild = contentChild(BrnFieldControl);
15
+
16
+ private readonly _spartanInvalid = computed(() => {
17
+ if (this._fieldControl) return this._fieldControl.spartanInvalid();
18
+
19
+ return this._fieldControlChild()?.spartanInvalid();
20
+ });
21
+
12
22
  constructor() {
13
23
  classes(() => [
14
24
  'group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none',
@@ -18,10 +28,10 @@ export class HlmInputGroup {
18
28
  'has-[>[data-align=inline-end]]:[&>input]:pe-2',
19
29
  'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
20
30
  'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
21
- // Focus state.
22
- 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
23
- // Error state.
24
- 'has-[>.ng-invalid.ng-touched]:ring-destructive/20 has-[>.ng-invalid.ng-touched]:border-destructive dark:has-[>.ng-invalid.ng-touched]:ring-destructive/40',
31
+ 'has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]',
32
+ this._spartanInvalid?.()
33
+ ? 'has-[>[data-matches-spartan-invalid=true]]:ring-destructive/20 has-[>[data-matches-spartan-invalid=true]]:border-destructive dark:has-[>[data-matches-spartan-invalid=true]]:ring-destructive/40'
34
+ : 'has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50',
25
35
  ]);
26
36
  }
27
37
  }
@@ -7,9 +7,12 @@ import { classes } from '<%- importAlias %>/utils';
7
7
  hostDirectives: [
8
8
  {
9
9
  directive: BrnLabel,
10
- inputs: ['id'],
10
+ inputs: ['id', 'for'],
11
11
  },
12
12
  ],
13
+ host: {
14
+ 'data-slot': 'label',
15
+ },
13
16
  })
14
17
  export class HlmLabel {
15
18
  constructor() {