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
9 changes: 9 additions & 0 deletions .changeset/default-renderer-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': patch
'@dynamic-field-kit/vue': patch
'@dynamic-field-kit/svelte': patch
'@dynamic-field-kit/angular': patch
---

Built-in renderers are labelled. A field without a registered renderer now renders its `label` as a `<label for>` tied to the control (`id="{fieldId}-label"`, class `dfk-field-label`), so the control has an accessible name; a radio group gets `role="radiogroup"` and `aria-labelledby` pointing at its caption. In React, Vue and Svelte the label, the control and the error message sit in one `<div class="dfk-field">`, which keeps them together in a grid layout; in Angular the `<dfk-dynamic-input>` element that already holds them gets the class `dfk-field`. CSS that selected a built-in control as a direct child of the layout needs updating (see MIGRATING). Registered renderers are unaffected. In Angular, an error passed in by a form store now also sets `aria-describedby` on the control. Core gains `makeLabelId(id)`.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ All framework adapters (`react`, `vue`, `angular`, `svelte`) ship with **built-i
`text` · `number` · `password` · `email` · `textarea` · `checkbox` · `select` ·
`radio` · `range` · `file` · `date` · `time` · `datetime-local` · `switch`

If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states.
If you do not register a custom component for a type, the library automatically renders a clean, accessible HTML5 input with support for labels, placeholders, disabled states, options, and error states. The label is a `<label for>` tied to the control (a radio group is named through `aria-labelledby`), and the label, control and error message are wrapped in one `<div class="dfk-field">`.

Every key above is also a `FieldTypeMap` entry, so `type: 'switch'` typechecks
out of the box. The map itself is reachable if you need to wrap or inspect a
Expand Down
38 changes: 38 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,44 @@
Version-by-version tables of what changed and what to do about it. For the full
list of changes see each package's `CHANGELOG.md`.

## 1.11.x → 1.12.0

### Default renderers are labelled, and wrapped

If a field uses the built-in renderers (no renderer registered for its type),
it now renders

```html
<div class="dfk-field">
<label id="{fieldId}-label" for="{fieldId}" class="dfk-field-label"
>Email</label
>
<input id="{fieldId}" type="email" />
<div id="{fieldId}-error" class="dfk-field-error" role="alert">
Invalid email
</div>
</div>
```

Before, the control (and its message) were direct children of the layout, and
there was no label at all, so the control had no accessible name. What moved:

- **A wrapper.** The control is inside `.dfk-field`, which keeps its label and
message in the same cell of a grid layout. CSS that picked the control as a
direct child of the layout, such as `.my-grid > input`, now needs
`.my-grid > .dfk-field > input` or a class of your own. Angular adds no
element: the `<dfk-dynamic-input>` that already held them gets the class
`dfk-field`.
- **A label**, only when the field has a `label`. Hide it visually with your
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:** 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.

Registered renderers are **not** affected: no wrapper, no label. Core gains
`makeLabelId(id)`, the id the label carries.

## 1.6.x → 1.7.0

Everything here is additive or a bugfix. No API was removed, and no peer range
Expand Down
5 changes: 5 additions & 0 deletions packages/angular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ it, so the form showed nothing. A registered custom renderer is unaffected — t
node is emitted only where a default was used, so you never get two copies. Hide
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.

## DevTools

