diff --git a/CHANGELOG.md b/CHANGELOG.md index 81364e8..3e6319c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,54 @@ Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et le p `utils.overlay-motion` s'il vit dans le calque supérieur, `useUiMotion` s'il quitte le DOM. C'est la question qu'on se pose en vrai, et elle n'était écrite nulle part. +- `ui-bottom-sheet` : panneau qui glisse depuis le bord bas de l'écran, sur le même socle que + `ui-modal` et `ui-drawer`, le `` natif : le voile et le positionneur du kit Angular + disparaissent, `::backdrop` fait le premier et les insets du dialogue font le second. Ce qui + reste écrit à la main lui appartient vraiment : trois paliers de hauteur plus n'importe quelle + longueur CSS, la fermeture en tirant vers le bas, et le passage de `half` à `full` en tirant + vers le haut, que les flèches font aussi au clavier. Le geste et l'animation touchent la même + propriété, `translate`, donc le glissement **coule** dans la fermeture au lieu de s'y ajouter. + +- `ui-stepper` : progression numérotée, en assistant à plusieurs étapes ou en simple + indicateur d'avancement. L'avancement d'une étape se **déduit** de sa place dans la séquence, + que React lit dans les `children` : une fonction pure, sans état ni effet, juste dès le + premier rendu. La sémantique ARIA suit la disposition, onglets à plat et accordéon en + colonne, un onglet qui contiendrait son propre panneau étant invalide. Un panneau quitté + reste monté mais devient `inert`, donc l'état d'un formulaire survit au passage d'une étape à + l'autre. `useUiStepper()` pilote la progression depuis un panneau, là où le kit Angular + appelle des méthodes sur une référence de gabarit. + +- `ui-speed-dial` : bouton flottant qui déploie ses actions autour de lui, empilées le long + d'une direction ou posées sur un anneau, une moitié ou un quart d'arc. Les entrées sont le + même sous-ensemble feuille que `ui-menu`, donc le modèle d'un menu alimente un bouton sans + être remodelé. **Fermé, aucune action n'est rendue** : ni lue par un lecteur d'écran, ni + atteignable au clavier, donc rien à masquer. Les actions forment un seul arrêt de tabulation, + entrent une par une et **sortent ensemble**, ce que la disparition de la liste entière donne + gratuitement là où le kit Angular doit annuler le décalage de sortie pour éviter un + clignotement. + +- `ui-bottom-tab-bar` : la barre de navigation basse des appareils tactiles, avec ses + destinations et son bouton d'action surélevé. Bâtie pour l'écran sur lequel elle vit : elle + réserve l'incrustation du système, iOS comme Android, tient la cible tactile de 44 px, coupe + le délai de double frappe et disparaît à l'impression. La destination courante s'annonce par + `aria-current="page"` et non par `role="tab"`, qui exigerait un panneau associé, et les + flèches parcourent la barre **sans** retirer aucun contrôle de l'ordre de tabulation. + +- `ui-breadcrumb` : le fil d'Ariane. Chaque maillon rend **l'élément natif qui correspond à sa + sémantique**, jamais une enveloppe : une ancre s'il mène quelque part, un ` + , + ); + + await screen.getByRole('button', { name: 'Ailleurs' }).click(); + await expect.poll(() => trigger(screen.container).getAttribute('aria-expanded')).toBe('false'); + + const colle = await render( + <> + + + , + ); + await colle.getByRole('button', { name: 'Ailleurs aussi' }).click(); + await new Promise((r) => setTimeout(r, 50)); + expect(trigger(colle.container)).toHaveAttribute('aria-expanded', 'true'); +}); + +// --- Dispositions ---------------------------------------------------------- +test('la disposition et la direction posent leurs modifieurs', async () => { + const empile = await render(); + const anneau = await render(); + + expect(empile.container.querySelector('.ui-speed-dial')).toHaveClass('_linear', '_right'); + expect(anneau.container.querySelector('.ui-speed-dial')).toHaveClass('_circle'); + // La direction n'a pas de sens sur un anneau entier. + expect(anneau.container.querySelector('.ui-speed-dial')).not.toHaveClass('_up'); +}); + +// Sur un arc, chaque action porte sa place angulaire en `transform` : la +// première est en haut, la suivante décalée d'un pas. +test('sur un arc, chaque action porte sa place angulaire', async () => { + const screen = await render(); + const all = actions(screen.container); + + for (const action of all) expect(action.style.transform).toContain('var(--_radius)'); + // Le tout premier slot est plein nord : sin(0) = 0, -cos(0) = -1. + expect(all[0]!.style.transform).toContain('calc(0 * var(--_radius))'); + expect(all[0]!.style.transform).toContain('calc(-1 * var(--_radius))'); + expect(all[1]!.style.transform).not.toBe(all[0]!.style.transform); +}); + +test('empilées, les actions n’ont aucune transformation', async () => { + const screen = await render(); + + for (const action of actions(screen.container)) expect(action.style.transform).toBe(''); +}); + +test('radius pose le rayon sur la racine', async () => { + const screen = await render(); + + expect( + screen.container + .querySelector('.ui-speed-dial')! + .style.getPropertyValue('--_radius'), + ).toBe('120px'); +}); + +// La boîte de la liste recouvre le déclencheur sur un arc : sans retrait du +// pointeur, elle avalerait ses clics. +test('la liste ne prend pas les clics du déclencheur', async () => { + const screen = await render(); + + expect(getComputedStyle(list(screen.container)!).pointerEvents).toBe('none'); + expect(getComputedStyle(actions(screen.container)[0]!).pointerEvents).toBe('auto'); +}); + +// --- Mouvement ------------------------------------------------------------- +test('les actions entrent avec le préréglage de leur disposition', async () => { + const empile = await render(); + const anneau = await render(); + + expect(actions(empile.container)[0]!.className).toContain('ui-motion-slide-up-enter'); + // Sur un arc, seul un fondu peut cohabiter avec la `transform` de position. + expect(actions(anneau.container)[0]!.className).toContain('ui-motion-fade-enter'); +}); + +test('l’entrée des actions est décalée selon leur rang', async () => { + const screen = await render(); + const all = actions(screen.container); + + expect(all.map((a) => a.style.getPropertyValue('--_stagger-index'))).toEqual(['0', '1', '2']); + expect(getComputedStyle(all[0]!).getPropertyValue('--ui-motion-delay').trim()).not.toBe( + getComputedStyle(all[2]!).getPropertyValue('--ui-motion-delay').trim(), + ); +}); + +// Toute la raison d'être du crochet : sans lui, la liste quitterait le DOM à +// l'instant de la fermeture et il n'y aurait plus rien à animer. +test('la liste reste rendue le temps de sa sortie', async () => { + const screen = await render(); + + await screen.getByRole('button', { name: 'Actions' }).click(); + + await expect.poll(() => list(screen.container)?.className).toContain('ui-motion-fade-leave'); + await expect.poll(() => list(screen.container)).toBeNull(); +}); + +test('motion=false retire les classes de mouvement', async () => { + const screen = await render(); + + expect(list(screen.container)!.className).toBe('ui-speed-dial-list'); + expect(actions(screen.container)[0]!.className).not.toContain('ui-motion'); +}); + +// --- Contrôlé -------------------------------------------------------------- +test('en mode contrôlé, l’ouverture appartient à l’appelant', async () => { + function Controlled() { + const [open, setOpen] = useState(false); + return ( + <> + + + + ); + } + const screen = await render(); + + await screen.getByRole('button', { name: 'Actions' }).click(); + await new Promise((r) => setTimeout(r, 50)); + expect(actions(screen.container)).toHaveLength(0); + + await screen.getByRole('button', { name: 'Ouvrir' }).click(); + await expect.poll(() => actions(screen.container)).toHaveLength(3); +}); + +test('showTooltips donne son libellé en bulle à chaque action', async () => { + const screen = await render(); + + expect(screen.container.querySelectorAll('.ui-tooltip')).toHaveLength(3); +}); diff --git a/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.tsx b/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.tsx new file mode 100644 index 0000000..71d093a --- /dev/null +++ b/packages/ui-kit-react/src/actions/ui-speed-dial/ui-speed-dial.tsx @@ -0,0 +1,496 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithRef, + type CSSProperties, + type KeyboardEvent, + type MouseEvent, + type SyntheticEvent, +} from 'react'; + +import { UiTooltip, type TooltipPosition } from '../../informative/ui-tooltip'; +import { useControllableState } from '../../core/forms'; +import { motionEnterClass, useUiMotion, type UiMotionPreset } from '../../core/motion'; +import { useCloseOnNavigation } from '../../core/overlay'; +import type { UiLevel } from '../../core/types'; +import { cx } from '../../core/utils'; +import type { UiMenuItem } from '../../navigation/ui-menu'; +import { UiButton, type ButtonSize, type ButtonVariant, type UiButtonProps } from '../ui-button'; + +import './ui-speed-dial.scss'; + +/** + * Axe le long duquel les actions se déploient. + * + * `linear` et `semi-circle` lisent les quatre points cardinaux ; + * `quarter-circle` lit les quatre coins, et retombe sur `up-right` si on lui + * donne un cardinal. + */ +export type SpeedDialDirection = + 'up' | 'down' | 'left' | 'right' | 'up-left' | 'up-right' | 'down-left' | 'down-right'; + +/** + * `linear` empile les actions le long de `direction`. Les trois autres les + * posent sur un arc autour du déclencheur, à `radius` : l'anneau entier pour + * `circle`, une moitié centrée sur `direction` pour `semi-circle`, un quart + * dans le coin de `direction` pour `quarter-circle`. + */ +export type SpeedDialType = 'linear' | 'circle' | 'semi-circle' | 'quarter-circle'; + +/** + * Une action révélée par le bouton, le sous-ensemble feuille de `UiMenuItem` + * qu'un `ui-button` sait rendre : ni groupe ni séparateur. + */ +export type UiSpeedDialItem = Pick< + UiMenuItem, + 'id' | 'label' | 'icon' | 'command' | 'disabled' | 'url' | 'target' | 'ariaLabel' +> & { + /** + * Rend l'action soi-même, pour brancher le lien d'un routeur. Typé pour + * `ui-button`, et non pour `ui-menu` : c'est un bouton que l'action rend, et + * ses props ne sont pas celles d'une entrée de menu. + */ + render?: UiButtonProps['render']; +}; + +/** Charge de `onItemClick` et de la `command` d'une action. */ +export interface UiSpeedDialItemClickEvent { + originalEvent: SyntheticEvent; + item: UiSpeedDialItem; +} + +/** Une action prête à rendre : son entrée, sa clé stable et son rang. */ +interface SpeedDialNode { + item: UiSpeedDialItem; + key: string; + index: number; +} + +/** Arrondit le bruit flottant qu'un `calc()` CSS ne sait pas lire. */ +const round = (value: number): number => Math.round(value * 1e6) / 1e6; + +/** + * L'ouverture angulaire d'un arc, en degrés horaires depuis le haut : 0° en + * haut, 90° à droite, 180° en bas, 270° à gauche. + * + * `circle` ignore `direction`, un anneau entier n'ayant pas de côté. + * `semi-circle` centre sa moitié dessus. `quarter-circle` remplit le quart du + * coin nommé, et retombe sur `up-right` pour un cardinal, n'ayant lui-même pas + * de « haut ». + */ +function arcSpan( + type: Exclude, + direction: SpeedDialDirection, +): { start: number; span: number } { + if (type === 'circle') return { start: 0, span: 360 }; + if (type === 'semi-circle') { + const center = { up: 0, right: 90, down: 180, left: 270 }[direction as string] ?? 0; + return { start: center - 90, span: 180 }; + } + const start = + { 'up-right': 0, 'down-right': 90, 'down-left': 180, 'up-left': 270 }[direction as string] ?? 0; + return { start, span: 90 }; +} + +export interface UiSpeedDialProps extends Omit< + ComponentPropsWithRef<'div'>, + 'children' | 'onClick' +> { + /** Les actions révélées par le bouton. */ + items: UiSpeedDialItem[]; + /** Ouverture imposée. Renseignée, le bouton est **contrôlé**. */ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + + /** Disposition : empilée, anneau entier, moitié ou quart. */ + type?: SpeedDialType; + /** Axe de déploiement. Inutilisé par `circle`, qui fait toujours le tour. */ + direction?: SpeedDialDirection; + /** Rayon de l'arc en px, pour tout sauf `linear`. Absent, le défaut du SCSS sert. */ + radius?: number; + + /** Niveau sémantique du déclencheur. */ + level?: UiLevel; + /** + * Niveau sémantique des actions. `low` par défaut, délibérément différent de + * celui du déclencheur : une action en `high` se lirait comme un second + * bouton principal plutôt que comme une option révélée. + */ + itemLevel?: UiLevel; + variant?: ButtonVariant; + size?: ButtonSize; + + /** Icône du déclencheur fermé. */ + showIcon?: string; + /** Icône du déclencheur ouvert. Absente, `showIcon` pivote sur place. */ + hideIcon?: string; + /** Anime la rotation de l'icône du déclencheur, quand il n'y a pas de `hideIcon`. */ + rotateAnimation?: boolean; + + /** Assombrit la page derrière le bouton ouvert. */ + mask?: boolean; + /** Referme au clic en dehors. */ + hideOnClickOutside?: boolean; + /** Désactive le déclencheur et toutes les actions. */ + disabled?: boolean; + + /** Affiche le libellé de chaque action en bulle d'aide. */ + showTooltips?: boolean; + /** Anime le déclencheur et les actions. Le mouvement réduit gagne toujours. */ + motion?: boolean; + + /** Notifié au clic sur le déclencheur, jamais quand il est désactivé. */ + onTriggerClick?: (event: MouseEvent) => void; + /** Notifié à l'activation d'une action, jamais sur une action désactivée. */ + onItemClick?: (event: UiSpeedDialItemClickEvent) => void; +} + +/** + * ui-speed-dial : un bouton flottant qui déploie ses actions autour de lui. + * + * Les actions viennent d'un modèle déclaratif, le même sous-ensemble feuille + * que `ui-menu`, donc les entrées d'un menu alimentent un bouton sans être + * remodelées. Quatre dispositions : empilée le long de `direction`, ou posée + * sur un anneau, une moitié ou un quart d'arc. + * + * Les actions ne sont rendues qu'à l'ouverture, donc ni lues ni atteignables + * une fois refermées, et elles forment **un seul** arrêt de tabulation. Le + * placement du bouton dans la page appartient à l'appelant. + */ +export function UiSpeedDial({ + items, + open, + defaultOpen = false, + onOpenChange, + type = 'linear', + direction = 'up', + radius, + level = 'high', + itemLevel = 'low', + variant = 'filled', + size = 'default', + showIcon = 'plus', + hideIcon, + rotateAnimation = true, + mask = false, + hideOnClickOutside = true, + disabled = false, + showTooltips = false, + motion = true, + onTriggerClick, + onItemClick, + className, + style, + tabIndex, + ref, + ...rest +}: UiSpeedDialProps) { + const uid = useId(); + const ariaLabel = rest['aria-label']; + const ariaLabelledBy = rest['aria-labelledby']; + delete rest['aria-label']; + delete rest['aria-labelledby']; + + const [isOpen, setOpen] = useControllableState({ + value: open, + defaultValue: defaultOpen, + onChange: onOpenChange, + }); + + const hostRef = useRef(null); + const triggerRef = useRef(null); + const listRef = useRef(null); + const [focusedKey, setFocusedKey] = useState(null); + + const attachHost = useCallback( + (node: HTMLDivElement | null) => { + hostRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }, + [ref], + ); + + /** + * Un seul mouvement pour toute la liste, et non un par action. + * + * La sortie décalée a été essayée côté Angular puis annulée : une action dont + * le délai est plus court finit avant ses voisines, perd sa classe de sortie + * et revient à pleine opacité le temps qu'elles terminent, ce qui se lit + * comme un clignotement. Les actions partent donc ensemble, ce qu'une seule + * disparition de la liste donne gratuitement, et l'entrée reste décalée par + * `--_stagger-index`. + */ + const { + present, + ref: attachMotion, + className: motionClassName, + style: motionStyle, + } = useUiMotion(isOpen, { preset: 'fade', disabled: !motion }); + + const close = useCallback(() => { + setOpen(false); + setFocusedKey(null); + }, [setOpen]); + + // Un bouton déclaré dans une coquille d'application survit à la vue routée : + // on le referme plutôt que de le laisser ouvert par-dessus la page suivante. + useCloseOnNavigation(isOpen, close); + + // Fermeture au clic en dehors, branchée seulement tant que c'est ouvert. + useEffect(() => { + if (!isOpen || !hideOnClickOutside) return; + const onPointerDown = (event: PointerEvent) => { + if (event.target instanceof Node && hostRef.current?.contains(event.target)) return; + close(); + }; + document.addEventListener('pointerdown', onPointerDown, true); + return () => document.removeEventListener('pointerdown', onPointerDown, true); + }, [isOpen, hideOnClickOutside, close]); + + const nodes: SpeedDialNode[] = items.map((item, index) => ({ + item, + key: item.id ?? `${uid}_${index}`, + index, + })); + const focusableKeys = nodes.filter((node) => !node.item.disabled).map((node) => node.key); + const tabStop = + focusedKey && focusableKeys.includes(focusedKey) ? focusedKey : (focusableKeys[0] ?? null); + + const focusKey = useCallback((key: string | undefined) => { + if (!key) return; + setFocusedKey(key); + listRef.current?.querySelector(`[data-key="${CSS.escape(key)}"]`)?.focus(); + }, []); + + // À l'ouverture au clavier, le focus part sur l'arrêt de tabulation, déjà + // posé dans le DOM : le relire évite de recopier la règle qui l'a choisi. + const openedByKeyboard = useRef(false); + useEffect(() => { + if (!isOpen || !openedByKeyboard.current) return; + openedByKeyboard.current = false; + listRef.current?.querySelector('[data-key][tabindex="0"]')?.focus(); + }, [isOpen]); + + // Le focus revient au déclencheur quand il était dans la liste qui se ferme. + const wasOpen = useRef(isOpen); + useEffect(() => { + const closing = wasOpen.current && !isOpen; + wasOpen.current = isOpen; + if (closing && listRef.current?.contains(document.activeElement)) triggerRef.current?.focus(); + }, [isOpen]); + + const arcPosition = (() => { + if (type === 'linear' || !nodes.length) return () => undefined; + const { start, span } = arcSpan(type, direction); + const step = type === 'circle' ? span / nodes.length : span / Math.max(nodes.length - 1, 1); + return (index: number) => { + const angle = ((start + step * index) * Math.PI) / 180; + // Le bruit flottant, `sin(π)` valant 1,2e-16, s'écrit en notation + // exponentielle, qu'un `calc()` CSS ne sait pas lire. + const x = round(Math.sin(angle)); + const y = round(-Math.cos(angle)); + return `translate(-50%, -50%) translate(calc(${x} * var(--_radius)), calc(${y} * var(--_radius)))`; + }; + })(); + + /** + * Préréglage d'entrée d'une action. + * + * Sur un arc, chaque action porte déjà sa propre `transform`, sa place + * angulaire : `zoom` et les glissements animent `transform` eux aussi, et une + * animation CSS gagne sur un style en ligne pour la même propriété, ce qui + * effacerait la position le temps de l'animation. `fade`, qui ne touche que + * l'opacité, est le seul préréglage qui ne peut pas entrer en conflit. + */ + const itemPreset: UiMotionPreset = + type !== 'linear' + ? 'fade' + : direction === 'up' || direction === 'down' || direction === 'left' || direction === 'right' + ? (`slide-${direction}` as UiMotionPreset) + : 'slide-up'; + + /** La bulle se pose à l'opposé du déploiement, pour ne jamais couvrir une action. */ + const tooltipPosition: TooltipPosition = + type === 'linear' && (direction === 'up' || direction === 'down') ? 'right' : 'top'; + + const triggerIcon = hideIcon ?? showIcon; + const triggerRotates = isOpen && !hideIcon && rotateAnimation; + + const activate = (event: MouseEvent, node: SpeedDialNode) => { + const item = node.item; + if (item.disabled) { + event.preventDefault(); + return; + } + if (!item.url && !item.render) event.preventDefault(); + setFocusedKey(node.key); + item.command?.({ originalEvent: event, item }); + onItemClick?.({ originalEvent: event, item }); + close(); + }; + + const onListKeyDown = (event: KeyboardEvent) => { + if (!focusableKeys.length) return; + // La clé courante se lit sur l'ÉVÉNEMENT avant l'état : une touche qui suit + // immédiatement un `focus()` arrive avant que React ait traité le rendu que + // ce focus déclenche, et l'état pointerait encore l'action d'avant. + const from = (event.target as HTMLElement).closest?.('[data-key]'); + const current = from?.getAttribute('data-key') ?? focusedKey; + const index = current ? focusableKeys.indexOf(current) : -1; + + switch (event.key) { + // Les deux paires de flèches avancent pareil quelle que soit la + // disposition : mieux vaut une correspondance simple et prévisible que + // huit règles dérivées de la direction. + case 'ArrowUp': + case 'ArrowRight': + event.preventDefault(); + focusKey(focusableKeys[(index + 1) % focusableKeys.length]); + break; + case 'ArrowDown': + case 'ArrowLeft': + event.preventDefault(); + focusKey(focusableKeys[(index - 1 + focusableKeys.length) % focusableKeys.length]); + break; + case 'Home': + event.preventDefault(); + focusKey(focusableKeys[0]); + break; + case 'End': + event.preventDefault(); + focusKey(focusableKeys[focusableKeys.length - 1]); + break; + case 'Escape': + event.preventDefault(); + close(); + break; + } + }; + + const onTriggerKeyDown = (event: KeyboardEvent) => { + if (disabled || isOpen) return; + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) return; + event.preventDefault(); + openedByKeyboard.current = true; + setOpen(true); + }; + + const hostStyle: CSSProperties = { ...style }; + if (radius != null) (hostStyle as Record)['--_radius'] = `${radius}px`; + + return ( +
+ {mask && present && ( + // Le masque partage la durée de vie de la liste : il n'a donc pas + // besoin de sa propre rétention, seulement de la classe qui va avec. + + ); +} diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/index.ts b/packages/ui-kit-react/src/forms/ui-swatch-picker/index.ts new file mode 100644 index 0000000..7c7ca95 --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/index.ts @@ -0,0 +1,9 @@ +export { + UiSwatchPicker, + DEFAULT_SWATCH_PALETTE, + type UiSwatchPickerProps, + type UiSwatchPickerTriggerProps, + type UiSwatch, + type UiSwatchGroup, + type SwatchPickerSize, +} from './ui-swatch-picker'; diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.mdx b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.mdx new file mode 100644 index 0000000..ae1d8f5 --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.mdx @@ -0,0 +1,161 @@ +import { Meta, Canvas, ArgTypes } from '@storybook/addon-docs/blocks'; +import { ConfigTable } from '@sb/blocks/config-table'; +import * as SwatchStories from './ui-swatch-picker.stories'; + + + +# ui-swatch-picker + +Grille de couleurs, posée dans la page ou ouverte en popup, pour un choix unique dans une +palette. + + + +## API + + + +### UiSwatch et UiSwatchGroup + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ChampTypeRôle
+ key + + string + + Identifiant stable, qui est aussi ce que porte value. +
+ cssVar + + string + + La variable qui peint la pastille, par exemple --primitives-red-500. +
+ label + + string + Nom accessible de la pastille. Jamais la seule couleur.
+ UiSwatchGroup + + { label, swatches } + Une section titrée de la palette, rendue à la suite dans la même grille.
+ +## La palette pointe des jetons + +`DEFAULT_SWATCH_PALETTE` est une grille de 7 teintes sur 5 nuances, plus le noir et le blanc. +Aucune valeur n'y est écrite en dur : chaque pastille **pointe une variable** +`--primitives-*`, donc changer de marque change la grille sans toucher au code. Une palette +maison suit la même règle. + + + +## Aucune couleur + +`allowClear` ajoute une pastille en tête de grille, barrée d'une diagonale, qui vaut `null`. + + + +## Densité + + + +## Popup + +Le composant ne rend pas son propre déclencheur, même contrat que +[`ui-menu`](?path=/docs/components-ui-navigation-ui-menu--docs) : `trigger` reçoit les props à +reverser, dont la `ref` qui sert d'ancre et l'état ARIA. Le panneau vit dans le **calque +supérieur**, donc aucun ancêtre en `overflow: hidden` ne le rogne et aucun z-index n'a à être +arbitré. + + + +```tsx + } +/> +``` + +Choisir une pastille referme le panneau et **rend le focus** au déclencheur. Un +`popover="manual"` ne le fait pas seul, contrairement à un `auto` : le composant s'en charge, +et seulement si le focus était dans le panneau, pour ne pas le voler là où l'utilisateur vient +d'aller. + +## Accessibilité + + + + + + + + + + + + + + + + + + + + + + + + + + +
PointImplémentation
Motif listbox + Un role="listbox" nommé, dont chaque pastille est un{' '} + <button role="option"> portant aria-selected. +
Nom des pastilles + Un libellé, jamais la seule couleur : voyants et non voyants doivent lire la même chose. + La sélection est un liseré distinct, jamais une simple opacité, qui doit tenir sur toutes + les teintes. +
Clavier + Navigation de grille : les flèches se déplacent sur deux axes, une ligne + faisant sept pastilles ; Début et Fin vont aux extrémités. Un + seul arrêt de tabulation pour tout le lot, posé sur la sélection. +
Popup + aria-haspopup="listbox", aria-expanded et{' '} + aria-controls sont posés sur le déclencheur par le composant. À l'ouverture + le focus part sur l'arrêt de tabulation ; Échap et un clic à côté referment. +
+ +## Theming + + diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.scss b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.scss new file mode 100644 index 0000000..4dbc67b --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.scss @@ -0,0 +1,132 @@ +@use 'utils'; +@use 'sass:map'; + +// ===================================================================== +// ui-swatch-picker : co-located styles. +// +// No host element here, so `.ui-swatch-picker` IS the panel, en ligne ou +// dans le calque supérieur. +// ===================================================================== + +// --- Config (extension points) ---------------------------------------- +$columns: 7; // colonnes de la grille, miroir de la constante GRID_COLUMNS + +/// Taille de la cellule et espacement de la grille, par densité. +$sizes: ( + 'default': ( + cell-size: var(--ui-swatch-picker-cell-size, #{utils.rem-calc(32)}), + gap: var(--ui-swatch-picker-gap, var(--units-xs)), + ), + 'small': ( + cell-size: var(--ui-swatch-picker-cell-size-small, #{utils.rem-calc(24)}), + gap: var(--ui-swatch-picker-gap-small, var(--units-2xs)), + ), +); +$size-default: map.get($sizes, 'default'); /// Raccourci vers l'entrée `default` de `$sizes`. + +$panel-surface: var(--ui-swatch-picker-panel-surface, var(--global-background-default)); /// Fond opaque du panneau. +$panel-padding: var(--ui-swatch-picker-panel-padding, #{utils.$overlay-panel-padding}); /// Gouttière interne du panneau, **partagée** avec les autres panneaux flottants. +$swatch-radius: var(--ui-swatch-picker-swatch-radius, var(--radius-xs)); /// Rayon des coins d'une pastille. +$focus-ring-width: var(--ui-swatch-picker-focus-ring-width, #{utils.$focus-ring-width}); /// Épaisseur de l'anneau de focus. +$selected-ring-width: var(--ui-swatch-picker-selected-ring-width, var(--stroke-lg)); /// Épaisseur du liseré de sélection. +$stroke-width: var(--ui-swatch-picker-stroke-width, #{utils.$control-stroke-width}); /// Épaisseur du contour de chaque pastille. + +@mixin picker-size($size) { + grid-template-columns: repeat($columns, map.get($size, cell-size)); + gap: map.get($size, gap); + + .ui-swatch-picker-swatch { + width: map.get($size, cell-size); + height: map.get($size, cell-size); + } +} + +.ui-swatch-picker { + display: flex; + flex-direction: column; + box-sizing: border-box; + padding: $panel-padding; + border: none; + @include utils.overlay-panel($panel-surface, transparent); + + // --- Popup : le panneau dans le calque supérieur ---------------------- + &._popup { + position: fixed; + inset: auto; + width: max-content; + max-width: calc(100vw - var(--units-lg)); + max-height: calc(100vh - var(--units-lg)); + margin: 0; + overflow: auto; + + // Le `display: none` d'un popover fermé vient du style NAVIGATEUR, qu'un + // `display` d'auteur bat : il faut le redire, sinon le panneau fermé reste + // affiché par-dessus son propre déclencheur. + &:not(:popover-open) { + display: none; + } + + // Entrée et sortie, par le mixin partagé du système de motion. + @include utils.overlay-motion { + opacity: 0; + translate: 0 calc(-1 * var(--ui-motion-distance, #{utils.$motion-distance})); + } + } + + &-grid { + display: grid; + @include picker-size($size-default); + } + + &._small &-grid { + @include picker-size(map.get($sizes, 'small')); + } + + // --- Swatch (une cellule de couleur) ---------------------------------- + &-swatch { + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + padding: 0; + border: $stroke-width solid var(--ui-swatch-picker-swatch-stroke, var(--global-border-default)); /// Couleur du contour d'une pastille. + border-radius: $swatch-radius; + background-color: var(--ui-swatch-picker-swatch-color, transparent); /// Couleur peinte par une pastille ; le composant la pose depuis le `cssVar` de la palette. + cursor: pointer; + @include utils.control-transition(box-shadow, border-color); + + &:hover:not(:disabled) { + border-color: var(--ui-swatch-picker-swatch-stroke-hover, var(--global-border-default-hover)); /// Couleur du contour d'une pastille au survol. + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 $focus-ring-width + var(--ui-swatch-picker-focus-ring-color, var(--global-border-focus)); /// Couleur de l'anneau de focus. + } + + // Sélection : un liseré distinct, jamais une opacité, qui doit tenir sur + // toutes les teintes. + &._selected { + box-shadow: inset 0 0 0 $selected-ring-width + var(--ui-swatch-picker-selected-ring-color, var(--form-high-stroke-checked)); /// Couleur du liseré de sélection. + } + + &:focus-visible._selected { + box-shadow: + inset 0 0 0 $selected-ring-width + var(--ui-swatch-picker-selected-ring-color, var(--form-high-stroke-checked)), + 0 0 0 $focus-ring-width var(--ui-swatch-picker-focus-ring-color, var(--global-border-focus)); + } + } + + // La pastille « aucune couleur » : transparente, barrée d'une diagonale. + &-swatch._clear { + background-color: transparent; + color: var(--ui-swatch-picker-clear-color, var(--global-text-muted)); /// Couleur de la pastille « aucune couleur ». + } + + &-clear-icon { + line-height: 0; + } +} diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.stories.tsx b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.stories.tsx new file mode 100644 index 0000000..322da66 --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.stories.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { UiButton } from '../../actions/ui-button'; + +import { UiSwatchPicker, type UiSwatch, type UiSwatchGroup } from './ui-swatch-picker'; + +const meta: Meta = { + title: 'Components/ui/forms/ui-swatch-picker', + component: UiSwatchPicker, + args: { + size: 'small', + allowClear: true, + popup: false, + 'aria-label': 'Couleur du texte', + }, + argTypes: { + size: { control: 'inline-radio', options: ['default', 'small'] }, + allowClear: { control: 'boolean' }, + popup: { control: false }, + palette: { control: false }, + trigger: { control: false }, + value: { control: false }, + }, + parameters: { + layout: 'padded', + design: { + type: 'figma', + url: 'https://www.figma.com/design/GZww5hdUA49LB8XWeWP6tl/-Projet----UI-Kit?node-id=3727-36720', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** La palette par défaut, posée dans la page. */ +export const Default: Story = { + args: { size: 'default', allowClear: false, defaultValue: 'primary-500' }, +}; + +/** Densité resserrée, pour une barre d'outils. */ +export const Small: Story = { + args: { allowClear: false, defaultValue: 'red-700' }, +}; + +/** Une pastille « aucune couleur » en tête de grille, qui vaut `null`. */ +export const WithClear: Story = { + args: { allowClear: true, defaultValue: null }, +}; + +/** Une palette maison : chaque pastille pointe une variable, jamais une valeur en dur. */ +const MARQUE: UiSwatchGroup[] = [ + { + label: 'Marque', + swatches: [ + { key: 'primary', cssVar: '--primitives-primary-500', label: 'Primaire' }, + { key: 'secondary', cssVar: '--primitives-secondary-500', label: 'Secondaire' }, + { key: 'green', cssVar: '--primitives-green-500', label: 'Vert' }, + { key: 'orange', cssVar: '--primitives-orange-500', label: 'Orange' }, + ], + }, +]; + +export const CustomPalette: Story = { + args: { palette: MARQUE, allowClear: true, defaultValue: 'green' }, +}; + +/** Le composant ne rend pas son déclencheur : `trigger` reçoit les props à reverser. */ +function PopupDemo() { + const [choisie, setChoisie] = useState(null); + + return ( +
+ } + /> +

+ Dernière sélection : {choisie?.label ?? 'aucune'} +

+
+ ); +} + +export const Popup: Story = { + render: () => , +}; diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.test.tsx b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.test.tsx new file mode 100644 index 0000000..33d151d --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.test.tsx @@ -0,0 +1,375 @@ +import { useState } from 'react'; +import { expect, test, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { UiButton } from '../../actions/ui-button'; + +import { + DEFAULT_SWATCH_PALETTE, + UiSwatchPicker, + type UiSwatch, + type UiSwatchGroup, +} from './ui-swatch-picker'; + +const PALETTE: UiSwatchGroup[] = [ + { + label: 'Marque', + swatches: [ + { key: 'rouge', cssVar: '--primitives-red-500', label: 'Rouge' }, + { key: 'vert', cssVar: '--primitives-green-500', label: 'Vert' }, + { key: 'bleu', cssVar: '--primitives-primary-500', label: 'Bleu' }, + ], + }, +]; + +const swatches = (root: ParentNode) => [ + ...root.querySelectorAll('.ui-swatch-picker-swatch'), +]; +const panel = (root: ParentNode) => root.querySelector('.ui-swatch-picker')!; + +const touche = (el: HTMLElement, key: string) => + el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + +// --- Grille ---------------------------------------------------------------- +test('la grille est une listbox nommée, chaque pastille une option', async () => { + const screen = await render( + , + ); + const liste = screen.container.querySelector('[role="listbox"]')!; + + expect(liste).toHaveAttribute('aria-label', 'Couleur'); + expect(swatches(screen.container)).toHaveLength(3); + for (const pastille of swatches(screen.container)) { + expect(pastille).toHaveAttribute('role', 'option'); + expect(pastille).toHaveAttribute('type', 'button'); + } +}); + +// Le nom ne doit jamais être la seule couleur : voyants et non voyants doivent +// lire la même chose. +test('chaque pastille porte son nom, jamais sa seule couleur', async () => { + const screen = await render(); + + expect(swatches(screen.container).map((s) => s.getAttribute('aria-label'))).toEqual([ + 'Rouge', + 'Vert', + 'Bleu', + ]); + expect(swatches(screen.container)[0]).toHaveAttribute('title', 'Rouge'); +}); + +// La pastille pointe une VARIABLE, jamais une valeur en dur : c'est ce qui fait +// suivre la grille quand la marque change. +test('la pastille est peinte par la variable de la palette', async () => { + const screen = await render(); + const rouge = swatches(screen.container)[0]!; + + expect(rouge.style.getPropertyValue('--ui-swatch-picker-swatch-color')).toBe( + 'var(--primitives-red-500)', + ); + // Et la variable RÉSOUT : une pastille qui pointe un jeton absent serait + // transparente sans que rien ne le dise. + expect(getComputedStyle(rouge).backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); +}); + +test('la palette par défaut couvre les teintes et les nuances', async () => { + const screen = await render(); + + // 7 teintes × 5 nuances, plus le noir et le blanc. + expect(swatches(screen.container)).toHaveLength(37); + expect( + DEFAULT_SWATCH_PALETTE[0]!.swatches.every((s) => s.cssVar.startsWith('--primitives-')), + ).toBe(true); +}); + +// --- Sélection ------------------------------------------------------------- +test('cliquer une pastille la sélectionne, et rapporte les deux formes', async () => { + const onValueChange = vi.fn(); + const onSwatchSelect = vi.fn(); + const screen = await render( + , + ); + + await screen.getByRole('option', { name: 'Vert' }).click(); + + await expect + .poll(() => swatches(screen.container)[1]!.getAttribute('aria-selected')) + .toBe('true'); + expect(swatches(screen.container)[1]).toHaveClass('_selected'); + expect(onValueChange).toHaveBeenLastCalledWith('vert'); + expect(onSwatchSelect).toHaveBeenLastCalledWith( + expect.objectContaining({ key: 'vert', label: 'Vert' }), + ); +}); + +test('une seule pastille est sélectionnée à la fois', async () => { + const screen = await render( + , + ); + + await screen.getByRole('option', { name: 'Bleu' }).click(); + + await expect + .poll(() => screen.container.querySelectorAll('[aria-selected="true"]')) + .toHaveLength(1); + expect(swatches(screen.container)[2]).toHaveClass('_selected'); +}); + +test('la pastille « aucune couleur » vaut null', async () => { + const onValueChange = vi.fn(); + const onSwatchSelect = vi.fn(); + const screen = await render( + , + ); + + const vide = swatches(screen.container)[0]!; + expect(vide).toHaveClass('_clear'); + expect(vide).toHaveAttribute('aria-label', 'Aucune couleur'); + + await screen.getByRole('option', { name: 'Aucune couleur' }).click(); + + await expect.poll(() => onValueChange.mock.calls.length).toBe(1); + expect(onValueChange).toHaveBeenLastCalledWith(null); + expect(onSwatchSelect).toHaveBeenLastCalledWith(null); +}); + +test('allowClear=false retire la pastille vide', async () => { + const screen = await render(); + + expect(screen.container.querySelector('._clear')).toBeNull(); +}); + +test('le nom de la pastille vide se surcharge', async () => { + const screen = await render( + , + ); + + expect(swatches(screen.container)[0]).toHaveAttribute('aria-label', 'Transparent'); +}); + +test('en mode contrôlé, la valeur de l’appelant gagne', async () => { + function Controlled() { + const [value, setValue] = useState('rouge'); + return ( + <> + + + + ); + } + const screen = await render(); + + await screen.getByRole('option', { name: 'Vert' }).click(); + await expect + .poll(() => swatches(screen.container)[0]!.getAttribute('aria-selected')) + .toBe('true'); + + await screen.getByRole('button', { name: 'Imposer' }).click(); + await expect + .poll(() => swatches(screen.container)[2]!.getAttribute('aria-selected')) + .toBe('true'); +}); + +// --- Clavier --------------------------------------------------------------- +// Un seul arrêt de tabulation pour tout le lot : `Tab` traverse la grille, les +// flèches circulent dedans. +test('un seul arrêt de tabulation, posé sur la sélection', async () => { + const screen = await render( + , + ); + + expect(swatches(screen.container).map((s) => s.tabIndex)).toEqual([-1, 0, -1]); +}); + +test('sans sélection, l’arrêt de tabulation est sur la première pastille', async () => { + const screen = await render(); + + expect(swatches(screen.container).map((s) => s.tabIndex)).toEqual([0, -1, -1]); +}); + +test('les flèches horizontales avancent d’une pastille, sans boucler', async () => { + const screen = await render(); + const all = swatches(screen.container); + + all[0]!.focus(); + touche(all[0]!, 'ArrowRight'); + await expect.poll(() => document.activeElement).toBe(all[1]); + + touche(all[1]!, 'ArrowLeft'); + await expect.poll(() => document.activeElement).toBe(all[0]); + + // Au bord, on reste : une grille n'est pas un carrousel. + touche(all[0]!, 'ArrowLeft'); + await expect.poll(() => document.activeElement).toBe(all[0]); +}); + +// La grille fait 7 colonnes : c'est ce qui distingue ce clavier de celui d'une +// liste, et c'est la seule chose que les flèches verticales vérifient. +test('les flèches verticales sautent d’une ligne, soit sept pastilles', async () => { + const screen = await render(); + const all = swatches(screen.container); + + all[0]!.focus(); + touche(all[0]!, 'ArrowDown'); + await expect.poll(() => document.activeElement).toBe(all[7]); + + touche(all[7]!, 'ArrowUp'); + await expect.poll(() => document.activeElement).toBe(all[0]); +}); + +test('Début et Fin vont à la première et à la dernière pastille', async () => { + const screen = await render(); + const all = swatches(screen.container); + + all[1]!.focus(); + touche(all[1]!, 'End'); + await expect.poll(() => document.activeElement).toBe(all[2]); + + touche(all[2]!, 'Home'); + await expect.poll(() => document.activeElement).toBe(all[0]); +}); + +test('l’arrêt de tabulation suit le focus', async () => { + const screen = await render(); + const all = swatches(screen.container); + + all[2]!.focus(); + + await expect.poll(() => swatches(screen.container).map((s) => s.tabIndex)).toEqual([-1, -1, 0]); +}); + +// --- Popup ----------------------------------------------------------------- +function PopupHost(props: { onSelect?: (s: UiSwatch | null) => void; defaultOpen?: boolean }) { + return ( + } + /> + ); +} + +test('fermé, le panneau n’occupe aucune place', async () => { + const screen = await render(); + const el = panel(screen.container); + + expect(el).toHaveAttribute('popover', 'manual'); + expect(el.matches(':popover-open')).toBe(false); + expect(getComputedStyle(el).display).toBe('none'); +}); + +test('le déclencheur annonce le popup, et l’ouvre', async () => { + const screen = await render(); + const bouton = screen.container.querySelector('button')!; + + expect(bouton).toHaveAttribute('aria-haspopup', 'listbox'); + expect(bouton).toHaveAttribute('aria-expanded', 'false'); + + await screen.getByRole('button', { name: 'Couleur' }).click(); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(true); + expect(screen.container.querySelector('button')).toHaveAttribute('aria-expanded', 'true'); + expect(screen.container.querySelector('button')!.getAttribute('aria-controls')).toBe( + panel(document.body).id, + ); +}); + +// Le panneau vit dans le calque supérieur : aucun ancêtre en `overflow: hidden` +// ne le rogne, et aucun z-index n'a à être arbitré. +test('le panneau ouvert échappe au rognage d’un ancêtre', async () => { + await render( +
+ +
, + ); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(true); + await expect.poll(() => panel(document.body).getBoundingClientRect().width).toBeGreaterThan(80); +}); + +test('à l’ouverture, le focus part sur l’arrêt de tabulation', async () => { + const screen = await render(); + + await screen.getByRole('button', { name: 'Couleur' }).click(); + + await expect + .poll(() => (document.activeElement as HTMLElement | null)?.dataset.key) + .toBe('rouge'); +}); + +// Choisir ferme et REND le focus : un `popover="manual"` ne le fait pas seul, +// contrairement à un `auto`. +test('choisir une pastille referme et rend le focus au déclencheur', async () => { + const onSelect = vi.fn(); + const screen = await render(); + + await screen.getByRole('button', { name: 'Couleur' }).click(); + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(true); + + await screen.getByRole('option', { name: 'Vert' }).click(); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(false); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ key: 'vert' })); + await expect.poll(() => document.activeElement).toBe(screen.container.querySelector('button')); +}); + +test('Échap referme et rend le focus', async () => { + const screen = await render(); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(true); + touche(document.activeElement as HTMLElement, 'Escape'); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(false); + await expect.poll(() => document.activeElement).toBe(screen.container.querySelector('button')); +}); + +test('un clic à côté referme', async () => { + const screen = await render( + <> + + + , + ); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(true); + + await screen.getByRole('button', { name: 'Ailleurs' }).click(); + + await expect.poll(() => panel(document.body).matches(':popover-open')).toBe(false); +}); + +test('hors popup, le panneau est posé dans la page', async () => { + const screen = await render(); + const el = panel(screen.container); + + expect(el.hasAttribute('popover')).toBe(false); + expect(getComputedStyle(el).position).not.toBe('fixed'); +}); + +test('la densité small pose son modifieur', async () => { + const petit = await render(); + const normal = await render(); + + expect(panel(petit.container)).toHaveClass('_small'); + expect(panel(normal.container)).not.toHaveClass('_small'); + expect(swatches(petit.container)[0]!.getBoundingClientRect().width).toBeLessThan( + swatches(normal.container)[0]!.getBoundingClientRect().width, + ); +}); diff --git a/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.tsx b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.tsx new file mode 100644 index 0000000..22cda41 --- /dev/null +++ b/packages/ui-kit-react/src/forms/ui-swatch-picker/ui-swatch-picker.tsx @@ -0,0 +1,428 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithRef, + type KeyboardEvent, + type ReactNode, + type Ref, +} from 'react'; + +import { UiIcon } from '../../base/ui-icon'; +import { useControllableState } from '../../core/forms'; +import { useCloseOnNavigation, useUiDismiss, useUiPosition } from '../../core/overlay'; +import { cx } from '../../core/utils'; + +import './ui-swatch-picker.scss'; + +export type SwatchPickerSize = 'default' | 'small'; + +/** Une couleur choisissable. */ +export interface UiSwatch { + /** Identifiant stable, qui est aussi la valeur portée par `value`. */ + key: string; + /** Variable CSS qui peint la pastille, par exemple `--primitives-red-500`. */ + cssVar: string; + /** Nom accessible. Jamais la seule couleur : voyants et non voyants doivent lire pareil. */ + label: string; +} + +/** Une section titrée de la palette. */ +export interface UiSwatchGroup { + label: string; + /** Les pastilles de la section, ligne par ligne. */ + swatches: UiSwatch[]; +} + +/** Props posées sur le déclencheur du popup, telles que `trigger` les reçoit. */ +export interface UiSwatchPickerTriggerProps { + ref: Ref; + 'aria-haspopup': 'listbox'; + 'aria-expanded': boolean; + 'aria-controls': string | undefined; + onClick: () => void; +} + +/** Une entrée aplatie, vraie pastille ou pastille « aucune couleur ». */ +interface FlatSwatch { + key: string | null; + cssVar: string | null; + label: string; +} + +/** Les teintes de la palette par défaut, en colonnes. */ +const DEFAULT_HUES: { key: string; label: string }[] = [ + { key: 'primary', label: 'Primaire' }, + { key: 'secondary', label: 'Secondaire' }, + { key: 'green', label: 'Vert' }, + { key: 'orange', label: 'Orange' }, + { key: 'red', label: 'Rouge' }, + { key: 'slate', label: 'Ardoise' }, + { key: 'grey', label: 'Gris' }, +]; +const DEFAULT_STEPS = [900, 700, 500, 300, 100]; + +/** + * La palette par défaut : 7 teintes sur 5 nuances, du foncé au clair, plus le + * noir et le blanc. Jamais une valeur en dur : chaque pastille pointe une + * variable `--primitives-*`, donc changer de marque change la grille sans + * toucher au code. + */ +export const DEFAULT_SWATCH_PALETTE: UiSwatchGroup[] = [ + { + label: 'Couleurs', + swatches: [ + ...DEFAULT_STEPS.flatMap((step) => + DEFAULT_HUES.map((hue): UiSwatch => ({ + key: `${hue.key}-${step}`, + cssVar: `--primitives-${hue.key}-${step}`, + label: `${hue.label} ${step}`, + })), + ), + { key: 'black', cssVar: '--primitives-black-base', label: 'Noir' }, + { key: 'white', cssVar: '--primitives-white-base', label: 'Blanc' }, + ], + }, +]; + +/** Colonnes de la grille, calées sur les 7 teintes de la palette par défaut. */ +const GRID_COLUMNS = 7; + +/** Écart entre le déclencheur et le panneau, aligné sur `$overlay-panel-offset`. */ +const PANEL_OFFSET = 8; + +export interface UiSwatchPickerProps extends Omit< + ComponentPropsWithRef<'div'>, + 'children' | 'defaultValue' | 'onChange' +> { + /** La palette, en sections de pastilles. */ + palette?: UiSwatchGroup[]; + /** Pastille choisie, par sa clé. `null` veut dire « aucune couleur ». */ + value?: string | null; + defaultValue?: string | null; + onValueChange?: (key: string | null) => void; + /** Notifié au choix de l'utilisateur, avec la pastille entière. */ + onSwatchSelect?: (swatch: UiSwatch | null) => void; + /** Mode popup : le panneau vit dans le calque supérieur, ancré au déclencheur. */ + popup?: boolean; + /** Ouverture imposée du popup. Renseignée, le panneau est **contrôlé**. */ + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + /** Rend le déclencheur du popup, en lui reversant les props reçues. */ + trigger?: (props: UiSwatchPickerTriggerProps) => ReactNode; + /** Retourner le panneau au-dessus du déclencheur quand la place manque. */ + autoFlip?: boolean; + /** Densité : `small` pour une barre d'outils ou un popup compact. */ + size?: SwatchPickerSize; + /** Rend une pastille « aucune couleur » en tête de grille. */ + allowClear?: boolean; + /** Nom accessible de cette pastille. */ + clearLabel?: string; + /** Nom accessible de la grille. */ + 'aria-label'?: string; + /** Couper l'animation d'ouverture du popup. */ + motionDisabled?: boolean; +} + +/** + * ui-swatch-picker : grille de couleurs, posée dans la page ou en popup. + * + * Chaque pastille est un ` + ); + })} +
+ + ); + + if (!popup) return panel; + + const triggerProps: UiSwatchPickerTriggerProps = { + ref: attachTrigger, + 'aria-haspopup': 'listbox', + 'aria-expanded': isOpen, + 'aria-controls': isOpen ? uid : undefined, + onClick: () => (isOpen ? close() : setOpen(true)), + }; + + return ( + <> + {/* + Faux positif de `react-hooks/refs` : la règle voit un objet contenant + une clé `ref` lu au rendu et suppose une lecture de `.current`. Ici on + TRANSMET une ref de rappel à une prop de rendu. + */} + {/* eslint-disable-next-line react-hooks/refs */} + {trigger?.(triggerProps)} + {panel} + + ); +} diff --git a/packages/ui-kit-react/src/informative/ui-tooltip/ui-tooltip.test.tsx b/packages/ui-kit-react/src/informative/ui-tooltip/ui-tooltip.test.tsx index 97353c5..6f09d64 100644 --- a/packages/ui-kit-react/src/informative/ui-tooltip/ui-tooltip.test.tsx +++ b/packages/ui-kit-react/src/informative/ui-tooltip/ui-tooltip.test.tsx @@ -147,6 +147,11 @@ test('hideOnEscape=false garde la bulle ouverte', async () => { // `life` assez long pour que la fenêtre « ouverte » soit observable : trop // court, le test devient une course entre l'ouverture et la fermeture. +// +// L'attente de fermeture est large à dessein. Sous la charge d'une exécution +// complète, l'ouverture, le compte à rebours et le rendu se disputent le même +// fil : mesuré, ce test tombait une fois sur deux suites avec 2 s, jamais avec +// 5. Ce n'est pas la durée du `life` qu'il vérifie, c'est qu'il ferme seul. test('life referme la bulle même sans quitter le déclencheur', async () => { const onHide = vi.fn(); const screen = await render(); @@ -154,7 +159,7 @@ test('life referme la bulle même sans quitter le déclencheur', async () => { await survol(screen); await expect.poll(() => bulle(screen).matches(':popover-open')).toBe(true); - await expect.poll(() => bulle(screen).matches(':popover-open'), { timeout: 2000 }).toBe(false); + await expect.poll(() => bulle(screen).matches(':popover-open'), { timeout: 5000 }).toBe(false); expect(onHide).toHaveBeenCalled(); }); diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/index.ts b/packages/ui-kit-react/src/layout/ui-bottom-sheet/index.ts new file mode 100644 index 0000000..ecc90bc --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/index.ts @@ -0,0 +1,6 @@ +export { + UiBottomSheet, + type UiBottomSheetProps, + type BottomSheetHeight, + type BottomSheetHeightPreset, +} from './ui-bottom-sheet'; diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.mdx b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.mdx new file mode 100644 index 0000000..e4daea2 --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.mdx @@ -0,0 +1,157 @@ +import { Meta, Canvas, ArgTypes } from '@storybook/addon-docs/blocks'; +import { ConfigTable } from '@sb/blocks/config-table'; +import * as SheetStories from './ui-bottom-sheet.stories'; + + + +# ui-bottom-sheet + +Un panneau qui glisse depuis le bord bas de l'écran. Pensé pour le tactile : on le referme en +le tirant vers le bas, et il peut passer d'un palier à l'autre du même geste. + + + +## API + + + +## Le socle est le `` natif + +Même choix que [`ui-modal`](?path=/docs/components-ui-layout-ui-modal--docs) et +[`ui-drawer`](?path=/docs/components-ui-layout-ui-drawer--docs) : le piège de focus, la +restitution du focus, l'inertie de l'arrière-plan, `Échap` et l'empilement viennent du +navigateur. Le voile et le positionneur que le kit Angular construit à la main disparaissent : +`::backdrop` fait le premier, les insets du dialogue font le second. + +Ce qui reste écrit à la main est ce qui appartient vraiment à ce composant : les paliers de +hauteur, le glissement vers le bas qui referme, et le passage de `half` à `full`. + +
+ +**L'état est la source de vérité, le DOM suit.** Un `` s'ouvre par une **méthode**, pas +par un attribut : `open` posé en JSX rendrait le panneau hors du calque supérieur, sans +arrière-plan et sans piège de focus. + +
+ +## Hauteur + +Trois paliers nommés, `auto`, `half` et `full`, ou n'importe quelle longueur CSS. Un palier +nommé passe par sa **classe**, pour que le crochet SCSS reste maître de sa valeur ; une +longueur libre s'écrit en ligne. + + + +## Le glissement + +Tirer le panneau vers le bas au-delà de `dragThreshold` le referme. C'est une transition sur +`translate` des deux côtés, l'ouverture comme le geste : le glissement **coule** donc dans +l'animation de fermeture au lieu de s'y ajouter. + +Seules la poignée et l'en-tête sont saisissables, jamais le corps, qui garde son défilement +natif. Un contrôle posé dans la zone de préhension, comme le bouton de fermeture, garde son +propre geste. + + + +### Paliers + +`enableSnapping` laisse un panneau `half` grandir jusqu'à `full` quand on le tire vers le haut, +et redescendre ensuite. La poignée devient alors un vrai **bouton**, nommé et assez grand pour +être une cible (WCAG 2.5.8), et les flèches haut et bas font au clavier ce que le doigt fait à +l'écran. + +Depuis `full`, un geste vers le bas redescend d'abord à `half` : il faut un second geste pour +refermer. + + + +## Zones + +Trois zones, disposées en colonne : l'en-tête et le pied restent en place pendant que le corps +défile. + + + + +`autoFocusElement` prend un sélecteur CSS et dirige le focus à l'ouverture, là où le navigateur +choisirait la première cible venue. + + + +## Modal, ou pas + +Sans `modal`, le panneau s'ouvre par `show()` plutôt que `showModal()` : plus de calque +supérieur, donc plus d'arrière-plan assombri, plus de piège de focus, et la page reste +utilisable. + + + +`contained` cantonne le panneau au premier ancêtre positionné, pour l'embarquer dans un cadre +borné, et les paliers deviennent des fractions de ce conteneur. Il est alors **toujours non +modal** : le calque supérieur est relatif à la fenêtre, jamais à un ancêtre. + + + +## Les contraintes de l'appareil + +Le panneau est bâti pour l'écran sur lequel il vit : il réserve l'incrustation du système sous +lui, barre d'accueil iOS et barre de gestes Android, coupe le délai de double frappe et le +flash de tapotement, et garde le défilement, tirer-pour-rafraîchir compris, à l'intérieur de +son corps. + +## Accessibilité + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PointImplémentation
Dialogue natif + Piège de focus, restitution du focus, inertie de l'arrière-plan et Échap{' '} + viennent du navigateur. Échap arrive par cancel, qu'on annule + toujours pour passer par l'état : une seule voie de fermeture. +
Nom + Le titre nomme le panneau par aria-labelledby ; sans titre,{' '} + aria-label prend le relais, et son absence lève un avertissement en + développement. +
Poignée + Décorative et aria-hidden par défaut. En mode palier elle devient un bouton + nommé, portant aria-expanded, manipulable aux flèches. +
Corps défilant + Il ne devient un arrêt de tabulation que s'il déborde et ne contient rien + de focalisable : axe exige qu'une région défilante soit atteignable, mais un arrêt de plus + sur une zone déjà parcourable n'encombre que le clavier. Même contrat que{' '} + ui-modal. +
Mouvement + Entrée et sortie neutralisées sous prefers-reduced-motion et{' '} + data-motion="off", et motionDisabled les coupe pour un panneau. +
+ +## Theming + + diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss new file mode 100644 index 0000000..a88d18c --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.scss @@ -0,0 +1,307 @@ +@use 'utils'; + +// ===================================================================== +// ui-bottom-sheet : co-located styles. All values come from design tokens. +// +// Built on the native ``, like `ui-modal` and `ui-drawer`: the scrim +// and the positioner of the Angular kit disappear, `::backdrop` does the +// first and the dialog's own insets do the second. +// +// Two properties move, and only two: `translate` on the sheet (open, close, +// drag down) and `height`, confined to the optional snap detent. Everything +// else is static. +// ===================================================================== + +// --- Config ----------------------------------------------------------- +$surface: var(--ui-bottom-sheet-surface, var(--global-background-default)); /// Fond du panneau. +$radius: var(--ui-bottom-sheet-radius, var(--radius-default)); /// Rayon des deux coins hauts. +$shadow: var(--ui-bottom-sheet-shadow, var(--shadow-default-lg)); /// Élévation du panneau. +$max-width: var(--ui-bottom-sheet-max-width, #{utils.rem-calc(1440)}); /// Largeur maximale, au-delà de laquelle le panneau se centre. +$mask-background: var(--ui-bottom-sheet-mask-background, var(--primitives-black-50)); /// Assombrissement de l'arrière-plan. + +$half-height: var(--ui-bottom-sheet-half-height, 50dvh); /// Hauteur du palier « moitié d'écran ». +$full-height: var(--ui-bottom-sheet-full-height, 100dvh); /// Hauteur du palier « plein écran ». +$max-height: var(--ui-bottom-sheet-max-height, 92dvh); /// Plafond de hauteur en mode `auto`, qui laisse voir la page derrière. + +// Réserve du système en bas de l'écran : barre d'accueil iOS, gestes Android. +// Vaut zéro partout ailleurs, chrome du navigateur compris. +$safe-area: var(--ui-bottom-sheet-safe-area-offset, env(safe-area-inset-bottom, 0px)); /// Réserve sous le panneau pour l'incrustation système. + +$panel-padding: var(--ui-bottom-sheet-padding, var(--units-default)); /// Retrait horizontal du panneau, et retrait haut du corps. +$panel-padding-bottom: var(--ui-bottom-sheet-padding-bottom, var(--units-lg)); /// Retrait bas de la dernière zone du panneau. +$header-gap: var(--ui-bottom-sheet-header-gap, var(--units-sm)); /// Espacement entre le titre et la fermeture. +$header-align: var(--ui-bottom-sheet-header-align, flex-start); /// Alignement vertical de l'en-tête. +$action-align: var(--ui-bottom-sheet-action-align, auto); /// Alignement vertical de la fermeture seule ; `auto` suit l'en-tête. +$footer-gap: var(--ui-bottom-sheet-footer-gap, var(--units-default)); /// Espacement entre les actions du pied. +$footer-padding-top: var(--ui-bottom-sheet-footer-padding-top, var(--units-sm)); /// Retrait haut du pied. +$action-size: var(--ui-bottom-sheet-action-size, #{utils.rem-calc(32)}); /// Taille de la cible tactile de la fermeture. + +// Le filet du pied est éteint dans le dessin de référence ; un projet le +// rallume en donnant une valeur au crochet de couleur. +$footer-stroke: var(--ui-bottom-sheet-footer-stroke, transparent); /// Couleur du filet de séparation du pied, absent par défaut. +$footer-stroke-width: var(--ui-bottom-sheet-footer-stroke-width, var(--stroke-sm)); /// Épaisseur du filet de séparation du pied. + +$grab-padding-y: var(--ui-bottom-sheet-grab-padding-y, var(--units-sm)); /// Retrait vertical de la zone de préhension. +$grab-padding-x: var(--ui-bottom-sheet-grab-padding-x, var(--units-md)); /// Retrait horizontal de la zone de préhension. + +$handle-width: var(--ui-bottom-sheet-handle-width, #{utils.rem-calc(54)}); /// Largeur de la barre de préhension. +$handle-height: var(--ui-bottom-sheet-handle-height, #{utils.rem-calc(4)}); /// Épaisseur de la barre de préhension. +$handle-radius: var(--ui-bottom-sheet-handle-radius, var(--radius-full)); /// Rayon de la barre de préhension. +$handle-color: var(--ui-bottom-sheet-handle-color, var(--global-text-default)); /// Couleur de la barre de préhension. +// Seul le mode palier fait de la poignée un bouton ; il lui faut alors une +// cible assez grande pour être manipulable (WCAG 2.5.8), ce que la barre seule +// n'est pas. +$handle-target-width: var(--ui-bottom-sheet-handle-target-width, #{utils.rem-calc(64)}); /// Largeur de la cible tactile de la poignée manipulable. +$handle-target-height: var(--ui-bottom-sheet-handle-target-height, #{utils.rem-calc(24)}); /// Hauteur de la cible tactile de la poignée manipulable. + +$focus-ring-width: var(--ui-bottom-sheet-focus-ring-width, #{utils.$focus-ring-width}); /// Épaisseur de l'anneau de focus des contrôles du panneau. + +.ui-bottom-sheet { + // --- Base ----------------------------------------------------------- + // Trois insets à zéro et le quatrième à `auto` collent le panneau au bord bas. + // La largeur doit être remise à `auto` explicitement : le style navigateur + // d'un `` la met à `fit-content`, ce qui l'emporterait. + position: fixed; + inset: auto 0 0 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + width: auto; + max-width: $max-width; + max-height: $max-height; + margin-inline: auto; + padding: 0; + border: none; + border-radius: $radius $radius 0 0; + background-color: $surface; + box-shadow: $shadow; + color: var(--ui-bottom-sheet-color, var(--global-text-default)); /// Couleur du texte du panneau. + font-family: var(--ui-bottom-sheet-font-family, var(--fontfamily-base)); /// Famille typographique du composant. + overflow: hidden; + outline: none; + // Un panneau est une surface tactile : ni délai de double frappe, ni flash. + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; + + // Le `display: none` d'un dialogue fermé vient du style NAVIGATEUR, que le + // `display: flex` ci-dessus bat. Il faut donc le redire, sinon le panneau + // fermé reste affiché en bas de l'écran. + &:not([open]) { + display: none; + } + + // Entrée et sortie, par le mixin partagé du système de motion. La hauteur + // suit à part, pour le seul changement de palier. + @include utils.overlay-motion((opacity, translate, height)) { + opacity: 0; + translate: 0 100%; + } + + &::backdrop { + background-color: $mask-background; + opacity: 1; + transition: + display allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), + overlay allow-discrete var(--ui-motion-duration, #{utils.$motion-duration-base}), + opacity var(--ui-motion-duration, #{utils.$motion-duration-base}); + } + + &:not([open])::backdrop { + opacity: 0; + } + + @starting-style { + &[open]::backdrop { + opacity: 0; + } + } + + // Panneau non modal : pas de calque supérieur, donc rien à assombrir. + &:not(:modal)::backdrop { + background: none; + } + + // --- Detents -------------------------------------------------------- + &._half { + height: $half-height; + } + + &._full { + height: $full-height; + max-height: $full-height; + } + + // Le glissement suit le doigt : aucune transition ne doit s'y interposer. + // Et tirer vers `full` ne doit pas être rogné par le plafond du mode `auto`. + &._dragging { + max-height: $full-height; + transition: none; + } + + // Cantonné dans un ancêtre positionné : les paliers suivent le conteneur. + &._contained { + position: absolute; + max-height: 92%; + + &._half { + height: 50%; + } + + &._full { + height: 100%; + max-height: 100%; + } + + &._dragging { + max-height: 100%; + } + } + + // Dégage la dernière rangée de la barre d'accueil et des gestes système. + &._safe-area { + padding-bottom: $safe-area; + } + + // --- Grab zone (handle and header) ---------------------------------- + &-grab { + flex: 0 0 auto; + display: flex; + flex-direction: column; + padding: $grab-padding-y $grab-padding-x; + + // Le geste possède l'axe vertical ici : le navigateur ne doit donc ni + // défiler ni déclencher son tirer-pour-rafraîchir depuis cette zone. + &._draggable { + touch-action: none; + user-select: none; + cursor: grab; + + &:active { + cursor: grabbing; + } + } + } + + &-handle { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + width: $handle-width; + height: $handle-height; + margin-inline: auto; + padding: 0; + border: none; + border-radius: $handle-radius; + background: none; + + &::after { + content: ''; + display: block; + width: $handle-width; + height: $handle-height; + border-radius: $handle-radius; + background-color: $handle-color; + } + + &._operable { + width: $handle-target-width; + height: $handle-target-height; + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 $focus-ring-width + var(--ui-bottom-sheet-handle-focus-ring-color, var(--global-border-focus)); /// Couleur de l'anneau de focus de la poignée. + } + } + } + + // --- Header --------------------------------------------------------- + &-header { + flex: 0 0 auto; + display: flex; + align-items: $header-align; + gap: $header-gap; + padding-top: $grab-padding-y; + } + + // Pas de poignée au-dessus : l'en-tête est la première rangée, son propre + // retrait suffit. + &-grab > &-header:first-child { + padding-top: 0; + } + + &-title { + flex: 1 1 auto; + min-width: 0; + margin: 0; + font-family: var(--ui-bottom-sheet-title-font-family, var(--fontfamily-title)); /// Famille typographique du titre. + font-size: var(--size-typography-title-sm); + font-weight: var(--ui-bottom-sheet-weight, var(--weight-bold)); /// Graisse du titre. + line-height: 1.3; + overflow-wrap: break-word; + } + + // Bouton d'icône natif, et non un `ui-button` : on le garde léger. + &-action { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + align-self: $action-align; + justify-content: center; + box-sizing: border-box; + width: $action-size; + height: $action-size; + padding: 0; + border: none; + border-radius: var(--radius-xs); + background: transparent; + color: inherit; + line-height: 0; + cursor: pointer; + @include utils.control-transition(background-color, color); + + &:hover { + background-color: var( + --ui-bottom-sheet-close-surface-hover, + var(--global-background-muted-hover) + ); /// Fond de la fermeture au survol. + color: var(--ui-bottom-sheet-close-color-hover, var(--global-text-muted-hover)); /// Couleur de la fermeture au survol. + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 $focus-ring-width + var(--ui-bottom-sheet-close-focus-ring-color, var(--global-border-focus)); /// Couleur de l'anneau de focus de la fermeture. + } + + @include utils.motion-reduce { + transition-duration: 0.01ms; + } + } + + // --- Content -------------------------------------------------------- + &-content { + flex: 1 1 auto; + min-height: 0; + padding: $panel-padding $panel-padding $panel-padding-bottom; + overflow-y: auto; + // Garde le défilement, et le tirer-pour-rafraîchir, à l'intérieur. + overscroll-behavior: contain; + font-size: var(--size-typography-text-md); + line-height: 1.5; + } + + // --- Footer --------------------------------------------------------- + &-footer { + flex: 0 0 auto; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: $footer-gap; + padding: $footer-padding-top $panel-padding $panel-padding-bottom; + border-top: $footer-stroke-width solid $footer-stroke; + } +} diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.stories.tsx b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.stories.tsx new file mode 100644 index 0000000..06fb0d1 --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.stories.tsx @@ -0,0 +1,222 @@ +import { useState, type CSSProperties, type ReactNode } from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { UiButton } from '../../actions/ui-button'; +import { UiInput } from '../../forms/ui-input'; + +import { UiBottomSheet, type UiBottomSheetProps } from './ui-bottom-sheet'; + +/** Cadre positionné : `contained` y ancre le panneau, qui reste dans le canevas. */ +const PHONE: CSSProperties = { + position: 'relative', + width: 340, + height: 420, + overflow: 'hidden', + border: '1px solid var(--global-border-default)', + borderRadius: 'var(--radius-lg)', + background: 'var(--global-background-muted)', +}; + +/** + * Chaque exemple ouvre son propre panneau. Il se pose au bas du canevas de la + * story, qui est une fenêtre à lui seul : c'est bien le panneau modal réel, avec + * son arrière-plan et son piège de focus. + */ +function Demo({ + label = 'Ouvrir', + children, + ...props +}: Partial & { label?: string; children?: ReactNode }) { + const [open, setOpen] = useState(false); + + return ( + <> + setOpen(true)} /> + + {children} + + + ); +} + +const meta: Meta = { + title: 'Components/ui/layout/ui-bottom-sheet', + component: UiBottomSheet, + args: { + height: 'auto', + modal: true, + dismissableMask: true, + closeOnEscape: true, + enableDragToClose: true, + dragThreshold: 96, + enableSnapping: false, + showHandle: true, + closable: false, + safeArea: true, + }, + argTypes: { + height: { control: 'text' }, + modal: { control: 'boolean' }, + dismissableMask: { control: 'boolean' }, + closeOnEscape: { control: 'boolean' }, + enableDragToClose: { control: 'boolean' }, + dragThreshold: { control: 'number' }, + enableSnapping: { control: 'boolean' }, + showHandle: { control: 'boolean' }, + closable: { control: 'boolean' }, + safeArea: { control: 'boolean' }, + visible: { control: false }, + children: { control: false }, + }, + parameters: { + layout: 'centered', + design: { + type: 'figma', + url: 'https://www.figma.com/design/GZww5hdUA49LB8XWeWP6tl/-Projet----UI-Kit?node-id=269-2273', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** Le cas nominal : un titre, un corps, et la hauteur qui épouse le contenu. */ +export const Basic: Story = { + render: (args) => ( + + Choisissez une application pour partager ce contenu. + + ), +}; + +/** Les trois paliers, plus n'importe quelle longueur CSS. */ +export const Heights: Story = { + render: (args) => ( +
+ + La hauteur épouse le contenu, sous un plafond qui laisse voir la page. + + + La moitié de la hauteur du conteneur. + + + Toute la hauteur du conteneur. + + + N'importe quelle longueur CSS fait un palier. + +
+ ), +}; + +/** `autoFocusElement` dirige le focus sur le champ plutôt que sur la première cible. */ +export const Search: Story = { + render: (args) => ( + + + + ), +}; + +/** Un pied d'actions, qui reste en place pendant que le corps défile. */ +export const Actions: Story = { + render: (args) => ( + + + + + } + > + Cette action est définitive. + + ), +}; + +/** Le corps défile seul : l'en-tête et le pied ne bougent pas. */ +export const ScrollingContent: Story = { + render: (args) => ( + +
    + {Array.from({ length: 30 }, (_, index) => ( +
  • + Opération numéro {index + 1} +
  • + ))} +
+
+ ), +}; + +/** Un panneau `half` qu'on tire vers le haut pour le passer à `full`, et retour. */ +export const Snapping: Story = { + render: (args) => ( + + Tirez la poignée vers le haut pour agrandir le panneau, vers le bas pour le réduire. Au + clavier, les flèches haut et bas font la même chose. + + ), +}; + +/** Sans arrière-plan : le panneau ne bloque plus la page derrière lui. */ +export const NoBackdrop: Story = { + render: (args) => ( + + La page reste utilisable pendant que le panneau est ouvert. + + ), +}; + +/** Sans poignée ni glissement : le panneau ne se ferme que par ses contrôles. */ +export const NoDrag: Story = { + render: (args) => ( + } + > + Ce panneau ne se referme ni au glissement ni au clic à côté. + + ), +}; + +/** + * `contained` cantonne le panneau au premier ancêtre positionné, pour l'embarquer + * dans un cadre borné. Il devient alors **non modal** : pas d'arrière-plan + * assombri, pas de piège de focus, pas de blocage du défilement, puisque le + * calque supérieur est toujours relatif à la fenêtre. + */ +function ContainedDemo(args: Partial) { + const [open, setOpen] = useState(true); + + return ( +
+
+ setOpen(true)} /> +
+ + Le panneau se pose au bas de ce cadre, et non au bas de la fenêtre. + +
+ ); +} + +export const Contained: Story = { + args: { height: 'half', closable: true }, + render: (args) => , +}; diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.test.tsx b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.test.tsx new file mode 100644 index 0000000..e231c1a --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.test.tsx @@ -0,0 +1,374 @@ +import { useState } from 'react'; +import { expect, test, vi } from 'vitest'; +import { render } from 'vitest-browser-react'; + +import { UiBottomSheet, type UiBottomSheetProps } from './ui-bottom-sheet'; + +// `children` est repris explicitement : en JSX, ce qui est écrit entre les +// balises l'emporte sur un `children` passé par étalement, et le contenu de +// l'appelant serait silencieusement remplacé. +function Host({ children = 'Contenu du panneau', ...props }: Partial) { + return ( + + {children} + + ); +} + +const sheet = (root: ParentNode) => root.querySelector('.ui-bottom-sheet')!; +const grab = (root: ParentNode) => root.querySelector('.ui-bottom-sheet-grab')!; +const handle = (root: ParentNode) => root.querySelector('.ui-bottom-sheet-handle')!; + +/** Un geste complet sur la zone de préhension, en pixels verticaux. */ +function glisser(zone: HTMLElement, from: number, to: number) { + const init = { bubbles: true, cancelable: true, pointerId: 1, clientX: 100 }; + zone.dispatchEvent(new PointerEvent('pointerdown', { ...init, clientY: from })); + zone.dispatchEvent(new PointerEvent('pointermove', { ...init, clientY: to })); + zone.dispatchEvent(new PointerEvent('pointerup', { ...init, clientY: to })); +} + +// --- Ouverture ------------------------------------------------------------- +// L'état est la source de vérité, et un `` s'ouvre par une MÉTHODE : +// l'attribut `open` posé en JSX rendrait le panneau hors du calque supérieur. +test('le panneau modal s’ouvre dans le calque supérieur', async () => { + const screen = await render(); + + await expect.poll(() => sheet(screen.container).open).toBe(true); + expect(sheet(screen.container).matches(':modal')).toBe(true); + expect(sheet(screen.container).tagName).toBe('DIALOG'); +}); + +test('fermé, le panneau n’occupe aucune place', async () => { + const screen = await render(); + + expect(sheet(screen.container).open).toBe(false); + expect(getComputedStyle(sheet(screen.container)).display).toBe('none'); +}); + +test('le titre nomme le panneau', async () => { + const screen = await render(); + const el = sheet(screen.container); + + expect(el.getAttribute('aria-labelledby')).toBe( + screen.container.querySelector('.ui-bottom-sheet-title')!.id, + ); + expect(el).not.toHaveAttribute('aria-label'); +}); + +test('sans titre, aria-label prend le relais', async () => { + const screen = await render(); + + expect(sheet(screen.container)).toHaveAttribute('aria-label', 'Options'); + expect(sheet(screen.container)).not.toHaveAttribute('aria-labelledby'); +}); + +// Non modal : pas de calque supérieur, donc pas d'arrière-plan à assombrir. +test('modal=false ouvre le panneau sans calque supérieur', async () => { + const screen = await render(); + + await expect.poll(() => sheet(screen.container).open).toBe(true); + expect(sheet(screen.container).matches(':modal')).toBe(false); + expect(getComputedStyle(sheet(screen.container), '::backdrop').backgroundColor).toBe( + 'rgba(0, 0, 0, 0)', + ); +}); + +test('contained pose le panneau dans son ancêtre, et le rend non modal', async () => { + const screen = await render( +
+ +
, + ); + + await expect.poll(() => sheet(screen.container).open).toBe(true); + expect(sheet(screen.container).matches(':modal')).toBe(false); + expect(getComputedStyle(sheet(screen.container)).position).toBe('absolute'); + expect(sheet(screen.container)).toHaveClass('_contained'); +}); + +// --- Fermeture ------------------------------------------------------------- +test('le bouton de fermeture referme', async () => { + const onVisibleChange = vi.fn(); + const screen = await render(); + + await screen.getByRole('button', { name: 'Fermer' }).click(); + + await expect.poll(() => sheet(screen.container).open).toBe(false); + expect(onVisibleChange).toHaveBeenLastCalledWith(false); +}); + +// Échap arrive par `cancel`, annulable : on l'annule toujours et on passe par +// l'état, pour n'avoir qu'une seule voie de fermeture. +test('Échap referme, et closeOnEscape=false l’en empêche', async () => { + const screen = await render(); + sheet(screen.container).dispatchEvent(new Event('cancel', { cancelable: true })); + await expect.poll(() => sheet(screen.container).open).toBe(false); + + const colle = await render(); + sheet(colle.container).dispatchEvent(new Event('cancel', { cancelable: true })); + await new Promise((r) => setTimeout(r, 40)); + expect(sheet(colle.container).open).toBe(true); +}); + +// Le panneau EST le dialogue : un clic dont la cible est le dialogue lui-même +// vient donc de l'arrière-plan, jamais du contenu. +test('le clic sur l’arrière-plan referme, et dismissableMask=false l’en empêche', async () => { + const screen = await render(); + sheet(screen.container).click(); + await expect.poll(() => sheet(screen.container).open).toBe(false); + + const colle = await render(); + sheet(colle.container).click(); + await new Promise((r) => setTimeout(r, 40)); + expect(sheet(colle.container).open).toBe(true); +}); + +test('un clic dans le contenu ne referme pas', async () => { + const screen = await render( + + + , + ); + + await screen.getByRole('button', { name: 'Action' }).click(); + + await new Promise((r) => setTimeout(r, 40)); + expect(sheet(screen.container).open).toBe(true); +}); + +// --- Paliers --------------------------------------------------------------- +test('les paliers nommés passent par leur classe', async () => { + const moitie = await render(); + const plein = await render(); + + expect(sheet(moitie.container)).toHaveClass('_half'); + expect(sheet(plein.container)).toHaveClass('_full'); + expect(sheet(moitie.container).style.height).toBe(''); +}); + +// Une longueur libre ne peut pas passer par une classe : elle est écrite en +// ligne, et le crochet SCSS n'a alors rien à dire. +test('une longueur CSS libre est écrite en ligne', async () => { + const screen = await render(); + + expect(sheet(screen.container).style.height).toBe('240px'); + expect(sheet(screen.container).className).not.toMatch(/_auto|_half|_full/); +}); + +test('auto laisse la hauteur épouser le contenu, sous un plafond', async () => { + const screen = await render(); + + expect(sheet(screen.container)).toHaveClass('_auto'); + expect(getComputedStyle(sheet(screen.container)).maxHeight).not.toBe('none'); +}); + +// --- Préhension ------------------------------------------------------------ +test('la poignée est décorative sans mode palier', async () => { + const screen = await render(); + + expect(handle(screen.container).tagName).toBe('DIV'); + expect(handle(screen.container)).toHaveAttribute('aria-hidden', 'true'); +}); + +// En mode palier la poignée devient manipulable : elle doit donc être un vrai +// bouton, nommé, et assez grand pour être une cible (WCAG 2.5.8). +test('en mode palier, la poignée devient un bouton nommé', async () => { + const screen = await render(); + const el = handle(screen.container); + + expect(el.tagName).toBe('BUTTON'); + expect(el).toHaveAttribute('aria-label', 'Redimensionner le panneau'); + expect(el).toHaveAttribute('aria-expanded', 'false'); + expect(el).toHaveClass('_operable'); + expect(el.getBoundingClientRect().height).toBeGreaterThanOrEqual(24); +}); + +test('showHandle=false retire la poignée', async () => { + const screen = await render(); + + expect(screen.container.querySelector('.ui-bottom-sheet-handle')).toBeNull(); +}); + +// Le geste possède l'axe vertical de la zone de préhension : sans ça le +// navigateur défilerait ou déclencherait son tirer-pour-rafraîchir. +test('la zone de préhension prend l’axe vertical', async () => { + const screen = await render(); + + expect(grab(screen.container)).toHaveClass('_draggable'); + expect(getComputedStyle(grab(screen.container)).touchAction).toBe('none'); +}); + +test('sans glissement ni palier, la zone n’est plus saisissable', async () => { + const screen = await render(); + + expect(grab(screen.container)).not.toHaveClass('_draggable'); +}); + +// --- Glissement ------------------------------------------------------------ +test('un glissement vers le bas au-delà du seuil referme', async () => { + const screen = await render(); + + glisser(grab(screen.container), 100, 200); + + await expect.poll(() => sheet(screen.container).open).toBe(false); +}); + +test('un glissement trop court ramène le panneau en place', async () => { + const screen = await render(); + + glisser(grab(screen.container), 100, 130); + + await new Promise((r) => setTimeout(r, 60)); + expect(sheet(screen.container).open).toBe(true); + expect(sheet(screen.container).style.translate).toBe(''); +}); + +test('enableDragToClose=false ne referme pas au glissement', async () => { + const screen = await render(); + + glisser(grab(screen.container), 100, 300); + + await new Promise((r) => setTimeout(r, 60)); + expect(sheet(screen.container).open).toBe(true); +}); + +// Un contrôle posé dans la zone de préhension garde son propre geste : sans +// cette garde, le bouton de fermeture ouvrirait un glissement. +test('un contrôle de la zone de préhension garde son geste', async () => { + const screen = await render(); + const fermeture = screen.container.querySelector('.ui-bottom-sheet-action')!; + const init = { bubbles: true, cancelable: true, pointerId: 1, clientX: 100 }; + + fermeture.dispatchEvent(new PointerEvent('pointerdown', { ...init, clientY: 100 })); + grab(screen.container).dispatchEvent(new PointerEvent('pointermove', { ...init, clientY: 300 })); + + expect(sheet(screen.container)).not.toHaveClass('_dragging'); + expect(sheet(screen.container).style.translate).toBe(''); +}); + +// --- Paliers au glissement et au clavier ----------------------------------- +test('tirer vers le haut fait passer un panneau half à full', async () => { + const screen = await render(); + + expect(sheet(screen.container)).toHaveClass('_half'); + + glisser(grab(screen.container), 300, 100); + + await expect.poll(() => sheet(screen.container).className).toContain('_full'); + expect(handle(screen.container)).toHaveAttribute('aria-expanded', 'true'); +}); + +// Depuis `full`, un panneau à palier redescend d'abord à `half` : le geste vers +// le bas ne referme qu'au second coup. +test('depuis full, le glissement vers le bas redescend à half avant de fermer', async () => { + const screen = await render(); + + glisser(grab(screen.container), 300, 100); + await expect.poll(() => sheet(screen.container).className).toContain('_full'); + + glisser(grab(screen.container), 100, 300); + await expect.poll(() => sheet(screen.container).className).toContain('_half'); + expect(sheet(screen.container).open).toBe(true); + + glisser(grab(screen.container), 100, 300); + await expect.poll(() => sheet(screen.container).open).toBe(false); +}); + +test('les flèches font au clavier ce que le glissement fait au doigt', async () => { + const screen = await render(); + const bouton = handle(screen.container); + + bouton.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + await expect.poll(() => sheet(screen.container).className).toContain('_full'); + + bouton.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + await expect.poll(() => sheet(screen.container).className).toContain('_half'); +}); + +// Le palier ne vaut que pour un panneau `half` : un `auto` ou un `full` n'a +// nulle part où grandir. +test('le mode palier ne s’applique qu’à un panneau half', async () => { + const screen = await render(); + + expect(handle(screen.container).tagName).toBe('DIV'); +}); + +test('rouvrir repart du palier de départ', async () => { + function Host2() { + const [open, setOpen] = useState(true); + return ( + <> + + + Contenu + + + ); + } + const screen = await render(); + + glisser(grab(screen.container), 300, 100); + await expect.poll(() => sheet(screen.container).className).toContain('_full'); + + // Le bouton vit HORS du panneau, donc il est inerte tant que celui-ci est + // modal : on referme par Échap, ce qui est de toute façon l'usage. + sheet(screen.container).dispatchEvent(new Event('cancel', { cancelable: true })); + await expect.poll(() => sheet(screen.container).open).toBe(false); + + await screen.getByRole('button', { name: 'Basculer' }).click(); + + await expect.poll(() => sheet(screen.container).open).toBe(true); + expect(sheet(screen.container).className).toContain('_half'); +}); + +// --- Zones et focus -------------------------------------------------------- +test('le pied n’est rendu que s’il a du contenu', async () => { + const sans = await render(); + const avec = await render(Valider} />); + + expect(sans.container.querySelector('.ui-bottom-sheet-footer')).toBeNull(); + expect(avec.container.querySelector('.ui-bottom-sheet-footer')).not.toBeNull(); +}); + +// Le corps défile seul : l'en-tête et le pied ne bougent pas. +test('le corps défile, pas le panneau', async () => { + const screen = await render(); + const corps = screen.container.querySelector('.ui-bottom-sheet-content')!; + + expect(getComputedStyle(corps).overflowY).toBe('auto'); + expect(getComputedStyle(corps).overscrollBehaviorY).toBe('contain'); + expect(getComputedStyle(sheet(screen.container)).overflow).toBe('hidden'); +}); + +test('autoFocusElement dirige le focus à l’ouverture', async () => { + await render( + + + , + ); + + await expect.poll(() => (document.activeElement as HTMLElement | null)?.id).toBe('champ-panneau'); +}); + +test('motionDisabled coupe la durée d’animation', async () => { + const screen = await render(); + + expect(sheet(screen.container).style.getPropertyValue('--ui-motion-duration')).toBe('0ms'); +}); + +test('safeArea réserve l’incrustation système', async () => { + const avec = await render(); + const sans = await render(); + + expect(sheet(avec.container)).toHaveClass('_safe-area'); + expect(sheet(sans.container)).not.toHaveClass('_safe-area'); +}); diff --git a/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.tsx b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.tsx new file mode 100644 index 0000000..f0445ab --- /dev/null +++ b/packages/ui-kit-react/src/layout/ui-bottom-sheet/ui-bottom-sheet.tsx @@ -0,0 +1,505 @@ +'use client'; + +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type ComponentPropsWithRef, + type CSSProperties, + type PointerEvent, + type ReactNode, +} from 'react'; + +import { UiIcon } from '../../base/ui-icon'; +import { useControllableState } from '../../core/forms'; +import { useUiScrollLock } from '../../core/overlay'; +import { cx } from '../../core/utils'; + +import './ui-bottom-sheet.scss'; + +/** Paliers d'ouverture nommés. */ +export type BottomSheetHeightPreset = 'auto' | 'half' | 'full'; + +/** + * Hauteur d'ouverture : un palier nommé, ou n'importe quelle longueur CSS + * (`'70vh'`, `'400px'`). `Record` garde les paliers suggérés par + * l'éditeur tout en acceptant une chaîne libre. + */ +export type BottomSheetHeight = BottomSheetHeightPreset | (string & Record); + +const HEIGHT_PRESETS: readonly string[] = ['auto', 'half', 'full']; + +/** Descendants interactifs qui gardent leur propre geste plutôt que d'ouvrir un glissement. */ +const INTERACTIVE = 'button, a[href], input, select, textarea, [role="button"], [contenteditable]'; +/** La poignée est elle-même un bouton en mode palier, et pourtant c'est ELLE qu'on saisit. */ +const HANDLE_CLASS = 'ui-bottom-sheet-handle'; + +/** Avertissements déjà émis, pour rester idempotent sous ``. */ +const warned = new Set(); + +type NativeProps = Omit< + ComponentPropsWithRef<'dialog'>, + 'open' | 'title' | 'onClose' | 'children' | 'role' +>; + +export interface UiBottomSheetProps extends NativeProps { + /** Ouverture imposée. Renseignée, le panneau est **contrôlé**. */ + visible?: boolean; + defaultVisible?: boolean; + onVisibleChange?: (visible: boolean) => void; + + /** + * Hauteur d'ouverture : `auto` épouse le contenu sous un plafond, `half` la + * moitié de l'écran, `full` l'écran entier, ou n'importe quelle longueur CSS. + */ + height?: BottomSheetHeight; + + /** Arrière-plan assombri, inerte, et défilement bloqué. */ + modal?: boolean; + /** Fermer au clic sur l'arrière-plan. Modal seulement. */ + dismissableMask?: boolean; + /** Fermer sur Échap. */ + closeOnEscape?: boolean; + /** Bloquer le défilement de fond même pour un panneau non modal. */ + blockScroll?: boolean; + + /** Fermer en tirant le panneau vers le bas, au doigt, à la souris ou au stylet. */ + enableDragToClose?: boolean; + /** Distance à parcourir avant que le relâchement ne referme, en px. */ + dragThreshold?: number; + /** Autoriser le passage d'un panneau `half` à `full` en le tirant vers le haut. */ + enableSnapping?: boolean; + /** Afficher la barre de préhension en haut du panneau. */ + showHandle?: boolean; + + /** Sélecteur CSS de l'élément à focaliser à l'ouverture, par exemple `'#recherche'`. */ + autoFocusElement?: string; + + /** Titre. Une chaîne suffit, mais tout nœud est accepté. */ + header?: ReactNode; + /** Zone de pied, typiquement les actions. */ + footer?: ReactNode; + /** Afficher le bouton de fermeture. Absent du dessin de référence, donc éteint. */ + closable?: boolean; + closeIcon?: string; + closeAriaLabel?: string; + /** Nom accessible de la poignée quand elle est manipulable (`enableSnapping`). */ + handleAriaLabel?: string; + + 'aria-label'?: string; + 'aria-labelledby'?: string; + + /** Réserver l'incrustation système sous le panneau : barre d'accueil iOS, gestes Android. */ + safeArea?: boolean; + /** + * Cantonner le panneau au premier ancêtre positionné plutôt qu'à l'écran, et + * ne pas bloquer le défilement. Les paliers deviennent des fractions de ce + * conteneur. + */ + contained?: boolean; + /** Couper l'animation d'ouverture et de fermeture pour ce panneau. */ + motionDisabled?: boolean; + /** Classes permettant de styler l'arrière-plan (`.ma-classe::backdrop`). */ + backdropClassName?: string; + + onShow?: () => void; + onHide?: () => void; + + /** Contenu principal, qui défile quand il déborde. */ + children?: ReactNode; +} + +/** + * ui-bottom-sheet : panneau qui glisse depuis le bord bas de l'écran. + * + * Même socle que `ui-modal` et `ui-drawer`, le `` natif : le piège de + * focus, la restitution du focus, l'inertie de l'arrière-plan et l'empilement + * viennent du navigateur. Ce que ce composant ajoute lui est propre : les + * paliers de hauteur, le glissement vers le bas qui referme, et le passage de + * `half` à `full` en tirant vers le haut. + * + * Le glissement coule dans l'animation de fermeture parce que les deux touchent + * la même propriété, `translate`. + */ +export function UiBottomSheet({ + visible, + defaultVisible = false, + onVisibleChange, + height = 'auto', + modal = true, + dismissableMask = true, + closeOnEscape = true, + blockScroll = true, + enableDragToClose = true, + dragThreshold = 96, + enableSnapping = false, + showHandle = true, + autoFocusElement, + header, + footer, + closable = false, + closeIcon = 'xmark', + closeAriaLabel = 'Fermer', + handleAriaLabel = 'Redimensionner le panneau', + safeArea = true, + contained = false, + motionDisabled = false, + backdropClassName, + onShow, + onHide, + className, + style, + children, + ref, + ...rest +}: UiBottomSheetProps) { + const innerRef = useRef(null); + const uid = useId(); + const titleId = `${uid}-title`; + + const [open, setOpen] = useControllableState({ + value: visible, + defaultValue: defaultVisible, + onChange: onVisibleChange, + }); + + /** Le panneau `half` est momentanément monté au palier `full`. */ + const [snapped, setSnapped] = useState(false); + /** Distance de glissement vers le bas, en px. */ + const [dragOffset, setDragOffset] = useState(0); + /** Hauteur vive pendant qu'on tire un panneau à palier vers le haut, en px. */ + const [dragHeight, setDragHeight] = useState(null); + const [dragging, setDragging] = useState(false); + + /** + * L'état vif du geste. + * + * Une valeur lue par deux gestionnaires d'un même geste est une **ref**, + * jamais un état : `pointermove` et `pointerup` peuvent arriver dans la même + * tâche, et le second lirait alors l'état d'avant le premier. Les états + * `dragOffset` et `dragHeight` ne servent qu'au RENDU. + */ + const gesture = useRef<{ + pointerId: number; + startY: number; + startHeight: number; + offset: number; + height: number | null; + } | null>(null); + + const contentRef = useRef(null); + const [contentNeedsFocus, setContentNeedsFocus] = useState(false); + + const isModalLayer = modal && !contained; + useUiScrollLock(open && !contained && (modal || blockScroll)); + + const ariaLabel = rest['aria-label']; + const ariaLabelledBy = rest['aria-labelledby']; + const labelledBy = ariaLabelledBy ?? (header ? titleId : undefined); + + /** Le palier `half` peut grandir jusqu'à `full` d'un glissement. */ + const canSnap = enableSnapping && height === 'half'; + const dragEnabled = enableDragToClose || canSnap; + const effectiveHeight = canSnap && snapped ? 'full' : height; + const preset = HEIGHT_PRESETS.includes(effectiveHeight) + ? (effectiveHeight as BottomSheetHeightPreset) + : null; + + const close = useCallback(() => setOpen(false), [setOpen]); + + // L'ÉTAT est la source de vérité, le DOM suit. Un `` s'ouvre par une + // méthode et non par un attribut : `open` posé en JSX rendrait le panneau sans + // calque supérieur, sans arrière-plan et sans piège de focus. + useEffect(() => { + const dialog = innerRef.current; + if (!dialog) return; + + if (open && !dialog.open) { + setSnapped(false); + setDragOffset(0); + setDragHeight(null); + if (isModalLayer) dialog.showModal(); + else dialog.show(); + onShow?.(); + } else if (!open && dialog.open) { + dialog.close(); + onHide?.(); + } + // `onShow` et `onHide` hors dépendances : recréés à chaque rendu, ils + // rejoueraient l'effet en boucle. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, isModalLayer]); + + useEffect(() => { + const dialog = innerRef.current; + if (!dialog) return; + + const onClose = () => setOpen(false); + + const onCancel = (event: Event) => { + // Échap arrive par `cancel`, annulable. On l'annule toujours et on passe + // par l'état : une seule voie de fermeture. + event.preventDefault(); + if (closeOnEscape) setOpen(false); + }; + + const onClick = (event: Event) => { + if (!isModalLayer || !dismissableMask) return; + // Le panneau EST le dialogue : un clic dont la cible est le dialogue + // lui-même vient donc de l'arrière-plan, jamais du contenu. + if (event.target === dialog) setOpen(false); + }; + + dialog.addEventListener('close', onClose); + dialog.addEventListener('cancel', onCancel); + dialog.addEventListener('click', onClick); + return () => { + dialog.removeEventListener('close', onClose); + dialog.removeEventListener('cancel', onCancel); + dialog.removeEventListener('click', onClick); + }; + }); + + /** + * Focus dirigé à l'ouverture. + * + * Une seule tentative suffit : les enfants sont rendus avant que l'effet ne + * tire, là où la version Angular doit réessayer parce que son contenu projeté + * arrive un tour plus tard. + */ + useEffect(() => { + if (!open || !autoFocusElement) return; + const target = innerRef.current?.querySelector(autoFocusElement); + if (target) { + target.focus({ preventScroll: true }); + return; + } + if (process.env.NODE_ENV !== 'production') { + console.warn(`[ui-bottom-sheet] autoFocusElement "${autoFocusElement}" ne matche rien.`); + } + }, [open, autoFocusElement]); + + // Le corps n'est un arrêt de tabulation que s'il défile ET n'offre rien + // d'autre à atteindre : mesuré, jamais supposé. + useEffect(() => { + const element = contentRef.current; + if (!element || typeof ResizeObserver === 'undefined') return; + + const measure = () => { + const overflows = element.scrollHeight > element.clientHeight + 1; + const focusable = element.querySelector( + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + setContentNeedsFocus(overflows && !focusable); + }; + + const observer = new ResizeObserver(measure); + observer.observe(element); + measure(); + return () => observer.disconnect(); + }, [open]); + + useEffect(() => { + if (process.env.NODE_ENV === 'production') return; + if (open && !labelledBy && !ariaLabel && !warned.has(uid)) { + warned.add(uid); + console.warn( + '[ui-bottom-sheet] Panneau sans nom accessible : renseignez `header`, `aria-label` ou `aria-labelledby`.', + ); + } + }, [open, labelledBy, ariaLabel, uid]); + + // --- Le glissement ------------------------------------------------------- + + const onPointerDown = (event: PointerEvent) => { + if (!dragEnabled || gesture.current) return; + if (event.pointerType === 'mouse' && event.button !== 0) return; + // Un contrôle posé dans la zone de préhension garde son propre geste, bouton + // de fermeture ou lien. Sauf la poignée, qui est un bouton en mode palier. + const control = (event.target as Element | null)?.closest(INTERACTIVE); + if (control && !control.classList.contains(HANDLE_CLASS)) return; + + gesture.current = { + pointerId: event.pointerId, + startY: event.clientY, + startHeight: innerRef.current?.offsetHeight ?? 0, + offset: 0, + height: null, + }; + setDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const onPointerMove = (event: PointerEvent) => { + const current = gesture.current; + if (!current || event.pointerId !== current.pointerId) return; + const delta = event.clientY - current.startY; + + if (delta >= 0) { + // Vers le bas : on translate le panneau, la propriété qui ne coûte rien. + current.height = null; + current.offset = enableDragToClose ? delta : 0; + setDragHeight(null); + setDragOffset(current.offset); + return; + } + + // Vers le haut : seul un panneau à palier réagit, en grandissant vers `full`. + current.offset = 0; + setDragOffset(0); + if (!canSnap || snapped) return; + const ceiling = contained + ? (innerRef.current?.parentElement?.clientHeight ?? current.startHeight) + : window.innerHeight; + current.height = Math.min(ceiling, current.startHeight - delta); + setDragHeight(current.height); + }; + + const onPointerUp = (event: PointerEvent) => { + const current = gesture.current; + if (!current || event.pointerId !== current.pointerId) return; + + const grown = (current.height ?? current.startHeight) - current.startHeight; + const offset = current.offset; + const threshold = Math.max(1, dragThreshold); + gesture.current = null; + setDragging(false); + setDragOffset(0); + setDragHeight(null); + + if (grown >= threshold) { + setSnapped(true); + return; + } + if (offset < threshold) return; + // Depuis `full`, un panneau à palier redescend d'abord à `half`. + if (canSnap && snapped) setSnapped(false); + else if (enableDragToClose) close(); + }; + + const onHandleKeyDown = (event: React.KeyboardEvent) => { + if (!canSnap) return; + switch (event.key) { + case 'ArrowUp': + setSnapped(true); + break; + case 'ArrowDown': + setSnapped(false); + break; + case 'Enter': + case ' ': + setSnapped((value) => !value); + break; + default: + return; + } + event.preventDefault(); + }; + + // --- Rendu --------------------------------------------------------------- + + const sheetStyle: CSSProperties = { ...style }; + const set = (name: string, value: string) => { + (sheetStyle as Record)[name] = value; + }; + if (motionDisabled) set('--ui-motion-duration', '0ms'); + // Un palier passe par sa classe, pour que le crochet SCSS reste maître. + if (dragHeight !== null) sheetStyle.height = `${dragHeight}px`; + else if (!preset) sheetStyle.height = effectiveHeight; + if (dragOffset > 0) set('translate', `0 ${dragOffset}px`); + + const hasGrab = showHandle || Boolean(header) || closable; + + return ( + { + innerRef.current = node; + if (typeof ref === 'function') ref(node); + else if (ref) ref.current = node; + }} + className={cx( + 'ui-bottom-sheet', + preset && `_${preset}`, + contained && '_contained', + safeArea && !contained && '_safe-area', + dragging && '_dragging', + backdropClassName, + className, + )} + style={sheetStyle} + aria-label={labelledBy ? undefined : ariaLabel} + aria-labelledby={labelledBy} + > + {hasGrab && ( + // Zone de préhension : la poignée et l'en-tête. Le corps en est exclu, + // pour qu'il garde son défilement natif. +
+ {showHandle && + (canSnap ? ( + + )} +
+ )} + + )} + + {/* + Le corps défile nativement, et garde son défilement chez lui. + + `jsx-a11y` refuse un `tabIndex` sur un élément non interactif, et axe + EXIGE qu'une région défilante soit atteignable au clavier + (`scrollable-region-focusable`). Les deux règles se contredisent, et + c'est axe qui tranche : lui mesure le rendu réel. Même contrat que + `ui-modal` : le `tabIndex` n'est posé que quand la région déborde ET ne + contient rien de focalisable, donc jamais « au cas où ». + */} + {/* eslint-disable jsx-a11y/no-noninteractive-tabindex */} +
+ {children} +
+ {/* eslint-enable jsx-a11y/no-noninteractive-tabindex */} + + {footer &&
{footer}
} +
+ ); +} diff --git a/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/index.ts b/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/index.ts new file mode 100644 index 0000000..631e4e9 --- /dev/null +++ b/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/index.ts @@ -0,0 +1,11 @@ +export { + UiBottomTabBar, + UiBottomTab, + UiBottomTabAction, + type UiBottomTabBarProps, + type UiBottomTabProps, + type UiBottomTabActionProps, + type UiBottomTabRootProps, + type UiBottomTabBarChangeEvent, + type UiBottomTabValue, +} from './ui-bottom-tab-bar'; diff --git a/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/ui-bottom-tab-bar.mdx b/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/ui-bottom-tab-bar.mdx new file mode 100644 index 0000000..28a8752 --- /dev/null +++ b/packages/ui-kit-react/src/navigation/ui-bottom-tab-bar/ui-bottom-tab-bar.mdx @@ -0,0 +1,214 @@ +import { Meta, Canvas, ArgTypes } from '@storybook/addon-docs/blocks'; +import { ConfigTable } from '@sb/blocks/config-table'; +import * as BarStories from './ui-bottom-tab-bar.stories'; + + + +# ui-bottom-tab-bar + +La barre de navigation basse des appareils tactiles : un repère `