Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
8feaf7d
FSHSP-106 fix(input): promote the popup trigger to role="combobox"
LBU4SH Aug 17, 2026
55df8fa
FSHSP-106 fix(input-number): stop advertising NaN as an ARIA bound
LBU4SH Aug 24, 2026
36e194c
FSHSP-106 fix(input-tags,autocomplete): name and scope the tags listbox
LBU4SH Aug 24, 2026
e52e8ea
FSHSP-106 fix(datepicker): give the month/year grids their ARIA rows
LBU4SH Aug 24, 2026
8e60acb
FSHSP-106 fix(tag,badge,chip): make the aria-label on the root legal
LBU4SH Aug 24, 2026
78dc530
FSHSP-106 fix(table,modal): make the scrollable regions keyboard-reac…
LBU4SH Aug 24, 2026
1bf54b4
FSHSP-106 fix(button): stop dropping projected content next to an icon
LBU4SH Aug 24, 2026
c025d86
FSHSP-106 feat(styles): add an .sr-only helper, and name the empty ta…
LBU4SH Aug 24, 2026
8f24865
FSHSP-106 fix(tabs,stepper): omit aria-controls when the panel is not…
LBU4SH Aug 25, 2026
636341d
FSHSP-106 fix(stepper): switch vertical mode to the accordion ARIA pa…
LBU4SH Aug 25, 2026
9228f6a
FSHSP-106 chore(input-tags,autocomplete): waive nested-interactive on…
LBU4SH Aug 25, 2026
9610f08
FSHSP-106 fix(tokens): bump two content tokens over the AA contrast t…
LBU4SH Aug 25, 2026
b44bfc8
FSHSP-106 fix(tabs): stop the panel query from dropping the tab labels
LBU4SH Aug 25, 2026
3ce4ba3
FSHSP-106 fix(tokens): move the error surface onto the conforming red
LBU4SH Aug 25, 2026
bad225d
FSHSP-106 feat(tokens): add green-650 and take the success surface to AA
LBU4SH Aug 25, 2026
6f1933e
FSHSP-106 fix(tokens): shift the warning surface ramp one step darker
LBU4SH Aug 25, 2026
90b32d9
FSHSP-106 fix(button): reset the On Color Light banner onto a light s…
LBU4SH Aug 25, 2026
fdcf1ac
FSHSP-106 fix(segment-control,button): clear the last two story-side …
LBU4SH Aug 25, 2026
b1e132b
FSHSP-106 fix(breadcrumb): state that a disabled crumb is disabled
LBU4SH Aug 25, 2026
51bdf9e
FSHSP-106 fix(datepicker): take the adjacent-month days to AA
LBU4SH Aug 25, 2026
835d736
FSHSP-106 chore(docs): regenerate the component-vars manifests
LBU4SH Aug 25, 2026
d0e22b5
FSHSP-106 docs(figma): refresh the alias counts quoted in the README
LBU4SH Aug 25, 2026
f6ab5bd
FSHSP-106 style(datepicker): drop the rationale comment on the other-…
LBU4SH Aug 25, 2026
b127aeb
FSHSP-106 fix(tokens)!: shift the success ramp instead of adding gree…
LBU4SH Aug 25, 2026
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
19 changes: 19 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion figma/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ The components' `--ui-*` variables, in the same DTCG shape as
one too. Generated by `npm run docs:config`; `docs:config:check` fails if it is stale.

**These are not design tokens.** A token belongs to the system (`--units-sm`); a
component variable belongs to one component, and 461 of the 582 entries merely _alias_ a
component variable belongs to one component, and 462 of the 583 entries merely _alias_ a
token. Hence a **dedicated collection** (`$extensions.com.4sh.ui-kit.figmaCollection`)
rather than adding them to `semantics` or `metrics`.