```html
Expand Down
49 changes: 47 additions & 2 deletions packages/angular/src/components/DynamicInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { FieldTypeKey, makeErrorId, Properties } from '@dynamic-field-kit/core';
import {
FieldTypeKey,
makeErrorId,
makeLabelId,
Properties,
} from '@dynamic-field-kit/core';
import { Subscription } from 'rxjs';
import { FIELD_REGISTRY } from '../fieldRegistryToken';
import { BaseInputComponent } from './BaseInput';
Expand Down Expand Up @@ -56,14 +61,36 @@ const KNOWN_PROPS = [
type BuiltInControl =
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;

/** The types the built-in fallback draws a control for (see createBuiltIn). */
const BUILT_IN_TYPES: ReadonlySet<string> = new Set([
'text',
'password',
'email',
'number',
'textarea',
'checkbox',
'select',
]);

@Component({
selector: 'dfk-dynamic-input',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
// A built-in control is bare, so its label is drawn here, tied to it by
// `for`. The host element keeps label, control and message together, as
// the `.dfk-field` wrapper does in the other adapters.
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+.
template: `<div #host style="display: contents;"></div>
template: `<label
*ngIf="showDefaultLabel()"
[id]="labelNodeId()"
[attr.for]="id"
class="dfk-field-label"
>{{ label }}</label
>
<div #host style="display: contents;"></div>
<div
*ngIf="showDefaultError()"
[id]="errorNodeId()"
Expand Down Expand Up @@ -191,6 +218,24 @@ export class DynamicInput
return makeErrorId(this.id ?? '');
}

/** The id of the label drawn for a built-in control. See core's `makeLabelId`. */
labelNodeId(): string {
return makeLabelId(this.id ?? '');
}

/**
* Whether the built-in fallback draws this field's control. Asks the
* registry directly, for the reason given on `showDefaultError`.
*/
isBuiltIn(): boolean {
return !this.registry.get(this.type) && BUILT_IN_TYPES.has(this.type);
}

/** A registered renderer owns its label; a built-in control gets one here. */
showDefaultLabel(): boolean {
return Boolean(this.isBuiltIn() && this.label && this.id);
}

/** `error` may arrive as a bare string, so index 0 would be a character. */
firstError(): string | undefined {
return Array.isArray(this.error) ? this.error[0] : this.error;
Expand Down
9 changes: 8 additions & 1 deletion packages/angular/src/components/FieldInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
isAsyncOptions,
type OptionsLoader,
type OptionsState,
makeErrorId,
makeFieldId,
FieldDescription,
Properties,
Expand Down Expand Up @@ -188,11 +189,12 @@ export class FieldInput implements OnChanges, OnDestroy {
}

const data = this.data ?? { [field.name]: this.value };
const id = makeFieldId(field, this.idPrefix);
const base = buildFieldRendererProps({
fieldDescription: field,
data,
rootData: this.rootData,
id: makeFieldId(field, this.idPrefix),
id,
touched: this.touched,
dirty: this.dirty,
optionsState: this.optionsState,
Expand All @@ -211,6 +213,11 @@ export class FieldInput implements OnChanges, OnDestroy {
readOnly: this.readOnly ?? base.readOnly,
error,
ariaInvalid: Boolean(error),
// Follows `error` as `ariaInvalid` does: an error the host passed in
// (a form store's) has a message node too, and the control must point
// at it. Taken from `base`, it pointed at nothing whenever the field
// description's own rules found no error.
ariaDescribedBy: error ? makeErrorId(id) : undefined,
};
}
}
1 change: 1 addition & 0 deletions packages/angular/src/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type {
export {
buildFieldRendererProps,
makeErrorId,
makeLabelId,
makeFieldId,
FIELD_RENDERER_PROP_KEYS,
} from '@dynamic-field-kit/core';
Expand Down
122 changes: 122 additions & 0 deletions packages/angular/test/defaultRendererLabels.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Component, Input } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import type { FieldDescription } from '@dynamic-field-kit/core';
import { beforeEach, describe, expect, it } from 'vitest';
import { MultiFieldInput } from '../src/components/MultiFieldInput';
import { FIELD_REGISTRY } from '../src/fieldRegistryToken';
import { makeRegistry, TextRendererComponent } from './helpers/renderers';

// The registry's field types are open-ended; these tests use plain strings.
const list = (fields: unknown[]) => fields as FieldDescription[];

@Component({
selector: 'dfk-test-label-host',
standalone: true,
imports: [MultiFieldInput],
template: `
<dfk-multi-field-input
[fieldDescriptions]="fields"
[properties]="{}"
[errors]="errors"
idPrefix="f"
></dfk-multi-field-input>
`,
})
class HostComponent {
@Input() fields: FieldDescription[] = [];
@Input() errors?: Record<string, string[]>;
}

// The types the Angular fallback draws a control for.
const builtIn = [
'text',
'password',
'email',
'number',
'textarea',
'checkbox',
'select',
];

/** The control a label names, the way assistive technology finds it. */
function byLabelText(root: HTMLElement, text: string): HTMLElement {
const label = Array.from(root.querySelectorAll('label')).find(
(element) => element.textContent?.trim() === text,
);
const control = label?.htmlFor
? root.querySelector<HTMLElement>(`[id="${label.htmlFor}"]`)
: null;
if (!control) {
throw new Error(`no control labelled "${text}"`);
}
expect(
Array.from((control as HTMLInputElement).labels ?? []).includes(label!),
).toBe(true);
return control;
}

describe('built-in controls', () => {
let registry: ReturnType<typeof makeRegistry>;

beforeEach(() => {
registry = makeRegistry();
TestBed.configureTestingModule({
imports: [HostComponent],
providers: [{ provide: FIELD_REGISTRY, useValue: registry }],
});
});

function mount(
fields: FieldDescription[],
errors?: Record<string, string[]>,
): HTMLElement {
const fixture = TestBed.createComponent(HostComponent);
fixture.componentRef.setInput('fields', fields);
fixture.componentRef.setInput('errors', errors);
fixture.detectChanges();
// The control is drawn after the view is checked; check it once more.
fixture.detectChanges();
document.body.appendChild(fixture.nativeElement);
return fixture.nativeElement as HTMLElement;
}

it.each(builtIn)('label a %s control with <label for>', (type) => {
const root = mount(
list([{ name: 'x', type, label: `The ${type}`, options: [] }]),
);
const control = byLabelText(root, `The ${type}`);
expect(control.id).toBe('f-x');
});

it('keep label, control and message together in one element', () => {
const root = mount(
list([{ name: 'email', type: 'email', label: 'Email' }]),
{
email: ['Invalid email'],
},
);
const field = root.querySelector('.dfk-field')!;
const control = byLabelText(root, 'Email');
expect(field.contains(control)).toBe(true);
expect(control.getAttribute('aria-invalid')).toBe('true');
const message = root.querySelector(
`[id="${control.getAttribute('aria-describedby')}"]`,
);
expect(message?.textContent?.trim()).toBe('Invalid email');
expect(field.contains(message)).toBe(true);
});

it('draw no label for a field without one', () => {
const root = mount(list([{ name: 'x', type: 'text' }]));
expect(root.querySelector('.dfk-field-label')).toBeNull();
expect(root.querySelector('.dfk-field input')).not.toBeNull();
});

it('leave a registered renderer as it was: no label', () => {
registry.register('text', TextRendererComponent as never);
const root = mount(list([{ name: 'x', type: 'text', label: 'X' }]));
expect(root.querySelector('input.txt')).not.toBeNull();
expect(root.querySelector('.dfk-field')).toBeNull();
expect(root.querySelector('label')).toBeNull();
});
});
9 changes: 9 additions & 0 deletions packages/core/src/rendererProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,15 @@ export function makeErrorId(id: string): string {
return `${id}-error`;
}

/**
* The id of the element that names a field: the `<label>` the adapters render
* for a built-in renderer, or the caption of a group of controls (the radio
* buttons), which the group points at with `aria-labelledby`.
*/
export function makeLabelId(id: string): string {
return `${id}-label`;
}

/**
* Builds the complete renderer prop bag for one field. Shared by the React,
* Vue and Angular adapters so all three forward an identical set.
Expand Down
8 changes: 8 additions & 0 deletions packages/core/test/rendererProps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
buildFieldRendererProps,
FIELD_RENDERER_PROP_KEYS,
makeErrorId,
makeLabelId,
makeFieldId,
} from '../src/rendererProps';
import type { FieldDescription } from '../src/types';
Expand Down Expand Up @@ -327,3 +328,10 @@ describe('options loading state', () => {
expect(FIELD_RENDERER_PROP_KEYS).toContain('optionsError');
});
});

describe('makeLabelId', () => {
it('names the label of a field from its id, beside the error id', () => {
expect(makeLabelId('f-email')).toBe('f-email-label');
expect(makeLabelId('f-email')).not.toBe(makeErrorId('f-email'));
});
});
8 changes: 8 additions & 0 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,14 @@ it, so the form showed nothing. A registered custom renderer is unaffected — t
node is emitted only where a default was used, so you never get two copies. Hide
it with `.dfk-field-error { display: none }` if you want the old silence.

A default renderer is also labelled: the control, its label and its message sit
in one `<div class="dfk-field">`, with
`<label id="{fieldId}-label" for="{fieldId}" class="dfk-field-label">` before
the control when the field has a `label`. A radio group is named by its caption
instead (`role="radiogroup"` with `aria-labelledby` pointing at a
`<span class="dfk-field-label">`). Before that the control was a direct child of
the layout and had no label at all. Again, a registered renderer is untouched.

## DevTools

```tsx
Expand Down
Loading
Loading