Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/angular-builtin-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@dynamic-field-kit/angular': patch
---

The built-in fallback covers all fourteen field types the README lists, as in the React, Vue and Svelte adapters. `radio`, `range`, `file`, `date`, `time`, `datetime-local` and `switch` used to render "Unknown field type" when no renderer was registered. They now draw the same controls as the other adapters: a radio group (`role="radiogroup"`, named by its caption through `aria-labelledby`, a label per button, emitting the option's own value), a range that emits a number and starts at `min`, a file input that emits the `File` (an array for `multiple`, `null` when cleared), date and time inputs that emit the browser's ISO text, and a switch drawn as a checkbox. Each gets the label, `aria-invalid` and `aria-describedby` the other built-in controls have.
4 changes: 4 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ there was no label at all, so the control had no accessible name. What moved:
own CSS if your design shows the label elsewhere, but keep it in the page.
- **Radio groups** have `role="radiogroup"` and `aria-labelledby` pointing at a
`<span class="dfk-field-label">` caption (a group cannot take `<label for>`).
- **Angular:** the built-in fallback covers all fourteen types, like the other
adapters: `radio`, `range`, `file`, `date`, `time`, `datetime-local` and
`switch` used to render "Unknown field type". Register a renderer for one of
them to keep your own.
- **Angular:** a form store's errors now set `aria-describedby` on the control
too; it used to follow only the errors of the field's own rules.

Expand Down
14 changes: 11 additions & 3 deletions packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,13 @@ For one field, set `id` on its `FieldDescription`; it wins over the prefix.
`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch`

Any type you have not registered falls back to one of these. `file` emits a
`File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers,
`checkbox` / `switch` emit booleans; everything else emits strings.
`File` (or `File[]` when `multiple` is set, `null` when the choice is cleared),
`range` and `number` emit numbers, `checkbox` / `switch` emit booleans, `radio`
emits the chosen option's own `value`; everything else emits strings (`date`,
`time` and `datetime-local` the ISO text the browser gives, such as
`2026-10-21T09:30`). Before 1.12 only `text`, `password`, `email`, `number`,
`textarea`, `checkbox` and `select` had a fallback here, and the other seven
rendered "Unknown field type".

Since 1.7.0 a default renderer also renders its validation message, as
`<div id="{fieldId}-error" class="dfk-field-error" role="alert">`, which is what
Expand All @@ -276,7 +281,10 @@ it with `.dfk-field-error { display: none }` if you want the old silence.
A built-in control is also labelled: when the field has a `label`,
`<label id="{fieldId}-label" for="{fieldId}" class="dfk-field-label">` comes
before it, and the `<dfk-dynamic-input>` element that holds the label, control
and message gets the class `dfk-field`. A registered renderer is untouched.
and message gets the class `dfk-field`. A radio group has `role="radiogroup"`
and `aria-labelledby` pointing at a `<span class="dfk-field-label">` caption
instead, with a `<label>` around each button. A registered renderer is
untouched.

## DevTools

Expand Down
233 changes: 197 additions & 36 deletions packages/angular/src/components/DynamicInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,49 @@ const KNOWN_PROPS = [
'multiple',
] as const;

// A radio group is a `div` holding the buttons; every other built-in is one
// form control.
type BuiltInControl =
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | HTMLDivElement;

/** The types the built-in fallback draws a control for (see createBuiltIn). */
const BUILT_IN_TYPES: ReadonlySet<string> = new Set([
/** Types whose control is an `<input>` of the same `type`, holding text. */
const TEXT_INPUT_TYPES: ReadonlySet<string> = new Set([
'text',
'password',
'email',
'number',
// The browser hands these over as ISO text: `2026-10-21`, `09:30`,
// `2026-10-21T09:30`. That text is the value, as in the other adapters.
'date',
'time',
'datetime-local',
]);

/**
* The types the built-in fallback draws a control for (see createBuiltIn):
* the same fourteen as the React, Vue and Svelte adapters.
*/
const BUILT_IN_TYPES: ReadonlySet<string> = new Set([
...TEXT_INPUT_TYPES,
'textarea',
'checkbox',
'switch',
'select',
'radio',
'range',
'file',
]);

/** An option as the select and the radio group show it, read as every adapter reads it. */
function readOption(option: unknown): { value: unknown; label: string } {
const record = option as Record<string, unknown>;
const value = record?.['value'] ?? record?.['id'] ?? option;
return {
value,
label: String(record?.['label'] ?? record?.['name'] ?? value),
};
}

@Component({
selector: 'dfk-dynamic-input',
standalone: true,
Expand All @@ -83,12 +112,21 @@ const BUILT_IN_TYPES: ReadonlySet<string> = new Set([
host: { '[class.dfk-field]': 'isBuiltIn()' },
// *ngIf rather than @if: the peer range starts at Angular 16, and the
// built-in control flow block syntax is 17+.
//
// A radio group is not a labelable element: its caption is a plain element
// the group names itself after (`aria-labelledby`), as in the other
// adapters.
template: `<label
*ngIf="showDefaultLabel()"
*ngIf="showDefaultLabel() && type !== 'radio'"
[id]="labelNodeId()"
[attr.for]="id"
class="dfk-field-label"
>{{ label }}</label
><span
*ngIf="showDefaultLabel() && type === 'radio'"
[id]="labelNodeId()"
class="dfk-field-label"
>{{ label }}</span
>
<div #host style="display: contents;"></div>
<div
Expand Down Expand Up @@ -121,6 +159,9 @@ export class DynamicInput
private builtIn?: BuiltInControl;
private builtInType?: string;
private builtInOptions?: string;
// The values of the radio buttons, by position: what a button emits when
// chosen is the option's own value, not the text of its `value` attribute.
private radioValues: unknown[] = [];
private subscriptions: Subscription[] = [];
// Tracks which KNOWN_PROPS were actually supplied to DynamicInput (via
// SimpleChanges), independent of TS class-field emit. See applyProps.
Expand Down Expand Up @@ -460,28 +501,38 @@ export class DynamicInput
* change, so a flag that is no longer given is taken off again.
*/
private applyFallbackAria(): void {
const control = (
this.host.element.nativeElement as HTMLElement
).querySelector('input, select, textarea');
if (!control) {
return;
}
const flag = (name: string, value: string | undefined) => {
if (value === undefined) {
control.removeAttribute(name);
} else {
control.setAttribute(name, value);
const root = this.host.element.nativeElement as HTMLElement;
// Each button of a radio group carries the flags, as in the React and Vue
// adapters; any other built-in is a single control.
const group = this.builtIn instanceof HTMLDivElement;
const controls = group
? Array.from(root.querySelectorAll('input[type="radio"]'))
: [root.querySelector('input, select, textarea')].filter(
(control): control is Element => control !== null,
);
for (const control of controls) {
const flag = (name: string, value: string | undefined) => {
if (value === undefined) {
control.removeAttribute(name);
} else {
control.setAttribute(name, value);
}
};
flag(
'aria-invalid',
this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid),
);
// The group's `required` says it; a button is not required on its own.
if (!group) {
flag(
'aria-required',
this.ariaRequired === undefined
? undefined
: String(this.ariaRequired),
);
}
};
flag(
'aria-invalid',
this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid),
);
flag(
'aria-required',
this.ariaRequired === undefined ? undefined : String(this.ariaRequired),
);
flag('aria-describedby', this.ariaDescribedBy || undefined);
flag('aria-describedby', this.ariaDescribedBy || undefined);
}
}