Expand Down
26 changes: 24 additions & 2 deletions figma/component-vars.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"Mode 1"
],
"counts": {
"tokens": 582,
"alias": 461,
"tokens": 583,
"alias": 462,
"literal": 121,
"skipped": 46,
"derivedDescriptions": 197
Expand Down Expand Up @@ -3362,6 +3362,28 @@
}
},
"day": {
"color-outside": {
"$value": "{semantics.global.text.default}",
"$type": "color",
"$description": "Couleur d'un jour appartenant au mois adjacent, avant atténuation.",
"$extensions": {
"com.figma": {
"resolvedType": "COLOR",
"scopes": [
"ALL_FILLS"
],
"codeSyntax": {
"WEB": "var(--ui-datepicker-day-color-outside)"
}
},
"com.4sh.ui-kit": {
"cssVar": "--ui-datepicker-day-color-outside",
"component": "ui-datepicker",
"entryPoint": "@4sh/ui-kit/forms/ui-datepicker",
"descriptionSource": "scss"
}
}
},
"font-size": {
"$value": "{responsive.size.typography.text.default}",
"$type": "dimension",
Expand Down
17 changes: 9 additions & 8 deletions projects/ui-kit/actions/ui-button/src/lib/ui-button.html
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,15 @@
<ng-container [ngTemplateOutlet]="iconSlot" />
}

@if (label() || !isIconOnly()) {
<span class="ui-button-content" #contentWrap>
@if (label() && !isIconOnly()) {
<span class="ui-button-label">{{ label() }}</span>
}
<ng-content />
</span>
}
<!-- Always rendered: `hasProjectedContent` is detected from this very element, so gating it on
`isIconOnly()` (which itself derives from that detection) made projected-only content
undetectable — and silently dropped. Collapsed by `.ui-button-content:empty` when unused. -->
<span class="ui-button-content" #contentWrap>
@if (label() && !isIconOnly()) {
<span class="ui-button-label">{{ label() }}</span>
}
<ng-content />
</span>

@if (!iconBefore()) {
<ng-container [ngTemplateOutlet]="iconSlot" />
Expand Down
16 changes: 13 additions & 3 deletions projects/ui-kit/actions/ui-button/ui-button.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,11 @@ export const GhostLevels: Story = variantRow('ghost');
// `onColor` n'a de sens que POSÉ sur un fond de couleur : ces planches
// reproduisent le bandeau, sinon il n'y a rien à démontrer. Fonds pris sur des
// tokens (jamais une couleur en dur) et choisis dans le domaine de validité de
// chaque polarité : primary.500 pour `dark`, warning/orange.500 pour `light`.
// chaque polarité : `actions.high.surface.default` (primary.500) pour `dark`,
// `informative.warningLow.surface.default` (orange.50) pour `light`.
// Ce dernier remplace `actions.warning.surface.default`, qui a été assombri en
// orange.700 pour passer AA sous du texte blanc (FSHSP-106) : il n'est donc plus
// un fond clair et mettait la polarité `light` en échec (3,42:1).
const onColorBanner = (polarity: 'dark' | 'light', surface: string): Story => ({
render: () => ({
template: `
Expand All @@ -216,7 +220,7 @@ const onColorBanner = (polarity: 'dark' | 'light', surface: string): Story => ({
});

export const OnColorDark: Story = onColorBanner('dark', '--actions-high-surface-default');
export const OnColorLight: Story = onColorBanner('light', '--actions-warning-surface-default');
export const OnColorLight: Story = onColorBanner('light', '--informative-warninglow-surface-default');

// Sans `onColor`, le même bandeau met le bouton en échec : c'est le problème
// que l'axe résout, et la comparaison vaut mieux qu'un paragraphe.
Expand All @@ -231,7 +235,13 @@ export const OnColorOmitted: Story = {
</div>
`,
}),
parameters: { layout: 'padded' },
// The failing contrast IS the subject of this story — it shows what `onColor`
// exists to fix. Waived rather than corrected (FSHSP-106): correcting it would
// delete the demonstration.
parameters: {
layout: 'padded',
a11y: { config: { rules: [{ id: 'color-contrast', enabled: false }] } },
},
};

// Tailles
Expand Down
19 changes: 11 additions & 8 deletions projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,6 @@
>
<div
class="ui-autocomplete-control"
[attr.role]="multiple() && !selectedItemTemplate() ? 'listbox' : null"
[attr.aria-orientation]="multiple() && !selectedItemTemplate() ? 'horizontal' : null"
[attr.aria-label]="
multiple() && !selectedItemTemplate() && !label() ? ariaLabel() || null : null
"
[attr.aria-labelledby]="
multiple() && !selectedItemTemplate() ? ariaLabelledBy() || null : null
"
(mousedown)="onBoxMousedown($event)"
>
@if (multiple()) {
Expand All @@ -169,6 +161,16 @@
<ng-container [ngTemplateOutlet]="tpl" [ngTemplateOutletContext]="ctx" />
}
} @else {
<!-- The listbox wraps ONLY the option tags: it may contain neither the combobox input
nor the informative overflow chip. `display: contents` keeps the tags flowing in
the box exactly as before. -->
<div
class="ui-autocomplete-tags"
role="listbox"
aria-orientation="horizontal"
[attr.aria-label]="ariaLabelledBy() ? null : ariaLabel() || label() || null"
[attr.aria-labelledby]="ariaLabelledBy() || null"
>
@for (tag of visibleTagRows(); track $index) {
<ui-chip
#tagEl
Expand All @@ -190,6 +192,7 @@
(remove)="removeAt(tag.index)"
/>
}
</div>
}
@if (overflowCount() > 0) {
<!-- Collapsed remainder (`maxSelectedLabels`): informative only, not removable. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ $focus-ring-width: var(--ui-autocomplete-focus-ring-width, #{utils.$form-focus-r
// --- Selected value chip (role=option, roving focus, `multiple`) ----
// The chip IS the focusable option; fill/padding/remove come from
// `ui-chip` — this rule only handles layout + the roving focus ring.
// The `role="listbox"` wrapper is ARIA-only: the tags stay direct children of the box.
&-tags { display: contents; }

&-tag {
display: inline-flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,9 @@ export const Group: Story = {
* `forceSelection` efface au blur toute saisie non reconnue sans toucher à la sélection.
*/
export const Multiple: Story = {
// FSHSP-106: each selected tag's remove button sits inside its role="option" — accepted for
// now, ticket to follow for the underlying listbox/chip pattern (same as ui-input-tags).
parameters: { a11y: { config: { rules: [{ id: 'nested-interactive', enabled: false }] } } },
render: () => ({
props: {
a: completer(COUNTRIES, (c) => c.name),
Expand Down
64 changes: 36 additions & 28 deletions projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,24 @@
@if (currentView() === 'year') {
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
<div class="ui-datepicker-picker _year" role="grid" (keydown)="onYearGridKeydown($event)">
@for (y of years(); track y.year) {
<button
type="button"
role="gridcell"
class="ui-datepicker-cell"
[class._selected]="y.selected"
[class._focusable]="isFocusableYear(y)"
[disabled]="y.disabled"
[attr.tabindex]="isFocusableYear(y) ? 0 : -1"
[attr.aria-selected]="y.selected"
(click)="selectYear(y)"
>
{{ y.year }}
</button>
@for (row of yearRows(); track $index) {
<div class="ui-datepicker-picker-row" role="row">
@for (y of row; track y.year) {
<button
type="button"
role="gridcell"
class="ui-datepicker-cell"
[class._selected]="y.selected"
[class._focusable]="isFocusableYear(y)"
[disabled]="y.disabled"
[attr.tabindex]="isFocusableYear(y) ? 0 : -1"
[attr.aria-selected]="y.selected"
(click)="selectYear(y)"
>
{{ y.year }}
</button>
}
</div>
}
</div>
}
Expand All @@ -71,20 +75,24 @@
@else if (currentView() === 'month') {
<!-- eslint-disable-next-line @angular-eslint/template/interactive-supports-focus -->
<div class="ui-datepicker-picker _month" role="grid" (keydown)="onMonthGridKeydown($event)">
@for (m of months(); track m.index) {
<button
type="button"
role="gridcell"
class="ui-datepicker-cell"
[class._selected]="m.selected"
[class._focusable]="isFocusableMonth(m)"
[disabled]="m.disabled"
[attr.tabindex]="isFocusableMonth(m) ? 0 : -1"
[attr.aria-selected]="m.selected"
(click)="selectMonth(m)"
>
{{ m.label }}
</button>
@for (row of monthRows(); track $index) {
<div class="ui-datepicker-picker-row" role="row">
@for (m of row; track m.index) {
<button
type="button"
role="gridcell"
class="ui-datepicker-cell"
[class._selected]="m.selected"
[class._focusable]="isFocusableMonth(m)"
[disabled]="m.disabled"
[attr.tabindex]="isFocusableMonth(m) ? 0 : -1"
[attr.aria-selected]="m.selected"
(click)="selectMonth(m)"
>
{{ m.label }}
</button>
}
</div>
}
</div>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ $panel-max-width: var(--ui-datepicker-panel-max-width, calc(100vw - var(--units-
$day-size: var(--ui-datepicker-day-size, var(--size-components-default)); /// Diamètre d'une cellule jour
$day-radius: var(--ui-datepicker-day-radius, var(--radius-full)); /// Rayon des cellules jour (cercle)
$day-font-size: var(--ui-datepicker-day-font-size, var(--size-typography-text-default)); /// Taille du chiffre d'une cellule jour.
$day-color-other-month: var(--ui-datepicker-day-color-outside, var(--global-text-default)); /// Couleur d'un jour appartenant au mois adjacent, avant atténuation.
$day-opacity-other-month: var(--ui-datepicker-day-opacity-outside, 0.6); /// Opacité d'un jour appartenant au mois adjacent.
$day-opacity-disabled: var(--ui-datepicker-day-opacity-disabled, 0.45); /// Opacité d'un jour désactivé.

Expand Down Expand Up @@ -187,6 +188,9 @@ $buttonbar-padding-top: var(--ui-datepicker-buttonbar-padding-top, var(--units-s
gap: $picker-gap;
width: $panel-width;

// The `role="row"` wrappers exist for ARIA only: the cells stay direct grid items.
&-row { display: contents; }

&._month { grid-template-columns: repeat(3, 1fr); }
&._year { grid-template-columns: repeat(2, 1fr); }
}
Expand Down Expand Up @@ -287,7 +291,7 @@ $buttonbar-padding-top: var(--ui-datepicker-buttonbar-padding-top, var(--units-s
}

// Previous/next month + disabled
&._other-month { color: var(--form-low-content-default); opacity: $day-opacity-other-month; }
&._other-month { color: $day-color-other-month; opacity: $day-opacity-other-month; }

// Today (outlined chip) — only when not selected.
&._today::before { border-color: var(--actions-high-stroke-default); }
Expand Down
15 changes: 15 additions & 0 deletions projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ const RANGE_DISPLAY_SEPARATOR = ' – ';
/** Typed/displayed `multiple` separator (`"jj/mm/aaaa, jj/mm/aaaa, ..."`) — one separator for both. */
const MULTIPLE_SEPARATOR = ', ';

/** Splits a flat cell list into rows of `size` (month/year pickers need `role="row"` wrappers). */
function chunk<T>(items: readonly T[], size: number): T[][] {
const rows: T[][] = [];
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
return rows;
}

/**
* ui-datepicker — headless date / month / year (and optional time) picker.
*
Expand Down Expand Up @@ -797,6 +804,14 @@ export class UiDatepicker extends BaseFormField<DatepickerValue> {
});
});

/** @ignore Month/year cells chunked into the rows the CSS actually renders (3 and 2 columns,
* see `.ui-datepicker-picker._month` / `._year`). A `role="grid"` requires its `gridcell`s to
* sit inside a `role="row"`; without it the whole grid is invalid ARIA and screen readers get
* no row structure to walk. Keep the chunk sizes in step with the SCSS column counts. */
protected readonly monthRows = computed<DatepickerMonthCell[][]>(() => chunk(this.months(), 3));
/** @ignore Year-picker rows — 2 columns, see `.ui-datepicker-picker._year`. */
protected readonly yearRows = computed<DatepickerYearCell[][]>(() => chunk(this.years(), 2));

/** @ignore Hours as shown in the 12h stepper (1–12). */
protected readonly displayHours = computed(() => {
if (this.hourFormat() === '24') return this.hours();
Expand Down
2 changes: 1 addition & 1 deletion projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ via l'interop CVA native, ainsi qu'aux formulaires template-driven (`[(ngModel)]
<tbody>
<tr>
<td><strong>Déclencheur</strong></td>
<td>Champ <code>ui-input</code> lié à un <code>label</code> natif. Ouverture au clic, ou clavier <kbd>Entrée</kbd>/<kbd>Espace</kbd>/<kbd>↓</kbd> ; <code>aria-haspopup="dialog"</code> + <code>aria-expanded</code>.</td>
<td>Champ <code>ui-input</code> lié à un <code>label</code> natif. Ouverture au clic, ou clavier <kbd>Entrée</kbd>/<kbd>Espace</kbd>/<kbd>↓</kbd> ; <code>role="combobox"</code> + <code>aria-haspopup="dialog"</code> + <code>aria-expanded</code> — le rôle est nécessaire pour qu'<code>aria-expanded</code> soit pris en compte (interdit sur le <code>textbox</code> implicite), donc pour que l'existence du calendrier et son état soient annoncés.</td>
</tr>
<tr>
<td><strong>Navigation grille</strong></td>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
[attr.aria-invalid]="effectiveLevel() === 'error' ? 'true' : null"
role="spinbutton"
[attr.aria-valuenow]="modelValue() ?? null"
[attr.aria-valuemin]="min() ?? null"
[attr.aria-valuemax]="max() ?? null"
[attr.aria-valuemin]="ariaValueMin()"
[attr.aria-valuemax]="ariaValueMax()"
(input)="onInput()"
(keydown)="onKeydown($event)"
(focus)="onFocus($event)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export class UiInputNumber extends BaseFormField<number | null> {
const min = this.min();
return v != null && min != null && v <= min;
});
/** @ignore `numberAttribute(undefined)` yields NaN, and `aria-valuemin="NaN"` is an invalid
* ARIA value — omit the attribute entirely rather than advertise a bound that does not exist. */
protected readonly ariaValueMin = computed(() => (Number.isFinite(this.min()) ? this.min() : null));
/** @ignore Same as `ariaValueMin`, for the upper bound. */
protected readonly ariaValueMax = computed(() => (Number.isFinite(this.max()) ? this.max() : null));

/** @ignore Group/decimal separators of the current locale. */
private readonly separators = computed(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@
role="listbox"
aria-orientation="horizontal"
[attr.id]="listboxId()"
[attr.aria-label]="label() ? null : ariaLabel() || null"
[attr.aria-label]="ariaLabelledBy() ? null : ariaLabel() || label() || null"
[attr.aria-labelledby]="ariaLabelledBy() || null"
>
@for (tag of tagRows(); track $index) {
Expand Down
3 changes: 3 additions & 0 deletions projects/ui-kit/forms/ui-input-tags/ui-input-tags.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ const meta: Meta<UiInputTags> = {
type: 'figma',
url: 'https://www.figma.com/design/GZww5hdUA49LB8XWeWP6tl/-Projet----UI-Kit?node-id=3614-3746&t=hED8P0S1ohO6dLVA-1',
},
// FSHSP-106: each tag's remove button sits inside its role="option" — accepted for now,
// ticket to follow for the underlying listbox/chip pattern.
a11y: { config: { rules: [{ id: 'nested-interactive', enabled: false }] } },
},
argTypes: {
placeholder: {
Expand Down
1 change: 1 addition & 0 deletions projects/ui-kit/forms/ui-input/src/lib/ui-input.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
[attr.tabindex]="tabindex()"
[attr.autocomplete]="autocomplete() || null"
[attr.maxlength]="maxlength()"
[attr.role]="nativeRole()"
[attr.aria-haspopup]="ariaHasPopup() || null"
[attr.aria-expanded]="ariaExpanded() ?? null"
[attr.aria-controls]="ariaControls() || null"
Expand Down
11 changes: 10 additions & 1 deletion projects/ui-kit/forms/ui-input/src/lib/ui-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ export class UiInput extends BaseFormField<string> {
value = input<string>();
/** `aria-haspopup` forwarded to the native input (popup trigger hosts). */
ariaHasPopup = input<'menu' | 'listbox' | 'tree' | 'grid' | 'dialog'>();
/** `aria-expanded` forwarded to the native input (popup trigger hosts). */
/**
* `aria-expanded` forwarded to the native input (popup trigger hosts).
* Setting it also promotes the input to `role="combobox"`: `aria-expanded`
* is not allowed on the implicit `textbox` role, so screen readers would
* silently drop the popup state without it.
*/
ariaExpanded = input<boolean>();
/** `aria-controls` forwarded to the native input (popup trigger hosts). */
ariaControls = input<string>();
Expand Down Expand Up @@ -157,6 +162,10 @@ export class UiInput extends BaseFormField<string> {
const joined = ids.filter((id): id is string => !!id).join(' ');
return joined || null;
});
/** @ignore ARIA 1.2: only `combobox` (not the implicit `textbox`) accepts
* `aria-expanded`. Stays null for plain text fields so their announcement
* ("edit text") is untouched. */
protected readonly nativeRole = computed(() => (this.ariaExpanded() === undefined ? null : 'combobox'));

/** Focuses the input. */
focus(options?: FocusOptions): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ export const Template: Story = {
<ng-template #item let-plan>
<span style="display:flex; flex-direction:column; align-items:center; line-height:1.1;">
<strong>{{ plan.label }}</strong>
<small style="opacity:.75;">{{ plan.hint }}</small>
<small>{{ plan.hint }}</small>
</span>
</ng-template>
<code>model = {{ model }}</code>
Expand Down
Loading