Skip to content
Merged
6 changes: 6 additions & 0 deletions .changeset/old-pillows-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@digdir/designsystemet-web": patch
Comment thread
eirikbacker marked this conversation as resolved.
Resolved
"@digdir/designsystemet-react": patch
---

**ValidationMessage:** Now correctly sets `aria-invalid="true"` only if `danger` color
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

Use the class name `ds-validation-message` to style a validation message.

## Form fields and status

Validation message will automatically be connected to form fields, when it has the attribute `data-field="validation"`, and is inside a [field](/en/components/docs/field/code).

The form field gets `aria-invalid="true"` when the validation message has `data-color="danger"`, or when `data-color` is not specified.

## CSS variables and data attributes

Sizes are controlled with [data-size](/en/fundamentals/code/sizes) and colors with [data-color](/en/fundamentals/code/colors). The component will inherit from the closest parent where these are set.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

Bruk klassenavnet `ds-validation-message` for å style en validation message.

## Skjemafelt og status

Validation message vil automatisk kobles til skjemafelt, når den har attributtet `data-field="validation"`, og ligger inni et [field](/no/components/docs/field/code).

Skjemafeltet får `aria-invalid="true"` når validation message har `data-color="danger"`, eller når `data-color` ikke er angitt.

## CSS variabler og data-attributter

Størrelser styres med [data-size](/no/fundamentals/code/sizes) og farger med [data-color](/no/fundamentals/code/colors). Komponenten vil arve nærmeste forelder med disse satt.
Expand Down
6 changes: 6 additions & 0 deletions packages/web/src/field/field.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,16 @@ describe('Field component', () => {
const counter = document.querySelector('[data-field="counter"]');
expect(textarea).toBeInTheDocument();
expect(counter).toBeInTheDocument();
expect(textarea?.getAttribute('aria-invalid')).toBe('true');
expect(counter?.getAttribute('data-label')).toBe('13 tegn for mye');

await act(counter?.setAttribute('data-limit', '10'));
expect(textarea?.getAttribute('aria-invalid')).toBe('true');
expect(counter?.getAttribute('data-label')).toBe('23 tegn for mye');

await act(counter?.setAttribute('data-limit', '40'));
expect(textarea?.getAttribute('aria-invalid')).toBeFalsy();
expect(counter?.getAttribute('data-label')).toBe('7 tegn igjen');
});

test('should not override aria-invalid when already set on input', async () => {
Expand Down
25 changes: 16 additions & 9 deletions packages/web/src/field/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ declare global {
}
}

const ATTR_FIELD = 'data-field';
const ATTR_LIMIT = 'data-limit';
const ATTR_INVALID = 'aria-invalid';
const ATTR_DESCRIBEDBY = 'aria-describedby';
const ATTR_INDETERMINATE = 'data-indeterminate';
const SELECTOR_VALIDATION = `:scope > [${ATTR_FIELD}="validation"]`;
const COUNTER_DEBOUNCE = isWindows() ? 800 : 200; // Longer debounce on Windows due to NVDA performance
const WARNING_MULTIPLE_INPUTS = `Fields should only have one input element. Use <fieldset> to group multiple fields:`;

Expand All @@ -43,12 +46,12 @@ const handleFieldMutation = (field: DSFieldElement) => {
warn(WARNING_MULTIPLE_INPUTS, field);
else field._input = el; // Only register if visible input
} else {
const type = el.getAttribute('data-field'); // Using getAttribute instead of attr for best performance
const type = el.getAttribute(ATTR_FIELD); // Using getAttribute instead of attr for best performance
if (type === 'counter') field._counter = el;
if (type === 'validation') {
nextDescs.unshift(useId(el));
hasValidation = true;
invalid = invalid || isInvalid(el);
invalid = invalid || isInvalidColor(el);
} else if (type) nextDescs.push(useId(el)); // Adds both counter and descriptions
}
}
Expand All @@ -60,12 +63,12 @@ const handleFieldMutation = (field: DSFieldElement) => {

const fieldsetValidation = field
.closest('fieldset')
?.querySelector<HTMLElement>(':scope > [data-field="validation"]');
?.querySelector<HTMLElement>(SELECTOR_VALIDATION);

// Connect fieldset validation to inputs
if (fieldsetValidation && !fieldsetValidation?.hidden) {
hasValidation = true;
invalid = invalid || isInvalid(fieldsetValidation);
invalid = invalid || isInvalidColor(fieldsetValidation);
nextDescs.unshift(useId(fieldsetValidation));
}

Expand All @@ -87,7 +90,7 @@ const handleFieldMutation = (field: DSFieldElement) => {
// Only manage aria-invalid when field has validation elements, and aria-invalid does not already exist
const hadValidation = field._validation;
if (hasValidation && !hadValidation && !input.hasAttribute(ATTR_INVALID)) {
attr(input, ATTR_INVALID, 'true');
attr(input, ATTR_INVALID, invalid ? 'true' : null);
field._validation = true;
} else if (!hasValidation && hadValidation) {
attr(input, ATTR_INVALID, null); // Remove aria-invalid
Expand All @@ -107,7 +110,10 @@ const debouncedCounterLiveRegion = debounce((input: Element, text: string) => {
if (document.activeElement === input) announce(text); // Only announce if input is still focused
}, COUNTER_DEBOUNCE);

const isInvalid = (el: Element) => el.getAttribute('data-color') !== 'success';
const isInvalidColor = (el: Element) => {
const color = el.getAttribute('data-color');
return !color || color === 'danger';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might be to early for me but is this first check correct? If no data-color is defined, then its an invalid color?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, default color of validation is danger, so no color = danger ☺️

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If no color is set, every data-field="validation" should make the input invalid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this is how it works now ☺️

};
const isInputLike = (el: unknown): el is HTMLInputElement =>
el instanceof HTMLElement &&
'validity' in el && // Adds support for custom elements implemeted with attachInternals()
Expand All @@ -127,8 +133,8 @@ export class DSFieldElement extends DSElement {
this._unevents = on(this, 'input', this, QUICK_EVENT);
this._unmutate = onMutation(this, () => handleFieldMutation(this), {
attributeFilter: [
'data-field',
'data-limit',
ATTR_FIELD,
ATTR_LIMIT,
'hidden', // Needed to check validation visibility
'id', // Needed to sync label "for" when ID of input/selec/textarea changes
'value', // Needed to detect changes in controlled React inputs as they do not trigger input events
Expand All @@ -143,7 +149,7 @@ export class DSFieldElement extends DSElement {
const { _counter, _input } = this;

if (_counter?.isConnected && _input) {
const limit = Number(attr(_counter, 'data-limit')) || 0;
const limit = Number(attr(_counter, ATTR_LIMIT)) || 0;
const count = limit - _input.value.length;
const state = count < 0 ? 'over' : 'under';
const label = (attrOrCSS(_counter, `data-${state}`) || TEXTS[state]) // Browser translation tools will not pick up dynamic text anyway, so no need to use aria-labelledby here
Expand All @@ -153,6 +159,7 @@ export class DSFieldElement extends DSElement {
attr(_counter, 'data-label', label); // Using attribute to prevent hydration errors, not using aria-label to make axe tests happy
attr(_counter, 'data-state', state);
attr(_counter, 'data-color', count < 0 ? 'danger' : null);
attr(_input, 'aria-invalid', count < 0 ? 'true' : null); // Toggle aria-invalid of textarea

// Only update live region when user is actually typing
if (event?.type === 'input' && label)
Expand Down
Loading