Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/a11y-button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-react': patch
'@surfnet/curve-angular': patch
---

Button: the focus indicator is now a solid 2px outline with a 2px gap in `--primary-strong` (`--destructive` for destructive buttons), which meets 3:1 contrast in every theme, in light and dark mode.
5 changes: 5 additions & 0 deletions .changeset/a11y-calendar.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Calendar / Date Picker: day buttons are announced with the full date, and the grid has the month and year as its name, which is also announced when the month changes.
5 changes: 5 additions & 0 deletions .changeset/a11y-combobox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Combobox: the input now sets `aria-controls` and `aria-activedescendant`, so screen readers announce the highlighted option.
5 changes: 5 additions & 0 deletions .changeset/a11y-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@surfnet/curve-angular': patch
---

Command: the input has a default accessible name, the same as React.
6 changes: 6 additions & 0 deletions .changeset/a11y-context-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-react': patch
'@surfnet/curve-angular': patch
---

Context Menu: the trigger can be focused and opened with Shift+F10 or the Menu key, and shows the button focus outline. In Angular, focus now returns to the trigger when the menu closes.
6 changes: 6 additions & 0 deletions .changeset/a11y-sonner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@surfnet/curve-react': minor
'@surfnet/curve-angular': minor
---

Toast (Sonner): toasts now stay 8 seconds (was 4) and show a close button by default. Pass `duration` / `closeButton` to change this.
13 changes: 5 additions & 8 deletions packages/angular/src/lib/ui/button/src/lib/hlm-button.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@
transform: translateY(1px);
}

/* Solid 2px outline with a 2px gap: >= 3:1 against the page in every theme and mode.
--primary-strong is used because --ring is below 3:1 in several themes (see #250). */
.curve-button:focus-visible {
border-color: var(--ring);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--ring) 32%, transparent);
outline: 2px solid var(--primary-strong);
outline-offset: 2px;
}

.curve-button:disabled,
Expand Down Expand Up @@ -135,12 +137,7 @@
}

.curve-button--variant-destructive:focus-visible {
border-color: color-mix(in oklch, var(--destructive) 40%, transparent);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 20%, transparent);
}

.dark .curve-button--variant-destructive:focus-visible {
box-shadow: 0 0 0 3px color-mix(in oklch, var(--destructive) 40%, transparent);
outline-color: var(--destructive);
}

.curve-button--variant-link {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@ type ButtonArgs = HlmButton & { disabled: boolean };

const meta: Meta<ButtonArgs> = {
title: 'Components/Button',
// Unverified: shadcn/ui WCAG 2.2 AA audit (thefrontkit, 2026).
tags: ['a11y-minor'],
component: HlmButton,
decorators: [
moduleMetadata({
Expand Down
29 changes: 26 additions & 3 deletions packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-multi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,20 @@ import type { ClassValue } from 'clsx';
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.year }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.month }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.header }}</div>
}
}
<!-- Full month + year for screen readers: names the grid and announces month changes. -->
@if (captionLayout() !== 'label') {
<div brnCalendarHeader class="curve-calendar-sr-only">{{ heading.header }}</div>
}
</div>

<div class="curve-calendar-nav-group">
Expand Down Expand Up @@ -134,7 +138,12 @@ import type { ClassValue } from 'clsx';
<tr *brnCalendarWeek="let week" class="curve-calendar-week">
@for (date of week; track _dateAdapter.getTime(date)) {
<td brnCalendarCell class="curve-calendar-cell">
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
<button
brnCalendarCellButton
[date]="date"
[class]="_btnClass"
[attr.aria-label]="_labelDay(date)"
>
{{ _dateAdapter.getDate(date) }}
</button>
</td>
Expand Down Expand Up @@ -226,4 +235,18 @@ export class HlmCalendarMulti<T> {
);

protected readonly _selectClass = 'curve-calendar-select-trigger';

/** Full date for screen readers, e.g. "Wednesday, 4 November 2026" (the button only shows "4"). */
protected _labelDay(date: T): string {
return new Date(
this._dateAdapter.getYear(date),
this._dateAdapter.getMonth(date),
this._dateAdapter.getDate(date),
).toLocaleDateString(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
}
29 changes: 26 additions & 3 deletions packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar-range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,16 +85,20 @@ import type { ClassValue } from 'clsx';
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.year }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.month }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.header }}</div>
}
}
<!-- Full month + year for screen readers: names the grid and announces month changes. -->
@if (captionLayout() !== 'label') {
<div brnCalendarHeader class="curve-calendar-sr-only">{{ heading.header }}</div>
}
</div>

<div class="curve-calendar-nav-group">
Expand Down Expand Up @@ -133,7 +137,12 @@ import type { ClassValue } from 'clsx';
<tr *brnCalendarWeek="let week" class="curve-calendar-week">
@for (date of week; track _dateAdapter.getTime(date)) {
<td brnCalendarCell class="curve-calendar-cell curve-calendar-cell--range">
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
<button
brnCalendarCellButton
[date]="date"
[class]="_btnClass"
[attr.aria-label]="_labelDay(date)"
>
{{ _dateAdapter.getDate(date) }}
</button>
</td>
Expand Down Expand Up @@ -218,4 +227,18 @@ export class HlmCalendarRange<T> {
);

protected readonly _selectClass = 'curve-calendar-select-trigger';

/** Full date for screen readers, e.g. "Wednesday, 4 November 2026" (the button only shows "4"). */
protected _labelDay(date: T): string {
return new Date(
this._dateAdapter.getYear(date),
this._dateAdapter.getMonth(date),
this._dateAdapter.getDate(date),
).toLocaleDateString(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
}
13 changes: 13 additions & 0 deletions packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.css
Original file line number Diff line number Diff line change
Expand Up @@ -310,3 +310,16 @@
border-radius: calc(infinity * 1px);
background-color: var(--destructive);
}

/* Visually hidden month + year heading (screen readers only). */
.curve-calendar-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
29 changes: 26 additions & 3 deletions packages/angular/src/lib/ui/calendar/src/lib/hlm-calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,20 @@ import type { ClassValue } from 'clsx';
}
@case ('dropdown-months') {
<ng-container [ngTemplateOutlet]="month" />
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.year }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.year }}</div>
}
@case ('dropdown-years') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.month }}</div>
<div class="curve-calendar-heading" aria-hidden="true">{{ heading.month }}</div>
<ng-container [ngTemplateOutlet]="year" />
}
@case ('label') {
<div brnCalendarHeader class="curve-calendar-heading">{{ heading.header }}</div>
}
}
<!-- Full month + year for screen readers: names the grid and announces month changes. -->
@if (captionLayout() !== 'label') {
<div brnCalendarHeader class="curve-calendar-sr-only">{{ heading.header }}</div>
}
</div>