/**
Expand All @@ -502,12 +553,7 @@ export class DynamicInput
}

private createBuiltIn(type: string): BuiltInControl | null {
if (
type === 'text' ||
type === 'password' ||
type === 'email' ||
type === 'number'
) {
if (TEXT_INPUT_TYPES.has(type)) {
const input = document.createElement('input');
input.type = type;
input.addEventListener('input', () => {
Expand All @@ -527,7 +573,8 @@ export class DynamicInput
return textarea;
}

if (type === 'checkbox') {
// A switch is a checkbox, as in the other adapters.
if (type === 'checkbox' || type === 'switch') {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.addEventListener('change', () => {
Expand All @@ -544,6 +591,43 @@ export class DynamicInput
return select;
}

if (type === 'range') {
const range = document.createElement('input');
range.type = 'range';
range.addEventListener('input', () => {
this.emitValue(Number(range.value));
});
return range;
}

if (type === 'file') {
const file = document.createElement('input');
file.type = 'file';
// The chosen file, or the list of them for `multiple`; `null` when the
// choice is cleared.
file.addEventListener('change', () => {
const files = file.files;
if (!files) {
return;
}
this.emitValue(this.multiple ? Array.from(files) : (files[0] ?? null));
});
return file;
}

if (type === 'radio') {
const group = document.createElement('div');
group.setAttribute('role', 'radiogroup');
group.addEventListener('change', (event) => {
const target = event.target as HTMLInputElement;
const index = Number(target.dataset['index']);
if (target.type === 'radio' && index in this.radioValues) {
this.emitValue(this.radioValues[index]);
}
});
return group;
}

return null;
}

Expand All @@ -557,6 +641,18 @@ export class DynamicInput
}
};
attribute('id', this.id);

if (control instanceof HTMLDivElement) {
control.className = `dfk-radio-group ${this.className || ''}`;
// The caption drawn for a labelled field; see `showDefaultLabel`.
attribute(
'aria-labelledby',
this.label && this.id ? this.labelNodeId() : undefined,
);
this.syncRadioButtons(control);
return;
}

attribute('class', this.className);
control.required = Boolean(this.required);

Expand All @@ -574,6 +670,28 @@ export class DynamicInput
return;
}

if (control instanceof HTMLInputElement && control.type === 'range') {
// nor a range
control.disabled = Boolean(this.disabled || this.readOnly);
attribute('min', this.min?.toString());
attribute('max', this.max?.toString());
attribute('step', this.step?.toString());
// Bounds first: the browser clamps the value to them.
const next = String(this.value ?? this.min ?? 0);
if (control.value !== next) {
control.value = next;
}
return;
}

if (control instanceof HTMLInputElement && control.type === 'file') {
// A file input cannot be given a value, so `value` is not written.
control.disabled = Boolean(this.disabled || this.readOnly);
attribute('accept', this.accept);
control.multiple = Boolean(this.multiple);
return;
}

attribute('placeholder', this.placeholder);
control.disabled = Boolean(this.disabled);
control.readOnly = Boolean(this.readOnly);
Expand All @@ -592,13 +710,56 @@ export class DynamicInput
}
}

/**
* The buttons of a built-in radio group, rebuilt only when the options
* changed, and the one that is checked. Markup as in the other adapters: a
* `<label>` per option around its button and its text.
*/
private syncRadioButtons(group: HTMLDivElement): void {
const shown = (this.options || []).map(readOption);
const listed = JSON.stringify(
shown.map((option) => [String(option.value), option.label]),
);
if (listed !== this.builtInOptions) {
this.builtInOptions = listed;
this.radioValues = shown.map((option) => option.value);
while (group.firstChild) {
group.removeChild(group.firstChild);
}
shown.forEach((option, index) => {
const label = document.createElement('label');
label.style.marginRight = '12px';
label.style.display = 'inline-flex';
label.style.alignItems = 'center';
const button = document.createElement('input');
button.type = 'radio';
button.value = String(option.value);
button.dataset['index'] = String(index);
const text = document.createElement('span');
text.style.marginLeft = '4px';
text.textContent = option.label;
label.append(button, text);
group.appendChild(label);
});
}

const buttons = Array.from(
group.querySelectorAll<HTMLInputElement>('input[type="radio"]'),
);
buttons.forEach((button, index) => {
button.id = `${this.id || 'radio'}-${index}`;
button.name = this.id ?? '';
button.disabled = Boolean(this.disabled || this.readOnly);
button.required = Boolean(this.required);
button.checked = String(this.value) === String(this.radioValues[index]);
});
}

/** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
private syncBuiltInOptions(select: HTMLSelectElement): void {
const options = (this.options || []).map((opt) => {
const optObj = opt as Record<string, any>;
const optVal = optObj['value'] ?? optObj['id'] ?? opt;
const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
return { value: String(optVal), label: String(optLabel) };
const option = readOption(opt);
return { value: String(option.value), label: option.label };
});
const listed = JSON.stringify(options);
if (listed !== this.builtInOptions) {
Expand Down
Loading
Loading