diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8531b74b..363cc136 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
@@ -43,6 +44,9 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le pr
- **`ui-label` tronque son texte quand il est contraint** au lieu de déborder (`text-overflow: ellipsis` sur `.ui-label-text`, `max-width: 100%` sur la racine). Sans contrainte de largeur, le comportement est inchangé : le texte passe à la ligne comme avant.
- **`ui-datepicker` est saisissable au clavier par défaut** (`allowInput` passe de `false` à `true`, FSHSP-118). Le champ ne proposait la sélection qu'au calendrier dans la grande majorité des configurations ; pour un non-voyant, taper une date est bien plus rapide que naviguer une grille de ~30 cases au lecteur d'écran. Repasser `allowInput` à `false` restaure l'ancien comportement (grille seule). `range`/`multiple` sont couverts aussi (voir plus haut) ; sans effet en `timeOnly` (pas de parseur pour une heure seule), qui reste lecture seule comme avant.
- **`showClear` passe de `false` à `true` par défaut, et sa priorité change face à l'icône calendrier** (FSHSP-118). Avant, la croix remplaçait systématiquement l'icône calendrier/horloge dès qu'une valeur était présente (si `showClear`) — au prix de perdre le seul déclencheur focusable capable de rouvrir le panneau. Désormais la croix ne prend le pas que si `showIcon` est à `false` : avec l'icône affichée (le défaut), elle reste cliquable pour changer la date directement, et l'effacement passe par le clavier (`allowInput`, sélectionner + supprimer le texte). Un consommateur qui utilisait déjà `showClear` avec `showIcon` à `true` verra donc la croix disparaître au profit de l'icône calendrier ; passer `showIcon` à `false` restaure son ancien comportement.
+- **La règle `nested-interactive` est levée sur `ui-input-tags` et `ui-autocomplete` (mode `multiple`)** (FSHSP-106) : le bouton de suppression de chaque tag reste imbriqué dans son `role="option"`. Correctif accepté en l'état, décision prise avec l'équipe ; ticket de restructuration à suivre.
+- **Deux paires de tokens à un cheveu du seuil AA sont montées d'un cran** (FSHSP-106) : `actions.low.content.default` `slate-500` → `slate-600` (4,44 → 5,45), et `navigation.highOutlined.content.active` `primary-500` → `primary-600` (4,20 → 6,00). Dans les deux cas, l'état voisin qui partageait la même valeur (`hover`) est monté d'un cran aussi, pour ne pas le fusionner avec l'état qu'on vient de corriger : `actions.low.content.hover` passe à `slate-700` (sinon identique à `default`), `navigation.highOutlined.content.hover` à `primary-600` (même défaut que `active`, non mesuré par axe car en `:hover`, mais réel). Vérifié à l'exécution sur `ui-link`, `ui-segment-control` et `ui-tabs` : zéro violation, écart visuel imperceptible. Source Figma (`src/design-tokens/semantics.json`) — la variable doit être mise à jour côté Figma pour ne pas être écrasée au prochain export.
+- **La rampe de surfaces `warning` est décalée d'un cran** (FSHSP-106) : `default` passe de `orange-500` à `orange-700` (2,80 → **5,18:1** sous le texte blanc) et `hover` de `orange-700` à `orange-800` (7,31:1). Aucune couleur créée — contrairement au vert, l'orange n'avait pas de place pour un palier intermédiaire : la teinte conforme la plus claire a la même luminosité que le `700` déjà utilisé par le survol. `focused` (`orange-800`) et `pressed` (`orange-900`) sont inchangés. Conséquence visible : le `warning` par défaut est un orange nettement plus sombre. Mode clair uniquement.
### Fixed
@@ -59,6 +63,21 @@ 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`.
+- **`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.
+- **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é.
+- **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 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é.
+- **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/figma/README.md b/figma/README.md
index 98b718c6..21ca1180 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`.
diff --git a/figma/component-vars.json b/figma/component-vars.json
index aa4c5c93..e29a5d7f 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/actions/ui-button/src/lib/ui-button.html b/projects/ui-kit/actions/ui-button/src/lib/ui-button.html
index e22ffd05..4e4e2a7f 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()) {
diff --git a/projects/ui-kit/actions/ui-button/ui-button.stories.ts b/projects/ui-kit/actions/ui-button/ui-button.stories.ts
index 215b2e6b..dab0c72b 100644
--- a/projects/ui-kit/actions/ui-button/ui-button.stories.ts
+++ b/projects/ui-kit/actions/ui-button/ui-button.stories.ts
@@ -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: `
@@ -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.
@@ -231,7 +235,13 @@ export const OnColorOmitted: Story = {
`,
}),
- 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-autocomplete/src/lib/ui-autocomplete.html b/projects/ui-kit/forms/ui-autocomplete/src/lib/ui-autocomplete.html
index 2a5781bb..086637bf 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 @@
>
- @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 19e12e72..c54bea9b 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é.
@@ -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); }
}
@@ -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); }
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 dbd4e3b5..8ccffbdf 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();
diff --git a/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx b/projects/ui-kit/forms/ui-datepicker/ui-datepicker.mdx
index 71374896..9156e92a 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-number/src/lib/ui-input-number.html b/projects/ui-kit/forms/ui-input-number/src/lib/ui-input-number.html
index b9a7213f..4d712895 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 dda95a92..b4b8e3ee 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(() => {
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 6a4bc274..b5b410ad 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) {
diff --git a/projects/ui-kit/forms/ui-input-tags/ui-input-tags.stories.ts b/projects/ui-kit/forms/ui-input-tags/ui-input-tags.stories.ts
index 83b07478..4ee92399 100644
--- a/projects/ui-kit/forms/ui-input-tags/ui-input-tags.stories.ts
+++ b/projects/ui-kit/forms/ui-input-tags/ui-input-tags.stories.ts
@@ -53,6 +53,9 @@ const meta: Meta = {
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: {
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 d2dd7fe6..f2e7f41c 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 5c635dc7..07942453 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 {
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 7981c5f6..efbc14de 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 }}
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 bb88f9bf..fd0a14a5 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 1216d59c..6434ceb1 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 ec5b53e8..92413752 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) {
}
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 cbf4d64d..c5077226 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 31f5b645..80b7f33a 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/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html b/projects/ui-kit/navigation/ui-breadcrumb/src/lib/ui-breadcrumb.html
index 349cc8cc..b7b7e59e 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 {
+
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 d6ed2e77..20e9d536 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 @@
+