<div class="curve-calendar-nav-group">
Expand Down Expand Up @@ -126,7 +130,12 @@ import type { ClassValue } from 'clsx';
<tr *brnCalendarWeek="let week" class="curve-calendar-week">
@for (date of week; track _dateAdapter.getTime(date)) {
<td brnCalendarCell class="curve-calendar-cell">
<button brnCalendarCellButton [date]="date" [class]="_btnClass">
<button
brnCalendarCellButton
[date]="date"
[class]="_btnClass"
[attr.aria-label]="_labelDay(date)"
>
{{ _dateAdapter.getDate(date) }}
</button>
</td>
Expand Down Expand Up @@ -208,4 +217,18 @@ export class HlmCalendar<T> {
);

protected readonly _selectClass = 'curve-calendar-select-trigger';

/** Full date for screen readers, e.g. "Wednesday, 4 November 2026" (the button only shows "4"). */
protected _labelDay(date: T): string {
return new Date(
this._dateAdapter.getYear(date),
this._dateAdapter.getMonth(date),
this._dateAdapter.getDate(date),
).toLocaleDateString(undefined, {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import { Directive } from '@angular/core';
import { BrnComboboxChipInput } from '@spartan-ng/brain/combobox';
import { classes } from '../../../utils/src';
import { injectHlmComboboxAria } from './hlm-combobox-listbox-id';

@Directive({
selector: 'input[hlmComboboxChipInput]',
hostDirectives: [{ directive: BrnComboboxChipInput, inputs: ['id', 'aria-invalid'] }],
host: { 'data-slot': 'combobox-chip-input' },
host: {
'data-slot': 'combobox-chip-input',
'[attr.aria-controls]': '_aria.controls()',
'[attr.aria-activedescendant]': '_aria.activeDescendant()',
},
})
export class HlmComboboxChipInput {
protected readonly _aria = injectHlmComboboxAria();

constructor() {
classes(() => 'curve-combobox-chip-input');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
injectBrnComboboxBase,
} from '@spartan-ng/brain/combobox';
import { HlmInputGroupImports } from '../../../input-group/src';
import { injectHlmComboboxListboxId } from './hlm-combobox-listbox-id';
import { injectHlmComboboxAria, injectHlmComboboxListboxId } from './hlm-combobox-listbox-id';

@Component({
selector: 'hlm-combobox-input',
Expand All @@ -26,6 +26,8 @@ import { injectHlmComboboxListboxId } from './hlm-combobox-listbox-id';
[placeholder]="placeholder()"
[forceInvalid]="forceInvalid()"
[aria-invalid]="ariaInvalidOverride()"
[attr.aria-controls]="_aria.controls()"
[attr.aria-activedescendant]="_aria.activeDescendant()"
/>

<hlm-input-group-addon align="inline-end">
Expand Down Expand Up @@ -72,6 +74,7 @@ export class HlmComboboxInput {
private readonly _combobox = injectBrnComboboxBase();
protected readonly _listboxId = injectHlmComboboxListboxId();
protected readonly _isExpanded = this._combobox.isExpanded;
protected readonly _aria = injectHlmComboboxAria();

public readonly inputId = input<string>(`hlm-combobox-input-${HlmComboboxInput._id++}`);
public readonly placeholder = input<string>('');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { inject, InjectionToken, type ValueProvider } from '@angular/core';
import { computed, inject, InjectionToken, type Signal, type ValueProvider } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { injectBrnComboboxBase } from '@spartan-ng/brain/combobox';

let listboxId = 0;

Expand All @@ -14,3 +16,27 @@ export function provideHlmComboboxListboxId(): ValueProvider {
export function injectHlmComboboxListboxId(): string | null {
return inject(HlmComboboxListboxId, { optional: true });
}

/**
* ARIA links for a combobox input: `aria-controls` points at the open listbox and
* `aria-activedescendant` at the highlighted option, so screen readers announce the
* option while focus stays in the input. Both are `null` while the popup is closed.
*/
export function injectHlmComboboxAria(): {
controls: Signal<string | null>;
activeDescendant: Signal<string | null>;
} {
const combobox = injectBrnComboboxBase();
const listboxId = injectHlmComboboxListboxId();
// `change` emits the new active index; reading it makes the computed below re-run.
const activeIndex = toSignal(combobox.keyManager.change, { initialValue: -1 });

return {
controls: computed(() => (combobox.isExpanded() ? listboxId : null)),
activeDescendant: computed(() => {
activeIndex();
if (!combobox.isExpanded()) return null;
return combobox.keyManager.activeItem?.id() ?? null;
}),
};
}
Loading
Loading