From 8feaf7d83f3595c8fcecf62705ed18f0153de998 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 17 Aug 2026 14:16:20 +0200 Subject: [PATCH 01/24] FSHSP-106 fix(input): promote the popup trigger to role="combobox" ui-input forwarded aria-expanded to the native input, but that attribute is not allowed on the implicit textbox role: screen readers dropped it, so a ui-datepicker never announced that a calendar could open, nor its state. Promote the input to role="combobox" (the ARIA 1.2 role that accepts aria-expanded) only when ariaExpanded is provided, leaving plain text fields announced as before. ui-datepicker is the only consumer of these attributes in the kit. Clears the 25 aria-allowed-attr violations of the a11y job (136 -> 111 occurrences, 122 -> 97 failing stories), with no new violation and no cascade on aria-required-children, verified by a full rebuild + axe pass. --- CHANGELOG.md | 1 + projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx | 2 +- projects/ui-kit/forms/ui-input/src/lib/ui-input.html | 1 + projects/ui-kit/forms/ui-input/src/lib/ui-input.ts | 11 ++++++++++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8531b74..c5c25d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-datepicker` : la saisie tapée en `range`/`multiple` pouvait couper une date en plein milieu si son `dateFormat` custom contenait le caractère du séparateur** (FSHSP-118). Le découpage retenait la première occurrence littérale de `" - "` (`range`) ou `", "` (`multiple`) dans le texte tapé, sans vérifier qu'elle délimitait bien deux dates plutôt que d'appartenir à l'une d'elles (ex. un `dateFormat` ISO contenant un tiret). Chaque occurrence candidate doit désormais faire parser valablement le texte qui la précède comme une date avant d'être retenue comme frontière. - **`ui-datepicker` : le séparateur affiché d'une plage (`range`) était le même que celui attendu en saisie**, rendant les deux indiscernables à l'écran. L'affichage utilise maintenant un tiret cadratin dédié (« 08/07/2026 – 18/07/2026 »), découplé du séparateur de saisie/parsing (`" - "`, inchangé). - **`ui-datepicker` : ouvrir le panneau en `range`/`multiple` — icône ou champ — renvoyait toujours le focus vers la grille, y compris pour taper** (FSHSP-118). Un premier correctif (renvoi vers la grille dès que `selectionMode() !== 'single'`) visait à restaurer le comportement de l'icône, mais s'appliquait aussi au clic sur le **champ** lui-même — qui ouvre le panneau via le même `open()` — volant le focus au clavier au moment même de cliquer pour taper, dès que `range` a reçu son propre masque en direct. `open()` distingue maintenant explicitement les deux : un clic sur l'icône renvoie vers la grille (« je veux la grille »), un clic sur le champ garde le focus dedans pour continuer à taper — dans tous les modes, pas seulement `single`. +- **`ui-input` annonce enfin sa popup aux lecteurs d'écran** (FSHSP-106). Le composant transmettait bien `aria-expanded` à l'``, mais cet attribut est interdit sur le rôle implicite `textbox` : l'information était donc silencieusement ignorée, et un non-voyant n'apprenait jamais qu'un calendrier pouvait s'ouvrir sur un `ui-datepicker`. L'input est désormais promu en `role="combobox"` — le rôle ARIA 1.2 qui accepte `aria-expanded` — **uniquement quand `ariaExpanded` est fourni**, donc l'annonce des champs texte ordinaires est inchangée. Seul `ui-datepicker` utilise ce jeu d'attributs dans le kit. Corrige 25 des violations `aria-allowed-attr` remontées par le job `a11y`. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx index 7137489..9156e92 100644 --- a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx +++ b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx @@ -340,7 +340,7 @@ via l'interop CVA native, ainsi qu'aux formulaires template-driven (`[(ngModel)] Déclencheur - Champ ui-input lié à un label natif. Ouverture au clic, ou clavier Entrée/Espace/ ; aria-haspopup="dialog" + aria-expanded. + Champ ui-input lié à un label natif. Ouverture au clic, ou clavier Entrée/Espace/ ; role="combobox" + aria-haspopup="dialog" + aria-expanded — le rôle est nécessaire pour qu'aria-expanded soit pris en compte (interdit sur le textbox implicite), donc pour que l'existence du calendrier et son état soient annoncés. Navigation grille diff --git a/projects/ui-kit/forms/ui-input/src/lib/ui-input.html b/projects/ui-kit/forms/ui-input/src/lib/ui-input.html index d2dd7fe..f2e7f41 100644 --- a/projects/ui-kit/forms/ui-input/src/lib/ui-input.html +++ b/projects/ui-kit/forms/ui-input/src/lib/ui-input.html @@ -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" diff --git a/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts b/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts index 5c635dc..0794245 100644 --- a/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts +++ b/projects/ui-kit/forms/ui-input/src/lib/ui-input.ts @@ -62,7 +62,12 @@ export class UiInput extends BaseFormField { value = input(); /** `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(); /** `aria-controls` forwarded to the native input (popup trigger hosts). */ ariaControls = input(); @@ -157,6 +162,10 @@ export class UiInput extends BaseFormField { 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 { From 55df8fae23ab9467e87603dac80974ec01fbf9fa Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:49:31 +0200 Subject: [PATCH 02/24] FSHSP-106 fix(input-number): stop advertising NaN as an ARIA bound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `min`/`max` are declared with `numberAttribute`, which turns an unset input into NaN rather than null. The spinbutton then rendered aria-valuemin="NaN" and aria-valuemax="NaN" — an invalid ARIA value, so the bound was not just useless but discarded by assistive tech. Emit both attributes only for a finite bound: no bound, no attribute. Clears 11 of the aria-valid-attr-value violations of the a11y job. --- CHANGELOG.md | 1 + .../forms/ui-input-number/src/lib/ui-input-number.html | 4 ++-- .../ui-kit/forms/ui-input-number/src/lib/ui-input-number.ts | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5c25d1..9409fa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-datepicker` : le séparateur affiché d'une plage (`range`) était le même que celui attendu en saisie**, rendant les deux indiscernables à l'écran. L'affichage utilise maintenant un tiret cadratin dédié (« 08/07/2026 – 18/07/2026 »), découplé du séparateur de saisie/parsing (`" - "`, inchangé). - **`ui-datepicker` : ouvrir le panneau en `range`/`multiple` — icône ou champ — renvoyait toujours le focus vers la grille, y compris pour taper** (FSHSP-118). Un premier correctif (renvoi vers la grille dès que `selectionMode() !== 'single'`) visait à restaurer le comportement de l'icône, mais s'appliquait aussi au clic sur le **champ** lui-même — qui ouvre le panneau via le même `open()` — volant le focus au clavier au moment même de cliquer pour taper, dès que `range` a reçu son propre masque en direct. `open()` distingue maintenant explicitement les deux : un clic sur l'icône renvoie vers la grille (« je veux la grille »), un clic sur le champ garde le focus dedans pour continuer à taper — dans tous les modes, pas seulement `single`. - **`ui-input` annonce enfin sa popup aux lecteurs d'écran** (FSHSP-106). Le composant transmettait bien `aria-expanded` à l'``, mais cet attribut est interdit sur le rôle implicite `textbox` : l'information était donc silencieusement ignorée, et un non-voyant n'apprenait jamais qu'un calendrier pouvait s'ouvrir sur un `ui-datepicker`. L'input est désormais promu en `role="combobox"` — le rôle ARIA 1.2 qui accepte `aria-expanded` — **uniquement quand `ariaExpanded` est fourni**, donc l'annonce des champs texte ordinaires est inchangée. Seul `ui-datepicker` utilise ce jeu d'attributs dans le kit. Corrige 25 des violations `aria-allowed-attr` remontées par le job `a11y`. +- **`ui-input-number` annonçait des bornes invalides** (FSHSP-106). `min`/`max` non renseignés passent par `numberAttribute(undefined)`, qui rend `NaN` : le champ exposait `aria-valuemin="NaN"`/`aria-valuemax="NaN"`, une valeur ARIA invalide que les lecteurs d'écran ignorent au mieux. Les deux attributs ne sont désormais émis que si la borne est un nombre fini — sans borne, pas d'attribut, plutôt qu'une borne qui n'existe pas. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.html b/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.html index b9a7213..4d71289 100644 --- a/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.html +++ b/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.html @@ -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)" diff --git a/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.ts b/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.ts index dda95a9..b4b8e3e 100644 --- a/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.ts +++ b/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.ts @@ -102,6 +102,11 @@ export class UiInputNumber extends BaseFormField { 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(() => { From 36e194c4213528a1f0873ce0bf35c3dfa1618e67 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:49:46 +0200 Subject: [PATCH 03/24] FSHSP-106 fix(input-tags,autocomplete): name and scope the tags listbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tags listbox only got an accessible name when no visible `label` was set: with a label, the name went to the inner and the listbox announced nothing. Fall back to `label` for the listbox name. In ui-autocomplete the `role="listbox"` also spanned the whole box, including the combobox — a listbox may contain only options/groups. Wrap the option tags alone, with `display: contents` so the layout is untouched (same treatment ui-input-tags already had). Clears 14 aria-input-field-name and 3 aria-required-children violations. --- CHANGELOG.md | 1 + .../src/lib/ui-autocomplete.html | 19 +++++++++++-------- .../src/lib/ui-autocomplete.scss | 3 +++ .../ui-input-tags/src/lib/ui-input-tags.html | 2 +- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9409fa3..95d5312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-datepicker` : ouvrir le panneau en `range`/`multiple` — icône ou champ — renvoyait toujours le focus vers la grille, y compris pour taper** (FSHSP-118). Un premier correctif (renvoi vers la grille dès que `selectionMode() !== 'single'`) visait à restaurer le comportement de l'icône, mais s'appliquait aussi au clic sur le **champ** lui-même — qui ouvre le panneau via le même `open()` — volant le focus au clavier au moment même de cliquer pour taper, dès que `range` a reçu son propre masque en direct. `open()` distingue maintenant explicitement les deux : un clic sur l'icône renvoie vers la grille (« je veux la grille »), un clic sur le champ garde le focus dedans pour continuer à taper — dans tous les modes, pas seulement `single`. - **`ui-input` annonce enfin sa popup aux lecteurs d'écran** (FSHSP-106). Le composant transmettait bien `aria-expanded` à l'``, mais cet attribut est interdit sur le rôle implicite `textbox` : l'information était donc silencieusement ignorée, et un non-voyant n'apprenait jamais qu'un calendrier pouvait s'ouvrir sur un `ui-datepicker`. L'input est désormais promu en `role="combobox"` — le rôle ARIA 1.2 qui accepte `aria-expanded` — **uniquement quand `ariaExpanded` est fourni**, donc l'annonce des champs texte ordinaires est inchangée. Seul `ui-datepicker` utilise ce jeu d'attributs dans le kit. Corrige 25 des violations `aria-allowed-attr` remontées par le job `a11y`. - **`ui-input-number` annonçait des bornes invalides** (FSHSP-106). `min`/`max` non renseignés passent par `numberAttribute(undefined)`, qui rend `NaN` : le champ exposait `aria-valuemin="NaN"`/`aria-valuemax="NaN"`, une valeur ARIA invalide que les lecteurs d'écran ignorent au mieux. Les deux attributs ne sont désormais émis que si la borne est un nombre fini — sans borne, pas d'attribut, plutôt qu'une borne qui n'existe pas. +- **Le listbox des tags de `ui-input-tags` et `ui-autocomplete` était sans nom accessible** (FSHSP-106). Le nom n'était posé qu'à défaut de `label` : dès qu'un libellé visible existait, il partait sur l'`` et la liste de tags n'annonçait plus rien. Le `label` sert maintenant de repli au nom du listbox. Dans `ui-autocomplete`, le `role="listbox"` couvrait en outre la boîte entière, **input combobox compris**, ce qu'un listbox n'a pas le droit de contenir : il n'enveloppe plus que les tags-options (wrapper `display: contents`, mise en page inchangée), comme cela avait déjà été fait pour `ui-input-tags`. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html b/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html index 2a5781b..086637b 100644 --- a/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html +++ b/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html @@ -153,14 +153,6 @@ >
@if (multiple()) { @@ -169,6 +161,16 @@ } } @else { + +
@for (tag of visibleTagRows(); track $index) { } +
} @if (overflowCount() > 0) { diff --git a/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.scss b/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.scss index a8284c4..735a2ec 100644 --- a/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.scss +++ b/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.scss @@ -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; diff --git a/projects/ui-kit/forms/ui-input-tags/src/lib/ui-input-tags.html b/projects/ui-kit/forms/ui-input-tags/src/lib/ui-input-tags.html index 6a4bc27..b5b410a 100644 --- a/projects/ui-kit/forms/ui-input-tags/src/lib/ui-input-tags.html +++ b/projects/ui-kit/forms/ui-input-tags/src/lib/ui-input-tags.html @@ -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) { From e52e8ea83f32bc61630dc8fdfff0c5b2396e42ed Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:49:59 +0200 Subject: [PATCH 04/24] FSHSP-106 fix(datepicker): give the month/year grids their ARIA rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pickers rendered `role="gridcell"` buttons straight inside `role="grid"`, with no `role="row"` in between — required by the grid pattern, and present on the day grid. Screen readers got no row structure to walk in the month and year views, and axe flagged the whole grid as invalid. Chunk the cells into the rows the CSS actually renders (3 columns for months, 2 for years) and mark the wrappers `display: contents`, so the rendered grid is byte-for-byte the same. Clears 2 aria-required-parent (22 nodes) and 2 aria-required-children. --- CHANGELOG.md | 1 + .../ui-datepicker/src/lib/ui-datepicker.html | 64 +++++++++++-------- .../ui-datepicker/src/lib/ui-datepicker.scss | 3 + .../ui-datepicker/src/lib/ui-datepicker.ts | 15 +++++ 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d5312..1f102c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-input` annonce enfin sa popup aux lecteurs d'écran** (FSHSP-106). Le composant transmettait bien `aria-expanded` à l'``, mais cet attribut est interdit sur le rôle implicite `textbox` : l'information était donc silencieusement ignorée, et un non-voyant n'apprenait jamais qu'un calendrier pouvait s'ouvrir sur un `ui-datepicker`. L'input est désormais promu en `role="combobox"` — le rôle ARIA 1.2 qui accepte `aria-expanded` — **uniquement quand `ariaExpanded` est fourni**, donc l'annonce des champs texte ordinaires est inchangée. Seul `ui-datepicker` utilise ce jeu d'attributs dans le kit. Corrige 25 des violations `aria-allowed-attr` remontées par le job `a11y`. - **`ui-input-number` annonçait des bornes invalides** (FSHSP-106). `min`/`max` non renseignés passent par `numberAttribute(undefined)`, qui rend `NaN` : le champ exposait `aria-valuemin="NaN"`/`aria-valuemax="NaN"`, une valeur ARIA invalide que les lecteurs d'écran ignorent au mieux. Les deux attributs ne sont désormais émis que si la borne est un nombre fini — sans borne, pas d'attribut, plutôt qu'une borne qui n'existe pas. - **Le listbox des tags de `ui-input-tags` et `ui-autocomplete` était sans nom accessible** (FSHSP-106). Le nom n'était posé qu'à défaut de `label` : dès qu'un libellé visible existait, il partait sur l'`` et la liste de tags n'annonçait plus rien. Le `label` sert maintenant de repli au nom du listbox. Dans `ui-autocomplete`, le `role="listbox"` couvrait en outre la boîte entière, **input combobox compris**, ce qu'un listbox n'a pas le droit de contenir : il n'enveloppe plus que les tags-options (wrapper `display: contents`, mise en page inchangée), comme cela avait déjà été fait pour `ui-input-tags`. +- **Les grilles mois et année de `ui-datepicker` étaient une structure ARIA invalide** (FSHSP-106). Leurs cellules `role="gridcell"` n'étaient enfermées dans aucun `role="row"` — obligatoire dans un `role="grid"` — là où la grille des jours, elle, l'avait. Le lecteur d'écran n'avait donc aucune structure de lignes à parcourir dans les vues mois/année. Les cellules sont désormais découpées en lignes correspondant aux colonnes réellement rendues (3 pour les mois, 2 pour les années), les wrappers étant en `display: contents` pour que la grille CSS reste identique. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html index e4e4259..6389d4f 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.html @@ -49,20 +49,24 @@ @if (currentView() === 'year') {
- @for (y of years(); track y.year) { - + @for (row of yearRows(); track $index) { +
+ @for (y of row; track y.year) { + + } +
}
} @@ -71,20 +75,24 @@ @else if (currentView() === 'month') {
- @for (m of months(); track m.index) { - + @for (row of monthRows(); track $index) { +
+ @for (m of row; track m.index) { + + } +
}
} diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss index 19e12e7..0bbd8a9 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss @@ -187,6 +187,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); } } diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts index dbd4e3b..8ccffbd 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.ts @@ -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(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. * @@ -797,6 +804,14 @@ export class UiDatepicker extends BaseFormField { }); }); + /** @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(() => chunk(this.months(), 3)); + /** @ignore Year-picker rows — 2 columns, see `.ui-datepicker-picker._year`. */ + protected readonly yearRows = computed(() => 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(); From 8e60acb2f90faa4873758d459bff62b364816b47 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:50:14 +0200 Subject: [PATCH 05/24] FSHSP-106 fix(tag,badge,chip): make the aria-label on the root legal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aria-label is prohibited on a role-less : the name the consumer passed was simply dropped, so these components had no accessible name at all. Take role="img" when an aria-label is provided — and role="group" for a removable chip, whose × button must not end up inside an img, which hides its children from assistive tech. No label, no role: unlabelled usage is unchanged. Clears the 4 aria-prohibited-attr violations (ui-avatar's badge included, it is a ui-badge instance). --- CHANGELOG.md | 1 + projects/ui-kit/informative/ui-badge/src/lib/ui-badge.html | 2 +- projects/ui-kit/informative/ui-chip/src/lib/ui-chip.html | 1 + projects/ui-kit/informative/ui-tag/src/lib/ui-tag.html | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f102c3..dfc91bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-input-number` annonçait des bornes invalides** (FSHSP-106). `min`/`max` non renseignés passent par `numberAttribute(undefined)`, qui rend `NaN` : le champ exposait `aria-valuemin="NaN"`/`aria-valuemax="NaN"`, une valeur ARIA invalide que les lecteurs d'écran ignorent au mieux. Les deux attributs ne sont désormais émis que si la borne est un nombre fini — sans borne, pas d'attribut, plutôt qu'une borne qui n'existe pas. - **Le listbox des tags de `ui-input-tags` et `ui-autocomplete` était sans nom accessible** (FSHSP-106). Le nom n'était posé qu'à défaut de `label` : dès qu'un libellé visible existait, il partait sur l'`` et la liste de tags n'annonçait plus rien. Le `label` sert maintenant de repli au nom du listbox. Dans `ui-autocomplete`, le `role="listbox"` couvrait en outre la boîte entière, **input combobox compris**, ce qu'un listbox n'a pas le droit de contenir : il n'enveloppe plus que les tags-options (wrapper `display: contents`, mise en page inchangée), comme cela avait déjà été fait pour `ui-input-tags`. - **Les grilles mois et année de `ui-datepicker` étaient une structure ARIA invalide** (FSHSP-106). Leurs cellules `role="gridcell"` n'étaient enfermées dans aucun `role="row"` — obligatoire dans un `role="grid"` — là où la grille des jours, elle, l'avait. Le lecteur d'écran n'avait donc aucune structure de lignes à parcourir dans les vues mois/année. Les cellules sont désormais découpées en lignes correspondant aux colonnes réellement rendues (3 pour les mois, 2 pour les années), les wrappers étant en `display: contents` pour que la grille CSS reste identique. +- **`ui-tag`, `ui-badge` et `ui-chip` posaient un `aria-label` sur un `` sans rôle** (FSHSP-106), ce qui est interdit : l'attribut est purement et simplement ignoré, donc le nom fourni par le consommateur n'existait pas pour un lecteur d'écran. Ces racines prennent maintenant `role="img"` quand un `aria-label` est fourni — et `role="group"` pour un chip retirable, dont le bouton × ne doit pas se retrouver enfermé dans un `img` (qui masque ses enfants). Sans `aria-label`, aucun rôle n'est ajouté : rien ne change. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/informative/ui-badge/src/lib/ui-badge.html b/projects/ui-kit/informative/ui-badge/src/lib/ui-badge.html index bb88f9b..fd0a14a 100644 --- a/projects/ui-kit/informative/ui-badge/src/lib/ui-badge.html +++ b/projects/ui-kit/informative/ui-badge/src/lib/ui-badge.html @@ -1,4 +1,4 @@ - + @if (icon(); as name) { } diff --git a/projects/ui-kit/informative/ui-chip/src/lib/ui-chip.html b/projects/ui-kit/informative/ui-chip/src/lib/ui-chip.html index 1216d59..6434ceb 100644 --- a/projects/ui-kit/informative/ui-chip/src/lib/ui-chip.html +++ b/projects/ui-kit/informative/ui-chip/src/lib/ui-chip.html @@ -13,6 +13,7 @@ } @else { diff --git a/projects/ui-kit/informative/ui-tag/src/lib/ui-tag.html b/projects/ui-kit/informative/ui-tag/src/lib/ui-tag.html index ec5b53e..9241375 100644 --- a/projects/ui-kit/informative/ui-tag/src/lib/ui-tag.html +++ b/projects/ui-kit/informative/ui-tag/src/lib/ui-tag.html @@ -1,4 +1,4 @@ - + @if (iconLeft(); as name) { } From 78dc53047e6558c6379cae6cb634d838bb43d87c Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:50:28 +0200 Subject: [PATCH 06/24] FSHSP-106 fix(table,modal): make the scrollable regions keyboard-reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scrolling region has to be focusable, or its overflowing content can only be reached with a mouse. ui-table: the viewport takes tabindex=0 whenever `scrollable` is on. ui-modal: the body region is always `overflow-y: auto`, but an unconditional tab stop inserts itself into the focus order of every dialog — a focus-trap unit test caught exactly that. It therefore becomes focusable only once it actually overflows AND holds nothing focusable itself; since the content is projected, neither is derivable from the inputs, so both are measured (ResizeObserver). Clears the 6 scrollable-region-focusable violations. --- CHANGELOG.md | 1 + .../layout/ui-modal/src/lib/ui-modal.html | 7 +++- .../layout/ui-modal/src/lib/ui-modal.ts | 41 +++++++++++++++++++ .../table/ui-table/src/lib/ui-table.html | 1 + 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc91bf..61e34a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **Le listbox des tags de `ui-input-tags` et `ui-autocomplete` était sans nom accessible** (FSHSP-106). Le nom n'était posé qu'à défaut de `label` : dès qu'un libellé visible existait, il partait sur l'`` et la liste de tags n'annonçait plus rien. Le `label` sert maintenant de repli au nom du listbox. Dans `ui-autocomplete`, le `role="listbox"` couvrait en outre la boîte entière, **input combobox compris**, ce qu'un listbox n'a pas le droit de contenir : il n'enveloppe plus que les tags-options (wrapper `display: contents`, mise en page inchangée), comme cela avait déjà été fait pour `ui-input-tags`. - **Les grilles mois et année de `ui-datepicker` étaient une structure ARIA invalide** (FSHSP-106). Leurs cellules `role="gridcell"` n'étaient enfermées dans aucun `role="row"` — obligatoire dans un `role="grid"` — là où la grille des jours, elle, l'avait. Le lecteur d'écran n'avait donc aucune structure de lignes à parcourir dans les vues mois/année. Les cellules sont désormais découpées en lignes correspondant aux colonnes réellement rendues (3 pour les mois, 2 pour les années), les wrappers étant en `display: contents` pour que la grille CSS reste identique. - **`ui-tag`, `ui-badge` et `ui-chip` posaient un `aria-label` sur un `` sans rôle** (FSHSP-106), ce qui est interdit : l'attribut est purement et simplement ignoré, donc le nom fourni par le consommateur n'existait pas pour un lecteur d'écran. Ces racines prennent maintenant `role="img"` quand un `aria-label` est fourni — et `role="group"` pour un chip retirable, dont le bouton × ne doit pas se retrouver enfermé dans un `img` (qui masque ses enfants). Sans `aria-label`, aucun rôle n'est ajouté : rien ne change. +- **Les zones scrollables de `ui-table` et `ui-modal` étaient inatteignables au clavier** (FSHSP-106). Une région qui défile doit pouvoir recevoir le focus, sinon son contenu débordant n'est accessible qu'à la souris. Le viewport de `ui-table` devient focusable dès que `scrollable` est actif. Pour `ui-modal`, la zone de corps ne le devient que si elle **déborde réellement et ne contient rien de focusable** (mesure au `ResizeObserver`, le contenu étant projeté) : un arrêt de tabulation inconditionnel se serait inséré dans l'ordre de focus de toutes les modales, ce qu'un test unitaire du focus-trap a d'ailleurs attrapé. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.html b/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.html index cbf4d64..c507722 100644 --- a/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.html +++ b/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.html @@ -92,8 +92,11 @@
} - -
+ +
diff --git a/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.ts b/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.ts index 31f5b64..80b7f33 100644 --- a/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.ts +++ b/projects/ui-kit/layout/ui-modal/src/lib/ui-modal.ts @@ -206,6 +206,12 @@ export class UiModal { /** Emitted when a resize gesture ends. */ resizeEnd = output<{ width: number; height: number }>(); + /** @ignore Scrollable body region (see `contentNeedsFocus`). */ + private readonly contentRef = viewChild>('contentEl'); + /** @ignore The body overflows and holds no focusable element of its own — it then needs to be + * focusable itself so the scroll is keyboard-reachable (axe `scrollable-region-focusable`). */ + protected readonly contentNeedsFocus = signal(false); + /** @ignore */ private readonly dialogRef = viewChild>('dialog'); /** @ignore Scrim host (for imperative `maskStyleClass` — a bound `[class]` would @@ -339,6 +345,27 @@ export class UiModal { }); }); + // Watch the body region: it only earns a tab stop once it actually overflows and holds no + // focusable element of its own. Measured rather than derived: the content is projected, so + // neither its height nor what it contains is known from the inputs. + if (this.isBrowser && typeof ResizeObserver !== 'undefined') { + let observed: HTMLElement | null = null; + const ro = new ResizeObserver(() => this.measureContentFocusability()); + effect(() => { + const el = this.contentRef()?.nativeElement ?? null; + if (el === observed) return; + if (observed) ro.unobserve(observed); + observed = el; + if (el) { + ro.observe(el); + this.measureContentFocusability(); + } else { + this.contentNeedsFocus.set(false); + } + }); + destroyRef.onDestroy(() => ro.disconnect()); + } + // Rebuild the responsive width stylesheet when `breakpoints` changes. effect(() => { const bps = this.breakpoints(); @@ -547,6 +574,20 @@ export class UiModal { // --- Internals ------------------------------------------------------- + /** @ignore Recomputes `contentNeedsFocus` from the rendered body. */ + private measureContentFocusability(): void { + const el = this.contentRef()?.nativeElement; + if (!el) { + this.contentNeedsFocus.set(false); + return; + } + const overflows = el.scrollHeight > el.clientHeight + 1; + const hasFocusable = !!el.querySelector( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + this.contentNeedsFocus.set(overflows && !hasFocusable); + } + /** @ignore Release this instance's scroll lock, if held. */ private releaseLock(): void { if (this.locked) { diff --git a/projects/ui-kit/table/ui-table/src/lib/ui-table.html b/projects/ui-kit/table/ui-table/src/lib/ui-table.html index 564fb4d..0438e22 100644 --- a/projects/ui-kit/table/ui-table/src/lib/ui-table.html +++ b/projects/ui-kit/table/ui-table/src/lib/ui-table.html @@ -9,6 +9,7 @@
Date: Mon, 24 Aug 2026 20:50:43 +0200 Subject: [PATCH 07/24] FSHSP-106 fix(button): stop dropping projected content next to an icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `hasProjectedContent` is detected from the `.ui-button-content` element, but that element was only rendered `@if (label() || !isIconOnly())` — and `isIconOnly()` derives from that very detection. A ui-button given an `icon` and nothing but projected content therefore never rendered the content, stayed in icon-only mode, and ended up with no accessible name at all. The detection could never succeed: the element it inspects did not exist. Always render the wrapper. The SCSS already carried `.ui-button-content:empty { display: none; }` ("empty → removed, avoids phantom gap") precisely for this, so a genuine icon-only button is unchanged. Clears the button-name violation on the Projected Content story — which was not a story oversight but this bug, rendering an empty button. --- CHANGELOG.md | 1 + .../actions/ui-button/src/lib/ui-button.html | 17 +++++++++-------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e34a3..0ac9da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **Les grilles mois et année de `ui-datepicker` étaient une structure ARIA invalide** (FSHSP-106). Leurs cellules `role="gridcell"` n'étaient enfermées dans aucun `role="row"` — obligatoire dans un `role="grid"` — là où la grille des jours, elle, l'avait. Le lecteur d'écran n'avait donc aucune structure de lignes à parcourir dans les vues mois/année. Les cellules sont désormais découpées en lignes correspondant aux colonnes réellement rendues (3 pour les mois, 2 pour les années), les wrappers étant en `display: contents` pour que la grille CSS reste identique. - **`ui-tag`, `ui-badge` et `ui-chip` posaient un `aria-label` sur un `` sans rôle** (FSHSP-106), ce qui est interdit : l'attribut est purement et simplement ignoré, donc le nom fourni par le consommateur n'existait pas pour un lecteur d'écran. Ces racines prennent maintenant `role="img"` quand un `aria-label` est fourni — et `role="group"` pour un chip retirable, dont le bouton × ne doit pas se retrouver enfermé dans un `img` (qui masque ses enfants). Sans `aria-label`, aucun rôle n'est ajouté : rien ne change. - **Les zones scrollables de `ui-table` et `ui-modal` étaient inatteignables au clavier** (FSHSP-106). Une région qui défile doit pouvoir recevoir le focus, sinon son contenu débordant n'est accessible qu'à la souris. Le viewport de `ui-table` devient focusable dès que `scrollable` est actif. Pour `ui-modal`, la zone de corps ne le devient que si elle **déborde réellement et ne contient rien de focusable** (mesure au `ResizeObserver`, le contenu étant projeté) : un arrêt de tabulation inconditionnel se serait inséré dans l'ordre de focus de toutes les modales, ce qu'un test unitaire du focus-trap a d'ailleurs attrapé. +- **`ui-button` avalait son contenu projeté quand il portait une icône sans `label`** (FSHSP-106). La détection du contenu (`hasProjectedContent`) se fait depuis l'élément `.ui-button-content`, dont le rendu était lui-même conditionné à `isIconOnly()` — qui dérive de cette détection. Un `ui-button` avec `icon` et du seul contenu projeté ne rendait donc **jamais** ce contenu, restait en mode icône seule et se retrouvait sans nom accessible. Le wrapper est maintenant toujours rendu (le `.ui-button-content:empty { display: none; }` du SCSS, écrit pour ça, le replie quand il est vide) : un bouton icône seule est visuellement inchangé. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/actions/ui-button/src/lib/ui-button.html b/projects/ui-kit/actions/ui-button/src/lib/ui-button.html index e22ffd0..4e4e2a7 100644 --- a/projects/ui-kit/actions/ui-button/src/lib/ui-button.html +++ b/projects/ui-kit/actions/ui-button/src/lib/ui-button.html @@ -56,14 +56,15 @@ } - @if (label() || !isIconOnly()) { - - @if (label() && !isIconOnly()) { - {{ label() }} - } - - - } + + + @if (label() && !isIconOnly()) { + {{ label() }} + } + + @if (!iconBefore()) { From c025d86d7e7ae9e82e542b6e1a2350ec6a3f4a38 Mon Sep 17 00:00:00 2001 From: LBU Date: Mon, 24 Aug 2026 20:50:57 +0200 Subject: [PATCH 08/24] FSHSP-106 feat(styles): add an .sr-only helper, and name the empty table headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit axe's empty-table-header wants text that is actually present: an aria-label on the does not satisfy it (measured — the violation survived that attempt). The kit had no screen-reader-only utility, while four components hand-roll the same block in their own SCSS (ui-datepicker, ui-select, ui-file-upload, ui-read-only), so add it once to the published helpers sheet. Use it to name the selection and row-expansion columns of the ui-table stories. Verified: both stories now report zero violations, and the headers still measure 56px and 68px — the absolutely-positioned span adds nothing to the column. --- CHANGELOG.md | 1 + projects/ui-kit/styles/utils/_helpers.scss | 15 +++++++++++++++ .../ui-kit/table/ui-table/ui-table.stories.ts | 4 ++-- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ac9da8..dd540fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-datepicker` annonce le format de date attendu aux lecteurs d'écran** (FSHSP-118). Le `placeholder` seul (« jj/mm/aaaa ») est un support inégal selon les lecteurs d'écran, et il disparaît dès la première frappe. Un hint dédié, dérivé du même `resolvedPlaceholder`, est maintenant chaîné sur l'`aria-describedby` du déclencheur — à côté du message d'aide/erreur, jamais à sa place. Nouvel input `formatHintLabel` pour le personnaliser (ou `''` pour le désactiver) ; sans effet quand le champ n'est pas saisissable au clavier. - `ui-input` (donc tout champ construit dessus) accepte désormais un `ariaDescribedBy` externe, chaîné de la même façon sur son `aria-describedby` natif plutôt que de l'écraser — c'est le mécanisme qui rend le point ci-dessus possible sans dupliquer la logique dans `ui-datepicker`. - **`ui-datepicker` : `allowInput` couvre maintenant `range` et `multiple`** (FSHSP-118), en complément de la grille (le clic continue de fonctionner à l'identique). `range` se tape dans le même champ, les deux dates séparées par `" - "` (ex. `"08/07/2026 - 18/07/2026"`) ; `multiple` accepte une liste séparée par `", "`, nombre de dates non borné. `range` bénéficie du même masque auto-"/" en direct qu'en mode `single` (les deux dates, puis leur séparateur, se construisent au fil de la frappe) ; `multiple`, dont le nombre de dates n'est pas borné, reste en texte libre, parsé au blur/Entrée uniquement — dans les deux cas avec les mêmes garanties qu'en `single` : une entrée incomplète ou invalide revient à la dernière valeur affichée, une plage tapée dans le désordre est réordonnée chronologiquement (comme un second clic dans la grille), une date dupliquée en `multiple` est supprimée (comme un clic sur une case déjà sélectionnée). Un `parseDate` custom s'applique par date individuelle, symétrique de `dateFormat`. Non couvert : la combinaison avec `showTime` (les dates tapées en `range`/`multiple` sont toujours calées à minuit — seule la grille gère l'heure sur ces modes pour l'instant). +- **Un utilitaire global `.sr-only`** dans la feuille de helpers publiée (section « Visibility ») : rend un texte visible des seuls lecteurs d'écran, pour nommer une colonne ou une action dont l'affichage ne montre rien. Ajouté pour les en-têtes de colonne vides des tableaux (`` d'une colonne de sélection ou d'expansion), où `aria-label` ne suffit pas : `empty-table-header` exige du texte réellement présent. Quatre composants du kit recopient déjà ce bloc dans leur propre SCSS (`ui-datepicker`, `ui-select`, `ui-file-upload`, `ui-read-only`) — ils pourront s'appuyer dessus. ### Changed diff --git a/projects/ui-kit/styles/utils/_helpers.scss b/projects/ui-kit/styles/utils/_helpers.scss index 3f4ad22..6fed810 100644 --- a/projects/ui-kit/styles/utils/_helpers.scss +++ b/projects/ui-kit/styles/utils/_helpers.scss @@ -105,6 +105,21 @@ overflow-x: auto; } +/* Visible to assistive tech only: keeps a name on a control whose column/button shows + nothing (an empty ``, an icon-only affordance). `display: none` would hide it from + screen readers too, which is the opposite of the intent. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + border: 0; +} + .visibility-hidden { visibility: hidden; } diff --git a/projects/ui-kit/table/ui-table/ui-table.stories.ts b/projects/ui-kit/table/ui-table/ui-table.stories.ts index 3384a4a..8752db5 100644 --- a/projects/ui-kit/table/ui-table/ui-table.stories.ts +++ b/projects/ui-kit/table/ui-table/ui-table.stories.ts @@ -502,7 +502,7 @@ export const SelectionRadio: Story = { - + Sélection Code Nom Catégorie @@ -804,7 +804,7 @@ export const RowExpansion: Story = { - + Détail de la ligne Code Nom Catégorie From 8f24865a67f686ee26c33badcd843c5233aa9a75 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 09:13:12 +0200 Subject: [PATCH 09/24] FSHSP-106 fix(tabs,stepper): omit aria-controls when the panel is not mounted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-tabs used as a navigation menu (a router-outlet renders the content, see the TabMenu story) and ui-stepper used as a plain "steps only" progress indicator (both explicitly documented usages) still had every tab/step point aria-controls at an id that resolves to nothing — an invalid ARIA value. Each container now tracks the panels actually mounted via contentChildren; panelId() returns null when a step/tab has no matching panel, so the attribute is omitted rather than dangled. Clears 2 of the 3 remaining aria-valid-attr-value violations. Verified: both stories now report zero violations for this rule (ui-tabs/Tab Menu's one remaining violation is color-contrast, unrelated). ui-stepper/Vertical's aria-required-children (tablist containing its own tabpanel) is untouched — needs a design decision (tabs vs. accordion in vertical mode) before a fix, tracked separately on FSHSP-106. --- CHANGELOG.md | 1 + .../navigation/ui-stepper/src/lib/ui-stepper.ts | 14 +++++++++++--- .../ui-kit/navigation/ui-tabs/src/lib/ui-tabs.ts | 14 +++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd540fc..751b386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-tag`, `ui-badge` et `ui-chip` posaient un `aria-label` sur un `` sans rôle** (FSHSP-106), ce qui est interdit : l'attribut est purement et simplement ignoré, donc le nom fourni par le consommateur n'existait pas pour un lecteur d'écran. Ces racines prennent maintenant `role="img"` quand un `aria-label` est fourni — et `role="group"` pour un chip retirable, dont le bouton × ne doit pas se retrouver enfermé dans un `img` (qui masque ses enfants). Sans `aria-label`, aucun rôle n'est ajouté : rien ne change. - **Les zones scrollables de `ui-table` et `ui-modal` étaient inatteignables au clavier** (FSHSP-106). Une région qui défile doit pouvoir recevoir le focus, sinon son contenu débordant n'est accessible qu'à la souris. Le viewport de `ui-table` devient focusable dès que `scrollable` est actif. Pour `ui-modal`, la zone de corps ne le devient que si elle **déborde réellement et ne contient rien de focusable** (mesure au `ResizeObserver`, le contenu étant projeté) : un arrêt de tabulation inconditionnel se serait inséré dans l'ordre de focus de toutes les modales, ce qu'un test unitaire du focus-trap a d'ailleurs attrapé. - **`ui-button` avalait son contenu projeté quand il portait une icône sans `label`** (FSHSP-106). La détection du contenu (`hasProjectedContent`) se fait depuis l'élément `.ui-button-content`, dont le rendu était lui-même conditionné à `isIconOnly()` — qui dérive de cette détection. Un `ui-button` avec `icon` et du seul contenu projeté ne rendait donc **jamais** ce contenu, restait en mode icône seule et se retrouvait sans nom accessible. Le wrapper est maintenant toujours rendu (le `.ui-button-content:empty { display: none; }` du SCSS, écrit pour ça, le replie quand il est vide) : un bouton icône seule est visuellement inchangé. +- **`ui-tabs` et `ui-stepper` pointaient un `aria-controls` vers un panneau qui n'existe pas** (FSHSP-106) quand ils sont utilisés sans panneaux — un menu de navigation adossé à un `router-outlet` (`ui-tabs`), ou un simple indicateur de progression (`ui-stepper`), les deux usages étant explicitement documentés. L'attribut référençait un id qui ne correspond à rien dans le DOM, une valeur ARIA invalide. Chaque conteneur tient maintenant le registre des panneaux réellement montés ; l'onglet/étape omet `aria-controls` plutôt que de le pointer dans le vide quand son panneau n'existe pas. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/navigation/ui-stepper/src/lib/ui-stepper.ts b/projects/ui-kit/navigation/ui-stepper/src/lib/ui-stepper.ts index 1838aa8..798ae82 100644 --- a/projects/ui-kit/navigation/ui-stepper/src/lib/ui-stepper.ts +++ b/projects/ui-kit/navigation/ui-stepper/src/lib/ui-stepper.ts @@ -347,6 +347,13 @@ export class UiStepper { forwardRef(() => UiStep), { descendants: true }, ); + /** @ignore All panels projected under this container — a stepper used as a plain "steps only" + * progress indicator (see `UiStepList` doc) has none at all: `aria-controls` must not point + * at an id that resolves to nothing. */ + private readonly panels = contentChildren( + forwardRef(() => UiStepPanel), + { descendants: true }, + ); /** @ignore Ordered step values (the progression sequence). */ private readonly orderedValues = computed(() => this.steps().map((s) => s.value())); @@ -358,9 +365,10 @@ export class UiStepper { stepId(value: UiStepValue | undefined): string { return `${this.id}-step-${value}`; } - /** @ignore id of the panel for a given value. */ - panelId(value: UiStepValue | undefined): string { - return `${this.id}-panel-${value}`; + /** @ignore id of the panel for a given value — `null` when that panel is not actually + * rendered (see `panels` above), so the step omits `aria-controls` rather than dangle it. */ + panelId(value: UiStepValue | undefined): string | null { + return this.panels().some((p) => p.value() === value) ? `${this.id}-panel-${value}` : null; } /** @ignore Whether the given value is the active step. */ diff --git a/projects/ui-kit/navigation/ui-tabs/src/lib/ui-tabs.ts b/projects/ui-kit/navigation/ui-tabs/src/lib/ui-tabs.ts index 83b4713..dab3dd6 100644 --- a/projects/ui-kit/navigation/ui-tabs/src/lib/ui-tabs.ts +++ b/projects/ui-kit/navigation/ui-tabs/src/lib/ui-tabs.ts @@ -502,14 +502,22 @@ export class UiTabs { forwardRef(() => UiTab), { descendants: true }, ); + /** @ignore All panels projected under this container — some consumers use `ui-tabs` as a + * navigation menu with no panel at all (a router outlet renders the content instead, see + * the `TabMenu` story). `aria-controls` must not point at an id that resolves to nothing. */ + private readonly panels = contentChildren( + forwardRef(() => UiTabPanel), + { descendants: true }, + ); /** @ignore id of the tab button for a given value. */ tabId(value: UiTabValue): string { return `${this.id}-tab-${value}`; } - /** @ignore id of the panel for a given value. */ - panelId(value: UiTabValue): string { - return `${this.id}-panel-${value}`; + /** @ignore id of the panel for a given value — `null` when that panel is not actually + * rendered (see `panels` above), so the tab omits `aria-controls` rather than dangle it. */ + panelId(value: UiTabValue): string | null { + return this.panels().some((p) => p.value() === value) ? `${this.id}-panel-${value}` : null; } /** @ignore Whether the given value is the active tab. */ From 636341d95f6950d675112ebc60d725d363bb68a2 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 10:18:26 +0200 Subject: [PATCH 10/24] FSHSP-106 fix(stepper): switch vertical mode to the accordion ARIA pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vertical interleaves each step's panel right under its own header inside the same ui-step-item (see the Vertical story), so the container's role="tablist" always contained a role="tabpanel" descendant — a tablist containing its own tabpanel is invalid ARIA. No DOM restructuring: vertical switches its role vocabulary to the accordion pattern instead (matching ui-accordion-panel's own role+aria-labelledby pairing) rather than moving panels out of the tablist. - ui-stepper host: role="group" instead of "tablist" in vertical (keeps aria-label legal — a plain div with no role could not carry one, the same aria-prohibited-attr fixed earlier on ui-tag/ui-badge/ui-chip). - ui-step header: no role + aria-expanded instead of role="tab" + aria-selected, in vertical only. - ui-step-panel: role="region" instead of "tabpanel", in vertical only. Horizontal (ui-step-list, a real tablist with no panel inside it) is untouched. No CSS rule keys off aria-selected, so nothing visual moves. Clears the last aria-required-children violation. Verified across Vertical, StepsOnly, Linear, Horizontal and Basic: zero violations on all five. --- CHANGELOG.md | 1 + .../navigation/ui-stepper/src/lib/ui-step.html | 10 ++++++++-- .../navigation/ui-stepper/src/lib/ui-stepper.ts | 13 ++++++++++--- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 751b386..855db9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **Les zones scrollables de `ui-table` et `ui-modal` étaient inatteignables au clavier** (FSHSP-106). Une région qui défile doit pouvoir recevoir le focus, sinon son contenu débordant n'est accessible qu'à la souris. Le viewport de `ui-table` devient focusable dès que `scrollable` est actif. Pour `ui-modal`, la zone de corps ne le devient que si elle **déborde réellement et ne contient rien de focusable** (mesure au `ResizeObserver`, le contenu étant projeté) : un arrêt de tabulation inconditionnel se serait inséré dans l'ordre de focus de toutes les modales, ce qu'un test unitaire du focus-trap a d'ailleurs attrapé. - **`ui-button` avalait son contenu projeté quand il portait une icône sans `label`** (FSHSP-106). La détection du contenu (`hasProjectedContent`) se fait depuis l'élément `.ui-button-content`, dont le rendu était lui-même conditionné à `isIconOnly()` — qui dérive de cette détection. Un `ui-button` avec `icon` et du seul contenu projeté ne rendait donc **jamais** ce contenu, restait en mode icône seule et se retrouvait sans nom accessible. Le wrapper est maintenant toujours rendu (le `.ui-button-content:empty { display: none; }` du SCSS, écrit pour ça, le replie quand il est vide) : un bouton icône seule est visuellement inchangé. - **`ui-tabs` et `ui-stepper` pointaient un `aria-controls` vers un panneau qui n'existe pas** (FSHSP-106) quand ils sont utilisés sans panneaux — un menu de navigation adossé à un `router-outlet` (`ui-tabs`), ou un simple indicateur de progression (`ui-stepper`), les deux usages étant explicitement documentés. L'attribut référençait un id qui ne correspond à rien dans le DOM, une valeur ARIA invalide. Chaque conteneur tient maintenant le registre des panneaux réellement montés ; l'onglet/étape omet `aria-controls` plutôt que de le pointer dans le vide quand son panneau n'existe pas. +- **`ui-stepper` en mode vertical était une structure ARIA invalide** (FSHSP-106) : le conteneur `role="tablist"` contenait directement des `role="tabpanel"`, chaque panneau étant rendu juste sous son étape dans le même `ui-step-item` — un tablist qui contient son propre tabpanel est interdit. Le mode vertical bascule sur le pattern accordéon (`aria-expanded` sur l'en-tête à la place de `role="tab"`/`aria-selected`, `role="region"` sur le panneau à la place de `role="tabpanel"` — la même paire que `ui-accordion-panel`), sans déplacer un seul élément du DOM. Le mode horizontal (`ui-step-list`, un vrai tablist sans panneau à l'intérieur) est inchangé. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/navigation/ui-stepper/src/lib/ui-step.html b/projects/ui-kit/navigation/ui-stepper/src/lib/ui-step.html index d6ed2e7..20e9d53 100644 --- a/projects/ui-kit/navigation/ui-stepper/src/lib/ui-step.html +++ b/projects/ui-kit/navigation/ui-stepper/src/lib/ui-step.html @@ -1,11 +1,17 @@ +
`, }), - 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 diff --git a/projects/ui-kit/forms/ui-segment-control/ui-segment-control.stories.ts b/projects/ui-kit/forms/ui-segment-control/ui-segment-control.stories.ts index 7981c5f..efbc14d 100644 --- a/projects/ui-kit/forms/ui-segment-control/ui-segment-control.stories.ts +++ b/projects/ui-kit/forms/ui-segment-control/ui-segment-control.stories.ts @@ -207,7 +207,7 @@ export const Template: Story = { {{ plan.label }} - {{ plan.hint }} + {{ plan.hint }} model = {{ model }} From b1e132b27529750440e9b2f70473d7cca85b98c3 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 13:12:17 +0200 Subject: [PATCH 19/24] FSHSP-106 fix(breadcrumb): state that a disabled crumb is disabled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A disabled crumb was marked only by a CSS class, so assistive tech had no way to know — and its faded label (slate-300 at 2.44:1) was measured for contrast precisely because nothing said it was inactive, which WCAG 1.4.3 exempts. Add role="link" + aria-disabled="true" when item.disabled: the pattern for a link that would exist were it enabled. A disabled item always reaches this branch — every isXxxCrumb() guard requires !item.disabled — so the plain-text span is the right place. The role is not decoration: aria-disabled is not a global attribute and a role-less span rejects it, which would have traded one violation for an aria-prohibited-attr — the same defect fixed earlier on ui-tag/ui-badge/ui-chip. Verified no such violation appeared. Fixes the accessibility gap and clears the contrast finding at once, with no colour change and no token decision. Verified on Links, Basic and Custom Item: zero violations; the non-disabled current crumb keeps no role and no aria-disabled. --- CHANGELOG.md | 1 + .../navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2021d4c..4fb8857 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -77,6 +77,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **La surface `success` passe le seuil AA** (FSHSP-106) : `actions.success.surface.default` et `informative.successHigh.surface.default` pointent `green-650` au lieu de `green-500` — 2,54 → **4,76:1** sous le texte blanc. Le vert est visiblement plus sombre : c'est inhérent à WCAG 2 sur les verts saturés, aucun vert clair ne passe avec du texte blanc (le `600` n'y suffit pas non plus). Arbitrage designer : garder le texte blanc et assombrir la surface, plutôt que passer le texte en foncé. Mode clair uniquement. - **La story `ui-button/On Color Light` reposait sur un fond qui n'est plus clair** (FSHSP-106). Elle utilisait `actions.warning.surface.default` comme bandeau clair pour démontrer `onColor="light"` (texte foncé sur fond clair) ; l'assombrissement de ce token en `orange-700` a invalidé la prémisse — le texte foncé y tombait à 3,42:1. Le bandeau utilise désormais `informative.warningLow.surface.default` (`orange-50`), un fond réellement clair. Documentation seule, aucun composant touché. - **Deux stories mettaient le check de contraste en échec sans qu'un composant soit en cause** (FSHSP-106). `ui-segment-control/Template` appliquait un `opacity: .75` en dur sur le libellé secondaire de son template de démonstration, ce qui délavait le texte sous le seuil AA — retiré, la hiérarchie visuelle tenant déjà par la taille et la graisse. `ui-button/On Color Omitted` existe au contraire **pour montrer** un contraste insuffisant (c'est le problème que `onColor` résout) : la règle y est levée avec sa justification, plutôt que de supprimer la démonstration. +- **Un élément de `ui-breadcrumb` désactivé ne l'annonçait pas** (FSHSP-106) : seule une classe CSS le distinguait, une technologie d'assistance n'avait aucun moyen de le savoir. Il porte désormais `role="link"` + `aria-disabled="true"` — le motif d'un lien qui existerait s'il était actif. Le rôle est nécessaire pour que l'attribut soit légal (`aria-disabled` n'est pas global, un `` sans rôle le refuse). Effet de bord bienvenu : l'exemption de WCAG 1.4.3 pour les composants inactifs s'applique alors au contraste de son libellé atténué, que le check mesurait faute de savoir qu'il était désactivé. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html b/projects/ui-kit/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html index 349cc8c..b7b7e59 100644 --- a/projects/ui-kit/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html +++ b/projects/ui-kit/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html @@ -67,10 +67,19 @@ /> } @else { + From 51bdf9e8c6959f8d805937b90722dd4870805a66 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 14:25:27 +0200 Subject: [PATCH 20/24] FSHSP-106 fix(datepicker): take the adjacent-month days to AA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Those days are clickable, so their faded rendering has to clear 4.5:1 on its own — no disabled exemption applies. They read form.low.content.default (grey-500), which only passes undimmed: at 60% opacity it lands at 2.33:1. Add a component-level knob, --ui-datepicker-day-color-outside, defaulting to global.text.default, and keep the 60% opacity untouched: 4.68:1, with the days still visibly paler than the current month's. Measured across all three candidates on the real component before picking this one — it is the only option that preserves the existing dimming instead of trading it for contrast. Deliberately not the shared token: darkening form.low.content.default would have dragged 12 other consumers with it (ui-select/ui-textarea/ui-autocomplete placeholders, ui-rating's empty stars, ui-toggle, ui-helper, ui-field's float label), all of which render it undimmed and already pass. The reason is left in the SCSS so the next pass does not "fix" it back. Defaulting to a semantic token rather than a primitive also means dark mode follows on its own — a frozen grey-900 would have been unreadable there. Verified: Inline, Default, Two Months, Multiple, Range, Month Picker and Inline With Time all report zero violations. The knob carries a `///` doc comment, so it shows up in the generated Theming table. --- CHANGELOG.md | 1 + .../ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fb8857..4cc87e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -78,6 +78,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **La story `ui-button/On Color Light` reposait sur un fond qui n'est plus clair** (FSHSP-106). Elle utilisait `actions.warning.surface.default` comme bandeau clair pour démontrer `onColor="light"` (texte foncé sur fond clair) ; l'assombrissement de ce token en `orange-700` a invalidé la prémisse — le texte foncé y tombait à 3,42:1. Le bandeau utilise désormais `informative.warningLow.surface.default` (`orange-50`), un fond réellement clair. Documentation seule, aucun composant touché. - **Deux stories mettaient le check de contraste en échec sans qu'un composant soit en cause** (FSHSP-106). `ui-segment-control/Template` appliquait un `opacity: .75` en dur sur le libellé secondaire de son template de démonstration, ce qui délavait le texte sous le seuil AA — retiré, la hiérarchie visuelle tenant déjà par la taille et la graisse. `ui-button/On Color Omitted` existe au contraire **pour montrer** un contraste insuffisant (c'est le problème que `onColor` résout) : la règle y est levée avec sa justification, plutôt que de supprimer la démonstration. - **Un élément de `ui-breadcrumb` désactivé ne l'annonçait pas** (FSHSP-106) : seule une classe CSS le distinguait, une technologie d'assistance n'avait aucun moyen de le savoir. Il porte désormais `role="link"` + `aria-disabled="true"` — le motif d'un lien qui existerait s'il était actif. Le rôle est nécessaire pour que l'attribut soit légal (`aria-disabled` n'est pas global, un `` sans rôle le refuse). Effet de bord bienvenu : l'exemption de WCAG 1.4.3 pour les composants inactifs s'applique alors au contraste de son libellé atténué, que le check mesurait faute de savoir qu'il était désactivé. +- **Les jours des mois adjacents de `ui-datepicker` passent le seuil AA** (FSHSP-106). Ils sont **cliquables**, donc leur rendu atténué doit être conforme par lui-même : ils s'appuyaient sur `form.low.content.default` (`grey-500`), qui ne passe qu'à pleine opacité — atténué à 60 %, il tombait à 2,33:1. Nouveau réglage de composant **`--ui-datepicker-day-color-outside`**, par défaut `global.text.default`, l'opacité de 60 % restant inchangée : 4,68:1, et les jours adjacents restent visiblement plus pâles. Le token partagé n'est pas touché — ses 12 autres consommateurs (placeholders de `ui-select`, `ui-textarea`, `ui-autocomplete`, étoiles de `ui-rating`…) le rendent sans atténuation et sont déjà conformes. ## [0.6.1] - 2026-08-22 diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss index 0bbd8a9..695ed2d 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss @@ -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é. @@ -290,7 +291,12 @@ $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; } + // Base colour, not the placeholder token: these days are clickable, so their faded + // rendering has to clear AA on its own. `form.low.content.default` (grey-500) only + // passes at full opacity — dimmed to 60 % it lands at 2,33:1. Every other consumer of + // that token renders it undimmed, so darkening the token itself would have made 12 + // components pay for this one case (FSHSP-106). + &._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); } From 835d7360acad83c789c48efc29e60582a1c24cc0 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 14:28:40 +0200 Subject: [PATCH 21/24] FSHSP-106 chore(docs): regenerate the component-vars manifests Fallout of adding --ui-datepicker-day-color-outside in 450a1e8: both manifests are generated by `npm run docs:config` and tracked, so they carry the new knob. - component-vars.scss: the variable and its resolved default (609 -> 610). - figma/component-vars.json: same, exported towards Figma with the `///` comment as its description, its codeSyntax and its entry point. No hand edits. --- figma/component-vars.json | 26 ++++++++++++++++++++-- projects/ui-kit/styles/component-vars.scss | 3 ++- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/figma/component-vars.json b/figma/component-vars.json index aa4c5c9..e29a5d7 100644 --- a/figma/component-vars.json +++ b/figma/component-vars.json @@ -10,8 +10,8 @@ "Mode 1" ], "counts": { - "tokens": 582, - "alias": 461, + "tokens": 583, + "alias": 462, "literal": 121, "skipped": 46, "derivedDescriptions": 197 @@ -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", diff --git a/projects/ui-kit/styles/component-vars.scss b/projects/ui-kit/styles/component-vars.scss index 97584ce..2704140 100644 --- a/projects/ui-kit/styles/component-vars.scss +++ b/projects/ui-kit/styles/component-vars.scss @@ -11,7 +11,7 @@ // design token instead — it follows brand, light/dark and viewport. // // Generated by `npm run docs:config`, values read from the compiled CSS — do not -// edit here. 609 variables; 19 more exist whose value depends on the +// edit here. 610 variables; 19 more exist whose value depends on the // rendered variant, listed in each component’s “Theming” section in Storybook. // ===================================================================== @@ -177,6 +177,7 @@ --ui-datepicker-title-font-size: var(--size-typography-title-md); --ui-datepicker-weekday-font-size: var(--size-typography-text-md); // Couleurs + --ui-datepicker-day-color-outside: var(--global-text-default); --ui-datepicker-range-background: var(--informative-highlightlow-surface-hover); --ui-datepicker-range-color: var(--informative-highlightlow-content-default); // Divers From d0e22b55286ba12ae50660b31dbe7451c2548770 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 14:32:28 +0200 Subject: [PATCH 22/24] FSHSP-106 docs(figma): refresh the alias counts quoted in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new --ui-datepicker-day-color-outside knob (450a1e8) moved the manifest counts, and figma/README.md quotes them in prose: 461/582 -> 462/583. `docs:config:check` verifies those quoted numbers, not just the generated files, so CI's "Check the docs (lists, conventions, quoted counts)" step failed on the previous push — I had run the generator but not the check. --- figma/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/figma/README.md b/figma/README.md index 98b718c..21ca118 100644 --- a/figma/README.md +++ b/figma/README.md @@ -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`. From f6ab5bd7cbaebb24ed140930c7228c9b6d94b1c7 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 14:36:59 +0200 Subject: [PATCH 23/24] FSHSP-106 style(datepicker): drop the rationale comment on the other-month rule The five-line justification sat on a one-line rule and repeated what the commit message and the CHANGELOG entry already carry. --- .../ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss | 5 ----- 1 file changed, 5 deletions(-) diff --git a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss index 695ed2d..c54bea9 100644 --- a/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss +++ b/projects/ui-kit/forms/ui-datepicker/src/lib/ui-datepicker.scss @@ -291,11 +291,6 @@ $buttonbar-padding-top: var(--ui-datepicker-buttonbar-padding-top, var(--units-s } // Previous/next month + disabled - // Base colour, not the placeholder token: these days are clickable, so their faded - // rendering has to clear AA on its own. `form.low.content.default` (grey-500) only - // passes at full opacity — dimmed to 60 % it lands at 2,33:1. Every other consumer of - // that token renders it undimmed, so darkening the token itself would have made 12 - // components pay for this one case (FSHSP-106). &._other-month { color: $day-color-other-month; opacity: $day-opacity-other-month; } // Today (outlined chip) — only when not selected. From b127aebee2296a5789124434930f06fc3fc7e5b6 Mon Sep 17 00:00:00 2001 From: LBU Date: Tue, 25 Aug 2026 14:57:42 +0200 Subject: [PATCH 24/24] FSHSP-106 fix(tokens)!: shift the success ramp instead of adding green-650 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses the approach taken in e80a68e. Both reach AA; the shift wins on three counts, and I should have put it on the table at the time rather than only when asked: - no primitive to create, so nothing left to hand-build in the Figma variables - same method as warning (99f96c3), one rule to explain instead of two - wider default -> hover step (4.5 points of lightness against 3.5) default green-500 -> green-700 (2.54 -> 5.48:1), hover green-700 -> green-800 (7.68:1). focused (green-800) and pressed (green-900) untouched: pressed stays distinct, and focused is not rendered by the actions.* families at all — verified when doing warning. The cost is the one point in favour of green-650: the resting surface is darker (L 24.3% against 27.8%). Both are far from the original green-500 (39.4%) — no light green clears 4.5:1 under white text. green-650 is dropped from primitives.json, which is now identical to main again. The CHANGELOG's "Added" entry for it goes with it. Verified on the real build: ui-alert/Success, ui-button/Success and ui-tag/Success report zero violations, at rest and on hover. --- CHANGELOG.md | 3 +-- src/design-tokens/primitives.json | 15 --------------- src/design-tokens/semantics.json | 8 ++++---- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc87e1..363cc13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,6 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - `ui-input` (donc tout champ construit dessus) accepte désormais un `ariaDescribedBy` externe, chaîné de la même façon sur son `aria-describedby` natif plutôt que de l'écraser — c'est le mécanisme qui rend le point ci-dessus possible sans dupliquer la logique dans `ui-datepicker`. - **`ui-datepicker` : `allowInput` couvre maintenant `range` et `multiple`** (FSHSP-118), en complément de la grille (le clic continue de fonctionner à l'identique). `range` se tape dans le même champ, les deux dates séparées par `" - "` (ex. `"08/07/2026 - 18/07/2026"`) ; `multiple` accepte une liste séparée par `", "`, nombre de dates non borné. `range` bénéficie du même masque auto-"/" en direct qu'en mode `single` (les deux dates, puis leur séparateur, se construisent au fil de la frappe) ; `multiple`, dont le nombre de dates n'est pas borné, reste en texte libre, parsé au blur/Entrée uniquement — dans les deux cas avec les mêmes garanties qu'en `single` : une entrée incomplète ou invalide revient à la dernière valeur affichée, une plage tapée dans le désordre est réordonnée chronologiquement (comme un second clic dans la grille), une date dupliquée en `multiple` est supprimée (comme un clic sur une case déjà sélectionnée). Un `parseDate` custom s'applique par date individuelle, symétrique de `dateFormat`. Non couvert : la combinaison avec `showTime` (les dates tapées en `range`/`multiple` sont toujours calées à minuit — seule la grille gère l'heure sur ces modes pour l'instant). - **Un utilitaire global `.sr-only`** dans la feuille de helpers publiée (section « Visibility ») : rend un texte visible des seuls lecteurs d'écran, pour nommer une colonne ou une action dont l'affichage ne montre rien. Ajouté pour les en-têtes de colonne vides des tableaux (`` d'une colonne de sélection ou d'expansion), où `aria-label` ne suffit pas : `empty-table-header` exige du texte réellement présent. Quatre composants du kit recopient déjà ce bloc dans leur propre SCSS (`ui-datepicker`, `ui-select`, `ui-file-upload`, `ui-read-only`) — ils pourront s'appuyer dessus. -- **Nouvelle primitive `green-650` (`#0b835c`)**, palier intermédiaire entre le `600` et le `700`, dans les 3 modes brand. Le `600` échoue le seuil AA sous du texte blanc (3,77:1) et le `700` est déjà pris par l'état `hover` : il n'existait donc aucune teinte de vert utilisable pour rendre la surface `success` conforme. `#0b835c` est le vert **le plus clair** qui passe avec une marge réelle (4,76:1), pour limiter l'écart visuel. **À créer côté Figma** — pas encore de `variableId". ### Changed @@ -74,7 +73,7 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr - **`ui-tabs` et `ui-stepper` pointaient un `aria-controls` vers un panneau qui n'existe pas** (FSHSP-106) quand ils sont utilisés sans panneaux — un menu de navigation adossé à un `router-outlet` (`ui-tabs`), ou un simple indicateur de progression (`ui-stepper`), les deux usages étant explicitement documentés. L'attribut référençait un id qui ne correspond à rien dans le DOM, une valeur ARIA invalide. Chaque conteneur tient maintenant le registre des panneaux réellement montés ; l'onglet/étape omet `aria-controls` plutôt que de le pointer dans le vide quand son panneau n'existe pas. - **`ui-stepper` en mode vertical était une structure ARIA invalide** (FSHSP-106) : le conteneur `role="tablist"` contenait directement des `role="tabpanel"`, chaque panneau étant rendu juste sous son étape dans le même `ui-step-item` — un tablist qui contient son propre tabpanel est interdit. Le mode vertical bascule sur le pattern accordéon (`aria-expanded` sur l'en-tête à la place de `role="tab"`/`aria-selected`, `role="region"` sur le panneau à la place de `role="tabpanel"` — la même paire que `ui-accordion-panel`), sans déplacer un seul élément du DOM. Le mode horizontal (`ui-step-list`, un vrai tablist sans panneau à l'intérieur) est inchangé. - **La surface `error` passe le seuil AA** (FSHSP-106) : `actions.error.surface.default` et `informative.errorHigh.surface.default` pointent désormais `red-600` (`#dc2626`) au lieu de `red-500` (`#ef4444`) — 3,76 → **4,83:1** sous le texte blanc. Aucune couleur créée : le palier `600` existait déjà et n'était utilisé par aucun état de la chaîne (`hover` = `700`, `focused` = `800`, `pressed` = `900`), l'écart visuel est à peine perceptible. Mode clair uniquement, le seul que le job CI mesure. Les surfaces `success` et `warning` restent en échec : leur palier `600` ne suffit pas (3,77 et 3,56), elles demandent un arbitrage de palette. -- **La surface `success` passe le seuil AA** (FSHSP-106) : `actions.success.surface.default` et `informative.successHigh.surface.default` pointent `green-650` au lieu de `green-500` — 2,54 → **4,76:1** sous le texte blanc. Le vert est visiblement plus sombre : c'est inhérent à WCAG 2 sur les verts saturés, aucun vert clair ne passe avec du texte blanc (le `600` n'y suffit pas non plus). Arbitrage designer : garder le texte blanc et assombrir la surface, plutôt que passer le texte en foncé. Mode clair uniquement. +- **La rampe de surfaces `success` est décalée d'un cran** (FSHSP-106) : `default` passe de `green-500` à `green-700` (2,54 → **5,48:1** sous le texte blanc) et `hover` de `green-700` à `green-800` (7,68:1). `focused` (`green-800`) et `pressed` (`green-900`) sont inchangés. Aucune couleur créée : le `600` ne suffisait pas (3,77:1) et le décalage évite d'introduire un palier intermédiaire, en appliquant la même méthode que pour `warning`. Conséquence visible : le `success` par défaut est un vert nettement plus sombre — inhérent à WCAG 2 sur les verts saturés, aucun vert clair ne passe sous du texte blanc. Mode clair uniquement. - **La story `ui-button/On Color Light` reposait sur un fond qui n'est plus clair** (FSHSP-106). Elle utilisait `actions.warning.surface.default` comme bandeau clair pour démontrer `onColor="light"` (texte foncé sur fond clair) ; l'assombrissement de ce token en `orange-700` a invalidé la prémisse — le texte foncé y tombait à 3,42:1. Le bandeau utilise désormais `informative.warningLow.surface.default` (`orange-50`), un fond réellement clair. Documentation seule, aucun composant touché. - **Deux stories mettaient le check de contraste en échec sans qu'un composant soit en cause** (FSHSP-106). `ui-segment-control/Template` appliquait un `opacity: .75` en dur sur le libellé secondaire de son template de démonstration, ce qui délavait le texte sous le seuil AA — retiré, la hiérarchie visuelle tenant déjà par la taille et la graisse. `ui-button/On Color Omitted` existe au contraire **pour montrer** un contraste insuffisant (c'est le problème que `onColor` résout) : la règle y est levée avec sa justification, plutôt que de supprimer la démonstration. - **Un élément de `ui-breadcrumb` désactivé ne l'annonçait pas** (FSHSP-106) : seule une classe CSS le distinguait, une technologie d'assistance n'avait aucun moyen de le savoir. Il porte désormais `role="link"` + `aria-disabled="true"` — le motif d'un lien qui existerait s'il était actif. Le rôle est nécessaire pour que l'attribut soit légal (`aria-disabled` n'est pas global, un `` sans rôle le refuse). Effet de bord bienvenu : l'exemption de WCAG 1.4.3 pour les composants inactifs s'applique alors au contraste de son libellé atténué, que le check mesurait faute de savoir qu'il était désactivé. diff --git a/src/design-tokens/primitives.json b/src/design-tokens/primitives.json index 182fb2f..c80d456 100644 --- a/src/design-tokens/primitives.json +++ b/src/design-tokens/primitives.json @@ -936,11 +936,6 @@ } } }, - "650": { - "$value": "#0b835c", - "$type": "color", - "$description": "Palier intermédiaire 600/700. Le vert le plus clair conforme AA (4,76:1) sous du texte blanc : le 600 échoue (3,77) et le 700 est déjà pris par l'état hover. À créer côté Figma (pas encore de variableId)." - }, "700": { "$value": "#047857", "$type": "color", @@ -1073,11 +1068,6 @@ } } }, - "650": { - "$value": "#0b835c", - "$type": "color", - "$description": "Palier intermédiaire 600/700. Le vert le plus clair conforme AA (4,76:1) sous du texte blanc : le 600 échoue (3,77) et le 700 est déjà pris par l'état hover. À créer côté Figma (pas encore de variableId)." - }, "700": { "$value": "#047857", "$type": "color", @@ -1210,11 +1200,6 @@ } } }, - "650": { - "$value": "#0b835c", - "$type": "color", - "$description": "Palier intermédiaire 600/700. Le vert le plus clair conforme AA (4,76:1) sous du texte blanc : le 600 échoue (3,77) et le 700 est déjà pris par l'état hover. À créer côté Figma (pas encore de variableId)." - }, "700": { "$value": "#047857", "$type": "color", diff --git a/src/design-tokens/semantics.json b/src/design-tokens/semantics.json index 6c373bd..c94a0a7 100644 --- a/src/design-tokens/semantics.json +++ b/src/design-tokens/semantics.json @@ -1790,7 +1790,7 @@ }, "surface": { "default": { - "$value": "{primitives.green.650}", + "$value": "{primitives.green.700}", "$type": "color", "$extensions": { "com.figma": { @@ -1803,7 +1803,7 @@ } }, "hover": { - "$value": "{primitives.green.700}", + "$value": "{primitives.green.800}", "$type": "color", "$extensions": { "com.figma": { @@ -15617,7 +15617,7 @@ }, "surface": { "default": { - "$value": "{primitives.green.650}", + "$value": "{primitives.green.700}", "$type": "color", "$extensions": { "com.figma": { @@ -15630,7 +15630,7 @@ } }, "hover": { - "$value": "{primitives.green.700}", + "$value": "{primitives.green.800}", "$type": "color", "$extensions": { "com.figma": {