@sia-ui/cli 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +168 -0
  2. package/LICENSE +21 -21
  3. package/README.md +183 -3
  4. package/dist/index.js +0 -0
  5. package/dist/registry/package/components/Alert/index.tsx +7 -0
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
  7. package/dist/registry/package/components/AppShell/index.tsx +413 -3
  8. package/dist/registry/package/components/AppShell/styles.css +259 -7
  9. package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
  10. package/dist/registry/package/components/AsyncState/index.tsx +7 -0
  11. package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
  12. package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
  13. package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
  14. package/dist/registry/package/components/Avatar/index.tsx +333 -12
  15. package/dist/registry/package/components/Avatar/styles.css +236 -5
  16. package/dist/registry/package/components/Badge/index.tsx +7 -0
  17. package/dist/registry/package/components/Button/index.tsx +7 -0
  18. package/dist/registry/package/components/Calendar/index.tsx +11 -0
  19. package/dist/registry/package/components/Card/index.tsx +8 -0
  20. package/dist/registry/package/components/Checkbox/index.tsx +8 -0
  21. package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
  24. package/dist/registry/package/components/Container/index.tsx +7 -0
  25. package/dist/registry/package/components/Countdown/index.tsx +7 -0
  26. package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
  27. package/dist/registry/package/components/CrudPage/index.tsx +546 -5
  28. package/dist/registry/package/components/CrudPage/styles.css +25 -1
  29. package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
  30. package/dist/registry/package/components/DataTable/index.tsx +548 -200
  31. package/dist/registry/package/components/DataTable/query.ts +68 -0
  32. package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
  33. package/dist/registry/package/components/DataTable/styles.css +285 -73
  34. package/dist/registry/package/components/DataTable/types.ts +194 -0
  35. package/dist/registry/package/components/DataTable/values.ts +28 -0
  36. package/dist/registry/package/components/DatePicker/index.tsx +7 -0
  37. package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
  38. package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
  39. package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
  40. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  41. package/dist/registry/package/components/Divider/index.tsx +7 -0
  42. package/dist/registry/package/components/Drawer/index.tsx +8 -0
  43. package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
  44. package/dist/registry/package/components/EmptyState/index.tsx +7 -0
  45. package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
  46. package/dist/registry/package/components/ErrorState/index.tsx +8 -0
  47. package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
  48. package/dist/registry/package/components/Field/index.tsx +12 -0
  49. package/dist/registry/package/components/FileUpload/index.tsx +7 -0
  50. package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +14 -37
  52. package/dist/registry/package/components/IconButton/index.tsx +8 -0
  53. package/dist/registry/package/components/Icons/index.tsx +15 -0
  54. package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
  55. package/dist/registry/package/components/Input/index.tsx +7 -0
  56. package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
  57. package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
  58. package/dist/registry/package/components/Label/index.tsx +7 -0
  59. package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
  60. package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
  61. package/dist/registry/package/components/Modal/index.tsx +8 -0
  62. package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
  63. package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
  64. package/dist/registry/package/components/OtpInput/index.tsx +7 -0
  65. package/dist/registry/package/components/Overlay/index.tsx +34 -24
  66. package/dist/registry/package/components/PageHeader/index.tsx +7 -0
  67. package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
  68. package/dist/registry/package/components/Popover/index.tsx +7 -0
  69. package/dist/registry/package/components/QrCode/index.tsx +7 -0
  70. package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
  71. package/dist/registry/package/components/Rating/index.tsx +6 -0
  72. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  73. package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
  74. package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
  75. package/dist/registry/package/components/Resource/index.tsx +514 -0
  76. package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
  77. package/dist/registry/package/components/SearchInput/index.tsx +8 -0
  78. package/dist/registry/package/components/Select/index.tsx +11 -0
  79. package/dist/registry/package/components/Sidebar/index.tsx +489 -113
  80. package/dist/registry/package/components/Sidebar/styles.css +96 -4
  81. package/dist/registry/package/components/Skeleton/index.tsx +7 -0
  82. package/dist/registry/package/components/Spinner/index.tsx +7 -0
  83. package/dist/registry/package/components/StatCard/index.tsx +7 -0
  84. package/dist/registry/package/components/Statistic/index.tsx +7 -0
  85. package/dist/registry/package/components/Switch/index.tsx +7 -0
  86. package/dist/registry/package/components/Tabs/index.tsx +7 -0
  87. package/dist/registry/package/components/TagsInput/index.tsx +7 -0
  88. package/dist/registry/package/components/Textarea/index.tsx +7 -0
  89. package/dist/registry/package/components/TimePicker/index.tsx +7 -0
  90. package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
  91. package/dist/registry/package/components/Timeline/index.tsx +7 -0
  92. package/dist/registry/package/components/Timer/index.tsx +7 -0
  93. package/dist/registry/package/components/Tour/index.tsx +7 -0
  94. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  95. package/dist/registry/package/components/Tree/index.tsx +7 -0
  96. package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
  97. package/dist/registry/package/components/Typography/index.tsx +8 -0
  98. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
  99. package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
  100. package/dist/registry/package/components/YearPicker/index.tsx +7 -0
  101. package/dist/registry/registry.json +69 -14
  102. package/package.json +18 -18
@@ -0,0 +1,134 @@
1
+ import { memo, type ReactNode } from "react";
2
+ import { cn } from "@sia-ui/utils";
3
+ import { navLeaves, navSome } from "@sia-ui/headless";
4
+ import type { SidebarItem, SidebarRenderLink } from "../Sidebar";
5
+
6
+ export interface BottomTabsProps {
7
+ /** Attendu déjà filtré : la coquille a fait le tri une seule fois. */
8
+ items: SidebarItem[];
9
+ activeKey?: string;
10
+
11
+ /**
12
+ * Nombre d'onglets avant le bouton « Plus ».
13
+ *
14
+ * Quatre au maximum sur un téléphone : au-delà, les cibles tactiles
15
+ * descendent sous les quarante-quatre pixels recommandés, et on rate
16
+ * l'onglet voisin une fois sur trois.
17
+ */
18
+ max?: number;
19
+
20
+ renderLink?: SidebarRenderLink;
21
+ onNavigate?: (item: SidebarItem) => void;
22
+
23
+ /** Le bouton qui ouvre l'arborescence complète. */
24
+ more?: ReactNode;
25
+ onMore?: () => void;
26
+ moreLabel?: string;
27
+
28
+ className?: string;
29
+ }
30
+
31
+ /**
32
+ * La navigation d'un téléphone.
33
+ *
34
+ * Un rail d'icônes muettes n'est pas utilisable au comptoir : sur petit écran
35
+ * la navigation descend en bas, à portée du pouce, avec des libellés.
36
+ *
37
+ * Les onglets ne montrent que des **feuilles**. Un onglet qui ouvrirait un
38
+ * sous-menu depuis une barre basse serait un piège tactile — on vise une
39
+ * destination, on obtient un menu. Le reste de l'arborescence passe par
40
+ * « Plus ».
41
+ */
42
+ export const BottomTabs = memo(function BottomTabs({
43
+ items,
44
+ activeKey,
45
+ max = 4,
46
+ renderLink,
47
+ onNavigate,
48
+ more,
49
+ onMore,
50
+ moreLabel = "Plus",
51
+ className,
52
+ }: BottomTabsProps) {
53
+ const feuilles = navLeaves(items);
54
+ const onglets = feuilles.slice(0, max);
55
+ const reste = feuilles.length - onglets.length;
56
+ const afficherPlus = reste > 0 || Boolean(onMore);
57
+
58
+ return (
59
+ <nav className={cn("sia-bottom-tabs", className)} aria-label="Navigation principale">
60
+ {onglets.map((item) => {
61
+ const actif = item.key === activeKey;
62
+ const contenu = (
63
+ <>
64
+ <span className="sia-bottom-tabs__pastille" aria-hidden="true">
65
+ {item.icon}
66
+ {navSome(item, (e) => e.badge !== undefined && e.badge !== null) && (
67
+ <span className="sia-bottom-tabs__point" />
68
+ )}
69
+ </span>
70
+ <span className="sia-bottom-tabs__label">{item.label}</span>
71
+ </>
72
+ );
73
+
74
+ const classe = cn("sia-bottom-tabs__item", actif && "sia-bottom-tabs__item--active");
75
+ const naviguer = () => {
76
+ item.onClick?.();
77
+ onNavigate?.(item);
78
+ };
79
+
80
+ if (item.href && renderLink) {
81
+ return (
82
+ <span key={item.key} className="sia-bottom-tabs__cell">
83
+ {renderLink({
84
+ item,
85
+ children: contenu,
86
+ props: {
87
+ className: classe,
88
+ onClick: naviguer,
89
+ ...(actif ? { "aria-current": "page" as const } : {}),
90
+ },
91
+ })}
92
+ </span>
93
+ );
94
+ }
95
+
96
+ return item.href ? (
97
+ <a
98
+ key={item.key}
99
+ href={item.href}
100
+ className={classe}
101
+ {...(actif ? { "aria-current": "page" as const } : {})}
102
+ onClick={naviguer}
103
+ >
104
+ {contenu}
105
+ </a>
106
+ ) : (
107
+ <button
108
+ key={item.key}
109
+ type="button"
110
+ className={classe}
111
+ {...(actif ? { "aria-current": "page" as const } : {})}
112
+ onClick={naviguer}
113
+ >
114
+ {contenu}
115
+ </button>
116
+ );
117
+ })}
118
+
119
+ {afficherPlus && (
120
+ <button
121
+ type="button"
122
+ className="sia-bottom-tabs__item"
123
+ onClick={onMore}
124
+ aria-label={`${moreLabel}${reste > 0 ? ` (${reste})` : ""}`}
125
+ >
126
+ <span className="sia-bottom-tabs__pastille" aria-hidden="true">
127
+ {more ?? "···"}
128
+ </span>
129
+ <span className="sia-bottom-tabs__label">{moreLabel}</span>
130
+ </button>
131
+ )}
132
+ </nav>
133
+ );
134
+ });
@@ -14,6 +14,13 @@ export interface AsyncStateProps {
14
14
  children: ReactNode;
15
15
  }
16
16
 
17
+ /**
18
+ * Les trois états d'un chargement, au même endroit.
19
+ *
20
+ * En attente, en erreur, ou vide : ce sont les mêmes trois cas à chaque
21
+ * écran, et les écrire à la main en oublie toujours un — le plus souvent le
22
+ * vide, qui ressemble à un chargement qui n'en finit pas.
23
+ */
17
24
  export function AsyncState({ loading, error, empty, emptyTitle = "No data", emptyDescription, onRetry, children }: AsyncStateProps) {
18
25
  if (loading) return <div className="sia-async-state" role="status"><span className="sia-spinner" /><span>Loading...</span></div>;
19
26
  if (error) return <Alert tone="danger" title="Unable to load data" action={onRetry && <Button variant="outline" onClick={onRetry}>Retry</Button>}>{error}</Alert>;
@@ -17,6 +17,14 @@ export interface AuditMetaProps {
17
17
  className?: string;
18
18
  }
19
19
 
20
+ /**
21
+ * Qui a fait quoi, et quand.
22
+ *
23
+ * Une liste d'événements plutôt qu'un dernier état : savoir qu'une facture
24
+ * a été validée n'apprend rien si l'on ignore qu'elle avait été rejetée la
25
+ * veille. Les dates sont relatives — « il y a deux jours » se compare sans
26
+ * calcul mental.
27
+ */
20
28
  export function AuditMeta({ events, locale = "fr-FR", title = "Historique", emptyLabel = "Aucune activité", className }: AuditMetaProps) {
21
29
  return (
22
30
  <section className={cn("sia-audit-meta", className)}>
@@ -11,6 +11,13 @@ export interface AuthLayoutProps {
11
11
  footer?: ReactNode;
12
12
  className?: string;
13
13
  }
14
+ /**
15
+ * La page qui précède l'application : connexion, inscription, mot de passe
16
+ * oublié.
17
+ *
18
+ * Elle n'a ni navigation ni coquille, et c'est délibéré : tout ce qui mène
19
+ * ailleurs est hors de portée de quelqu'un qui n'est pas encore entré.
20
+ */
14
21
  export function AuthLayout({
15
22
  title,
16
23
  description,
@@ -3,6 +3,13 @@ import { cn } from "@sia-ui/utils";
3
3
  import type { SelectOption } from "../Select";
4
4
  import { useSiaLocale } from "@sia-ui/headless";
5
5
  export interface AutocompleteProps { id?: string; options: SelectOption[]; value?: string; defaultValue?: string; onValueChange?: (value: string, option?: SelectOption) => void; placeholder?: string; allowCustomValue?: boolean; disabled?: boolean; className?: string; "aria-describedby"?: string; "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-required"?: boolean; }
6
+ /**
7
+ * Un champ qui propose, sans imposer.
8
+ *
9
+ * `allowCustomValue` est vrai par défaut : la liste aide à trouver, elle ne
10
+ * dit pas ce qui existe. Un nom de client absent du référentiel reste
11
+ * saisissable, quitte à être créé ensuite.
12
+ */
6
13
  export function Autocomplete({ id, options, value, defaultValue = "", onValueChange, placeholder: placeholderProp, allowCustomValue = true, disabled, className, ...ariaProps }: AutocompleteProps) {
7
14
  const locale = useSiaLocale();
8
15
  const placeholder = placeholderProp ?? locale.searchPlaceholder; const initialLabel = options.find((option) => option.value === defaultValue)?.label; const [internal, setInternal] = useState(defaultValue); const [query, setQuery] = useState(initialLabel ? String(initialLabel) : defaultValue); const [open, setOpen] = useState(false); const current = value ?? internal; const filtered = useMemo(() => { const needle = query.toLowerCase(); return options.filter((option) => [String(option.label), ...(option.keywords ?? [])].join(" ").toLowerCase().includes(needle)); }, [options, query]); const select = (option: SelectOption) => { if (value === undefined) setInternal(option.value); setQuery(String(option.label)); setOpen(false); onValueChange?.(option.value, option); }; return <div id={id} className={cn("sia-autocomplete", className)} {...ariaProps}><input value={query} placeholder={placeholder} disabled={disabled} role="combobox" aria-expanded={open} aria-controls={`${id ?? "sia-autocomplete"}-list`} onFocus={() => setOpen(true)} onChange={(event) => { const next = event.target.value; setQuery(next); setOpen(true); if (allowCustomValue) { if (value === undefined) setInternal(next); onValueChange?.(next); } }} onBlur={() => window.setTimeout(() => setOpen(false), 120)} />{open && <div id={`${id ?? "sia-autocomplete"}-list`} role="listbox">{filtered.map((option) => <button type="button" role="option" aria-selected={current === option.value} key={option.value} disabled={option.disabled} onMouseDown={(event) => event.preventDefault()} onClick={() => select(option)}>{option.label}</button>)}</div>}</div>; }
@@ -1,23 +1,344 @@
1
- import { useState, type ImgHTMLAttributes } from "react";
1
+ import {
2
+ createContext,
3
+ useContext,
4
+ useMemo,
5
+ useState,
6
+ type HTMLAttributes,
7
+ type ImgHTMLAttributes,
8
+ type ReactNode,
9
+ } from "react";
2
10
  import { cn } from "@sia-ui/utils";
3
11
  import "./styles.css";
4
12
 
5
- export interface AvatarProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "children"> {
6
- name: string;
7
- size?: "sm" | "md" | "lg";
8
- fallback?: string;
13
+ export type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl";
14
+ export type AvatarShape = "circle" | "rounded" | "square";
15
+ export type AvatarVariant = "soft" | "solid" | "outline";
16
+
17
+ /**
18
+ * La présence d'une personne.
19
+ *
20
+ * Quatre états et pas davantage : au-delà, la pastille devient un code que
21
+ * personne ne retient, et il faut une légende pour la lire.
22
+ */
23
+ export type AvatarStatus = "online" | "offline" | "busy" | "away";
24
+
25
+ const LIBELLES_STATUT: Record<AvatarStatus, string> = {
26
+ online: "en ligne",
27
+ offline: "hors ligne",
28
+ busy: "occupé",
29
+ away: "absent",
30
+ };
31
+
32
+ /**
33
+ * Les teintes dérivées du nom.
34
+ *
35
+ * Deux personnes aux mêmes initiales doivent se distinguer : dans une liste
36
+ * de participants, six pastilles identiques ne valent pas mieux qu'aucune.
37
+ * La couleur est donc déterministe — la même personne garde la sienne d'un
38
+ * écran à l'autre, d'une session à l'autre.
39
+ */
40
+ const TEINTES = [
41
+ "primary",
42
+ "info",
43
+ "success",
44
+ "warning",
45
+ "danger",
46
+ "secondary",
47
+ ] as const;
48
+
49
+ export type AvatarTint = (typeof TEINTES)[number];
50
+
51
+ /**
52
+ * Une empreinte stable, calculée sur le nom.
53
+ *
54
+ * Volontairement minuscule : cette fonction tourne pour chaque avatar d'une
55
+ * liste qui peut en compter des centaines. Un hachage cryptographique y
56
+ * coûterait mille fois plus pour un résultat visuellement identique.
57
+ */
58
+ function empreinte(texte: string): number {
59
+ let h = 0;
60
+ for (let i = 0; i < texte.length; i += 1) {
61
+ h = (h * 31 + texte.charCodeAt(i)) | 0;
62
+ }
63
+ return Math.abs(h);
64
+ }
65
+
66
+ export function tintForName(name: string): AvatarTint {
67
+ return TEINTES[empreinte(name) % TEINTES.length]!;
68
+ }
69
+
70
+ /**
71
+ * Les initiales d'un nom.
72
+ *
73
+ * Première et dernière partie plutôt que les deux premières : « Jean-Baptiste
74
+ * Kouassi Mbella » se lit « JM », pas « JK ». Les particules sont écartées,
75
+ * sinon « Marie de la Tour » donnerait « MD ».
76
+ */
77
+ const PARTICULES = new Set([
78
+ "de", "du", "des", "la", "le", "les", "van", "von", "da", "di", "el", "al",
79
+ ]);
80
+
81
+ export function initialsOf(name: string, count = 2): string {
82
+ const parts = name
83
+ .trim()
84
+ .split(/[\s'-]+/)
85
+ .filter((p) => p.length > 0 && !PARTICULES.has(p.toLowerCase()));
86
+
87
+ if (parts.length === 0) return "";
88
+ if (parts.length === 1 || count === 1) {
89
+ return (parts[0]!.slice(0, count) || "").toUpperCase();
90
+ }
91
+
92
+ return (
93
+ (parts[0]![0] ?? "") + (parts[parts.length - 1]![0] ?? "")
94
+ ).toUpperCase();
9
95
  }
10
96
 
11
- function getInitials(name: string) {
12
- return name.trim().split(/\s+/).slice(0, 2).map((part) => part[0]?.toUpperCase() ?? "").join("");
97
+ interface AvatarGroupContextValue {
98
+ size: AvatarSize;
99
+ shape: AvatarShape;
100
+ variant: AvatarVariant;
13
101
  }
14
102
 
15
- export function Avatar({ name, size = "md", fallback, className, src, alt, onError, ...props }: AvatarProps) {
16
- const [failed, setFailed] = useState(false);
103
+ const AvatarGroupContext = createContext<AvatarGroupContextValue | null>(null);
104
+
105
+ export interface AvatarProps
106
+ extends Omit<ImgHTMLAttributes<HTMLImageElement>, "children" | "width" | "height"> {
107
+ /** Le nom de la personne : il sert au repli, à la couleur et à l'annonce. */
108
+ name: string;
109
+
110
+ size?: AvatarSize;
111
+ shape?: AvatarShape;
112
+ variant?: AvatarVariant;
113
+
114
+ /** Force la teinte au lieu de la dériver du nom. */
115
+ tint?: AvatarTint;
116
+
117
+ /** Remplace les initiales — une icône, un emoji, deux lettres à soi. */
118
+ fallback?: ReactNode;
119
+
120
+ /** Nombre d'initiales quand le repli est automatique. */
121
+ initials?: 1 | 2;
122
+
123
+ status?: AvatarStatus;
124
+ /** Le libellé du statut, pour les lecteurs d'écran. */
125
+ statusLabel?: string;
126
+
127
+ /** Un liseré qui détache l'avatar de son fond. */
128
+ ring?: boolean;
129
+
130
+ /** Rend l'avatar actionnable — un menu de compte, par exemple. */
131
+ onPress?: () => void;
132
+ }
133
+
134
+ /**
135
+ * Le portrait d'une personne, ou ce qui le remplace.
136
+ *
137
+ * Une image met du temps à arriver, échoue parfois, et manque souvent. Les
138
+ * trois cas sont traités sans que la mise en page bouge : l'espace est réservé
139
+ * d'emblée, le repli est peint dessous, et l'image se fond par-dessus quand
140
+ * elle arrive.
141
+ */
142
+ export function Avatar({
143
+ name,
144
+ size,
145
+ shape,
146
+ variant,
147
+ tint,
148
+ fallback,
149
+ initials = 2,
150
+ status,
151
+ statusLabel,
152
+ ring = false,
153
+ onPress,
154
+ className,
155
+ src,
156
+ alt,
157
+ onError,
158
+ onLoad,
159
+ style,
160
+ ...props
161
+ }: AvatarProps) {
162
+ const groupe = useContext(AvatarGroupContext);
163
+ const [etatImage, setEtatImage] = useState<"attente" | "prete" | "echouee">(
164
+ src ? "attente" : "echouee",
165
+ );
166
+
167
+ const resolvedSize = size ?? groupe?.size ?? "md";
168
+ const resolvedShape = shape ?? groupe?.shape ?? "circle";
169
+ const resolvedVariant = variant ?? groupe?.variant ?? "soft";
170
+
171
+ // Le nom ne change pas à chaque rendu, le hachage non plus : dans une liste
172
+ // de cent participants, c'est cent calculs évités par frappe au clavier.
173
+ const teinte = useMemo(() => tint ?? tintForName(name), [name, tint]);
174
+ const lettres = useMemo(
175
+ () => (fallback === undefined ? initialsOf(name, initials) : null),
176
+ [fallback, initials, name],
177
+ );
178
+
179
+ const classe = cn(
180
+ "sia-avatar",
181
+ `sia-avatar--${resolvedSize}`,
182
+ `sia-avatar--${resolvedShape}`,
183
+ `sia-avatar--${resolvedVariant}`,
184
+ `sia-avatar--tint-${teinte}`,
185
+ ring && "sia-avatar--ring",
186
+ etatImage === "attente" && "sia-avatar--loading",
187
+ onPress && "sia-avatar--pressable",
188
+ className,
189
+ );
190
+
191
+ const contenu = (
192
+ <>
193
+ {/* Le repli est toujours peint, sous l'image. C'est lui qui tient la
194
+ place pendant le chargement, et qui reste si l'image échoue —
195
+ aucune bascule, donc aucun saut. */}
196
+ <span className="sia-avatar__fallback" aria-hidden="true">
197
+ {fallback ?? lettres}
198
+ </span>
199
+
200
+ {src && etatImage !== "echouee" && (
201
+ <img
202
+ {...props}
203
+ className="sia-avatar__image"
204
+ src={src}
205
+ alt={alt ?? ""}
206
+ loading="lazy"
207
+ decoding="async"
208
+ onLoad={(event) => {
209
+ setEtatImage("prete");
210
+ onLoad?.(event);
211
+ }}
212
+ onError={(event) => {
213
+ setEtatImage("echouee");
214
+ onError?.(event);
215
+ }}
216
+ />
217
+ )}
218
+
219
+ {status && (
220
+ <span
221
+ className={cn("sia-avatar__status", `sia-avatar__status--${status}`)}
222
+ aria-hidden="true"
223
+ />
224
+ )}
225
+
226
+ {/* Le nom n'est jamais dans l'image : un `alt` vide et un texte masqué
227
+ annoncent la personne une seule fois, statut compris. */}
228
+ <span className="sia-visually-hidden">
229
+ {name}
230
+ {status ? ` — ${statusLabel ?? LIBELLES_STATUT[status]}` : ""}
231
+ </span>
232
+ </>
233
+ );
234
+
235
+ if (onPress) {
236
+ return (
237
+ <button
238
+ type="button"
239
+ className={classe}
240
+ title={name}
241
+ onClick={onPress}
242
+ {...(style ? { style } : {})}
243
+ >
244
+ {contenu}
245
+ </button>
246
+ );
247
+ }
248
+
17
249
  return (
18
- <span className={cn("sia-avatar", `sia-avatar--${size}`, className)} title={name}>
19
- {src && !failed ? <img src={src} alt={alt ?? name} onError={(event) => { setFailed(true); onError?.(event); }} {...props} /> : <span aria-hidden="true">{fallback ?? getInitials(name)}</span>}
20
- {!src || failed ? <span className="sia-visually-hidden">{name}</span> : null}
250
+ <span className={classe} title={name} {...(style ? { style } : {})}>
251
+ {contenu}
21
252
  </span>
22
253
  );
23
254
  }
255
+
256
+ export interface AvatarGroupProps extends HTMLAttributes<HTMLDivElement> {
257
+ children: ReactNode;
258
+
259
+ /**
260
+ * Au-delà, les suivants sont résumés par un compteur.
261
+ *
262
+ * Sans limite, une réunion de quarante personnes déborde de sa ligne et
263
+ * n'apprend plus rien : au-delà de cinq visages, on ne lit plus des gens,
264
+ * on lit une foule.
265
+ */
266
+ max?: number;
267
+
268
+ size?: AvatarSize;
269
+ shape?: AvatarShape;
270
+ variant?: AvatarVariant;
271
+
272
+ /** Le chevauchement, de 0 (côte à côte) à 1 (fortement empilés). */
273
+ overlap?: number;
274
+
275
+ /** Écarte les avatars au survol, pour les distinguer. */
276
+ spreadOnHover?: boolean;
277
+
278
+ /** Ce qu'affiche le compteur. Par défaut `+N`. */
279
+ renderOverflow?: (count: number) => ReactNode;
280
+ }
281
+
282
+ /**
283
+ * Plusieurs personnes, sur une ligne.
284
+ *
285
+ * Les avatars se chevauchent — c'est ce qui dit « un groupe » plutôt qu'« une
286
+ * liste ». Chacun porte un liseré de la couleur du fond, sans quoi la pile
287
+ * devient une bouillie dès que deux teintes se ressemblent.
288
+ */
289
+ export function AvatarGroup({
290
+ children,
291
+ max,
292
+ size = "md",
293
+ shape = "circle",
294
+ variant = "soft",
295
+ overlap = 0.25,
296
+ spreadOnHover = false,
297
+ renderOverflow,
298
+ className,
299
+ style,
300
+ ...props
301
+ }: AvatarGroupProps) {
302
+ const tous = Array.isArray(children) ? children : [children];
303
+ const visibles = max === undefined ? tous : tous.slice(0, max);
304
+ const reste = tous.length - visibles.length;
305
+
306
+ const contexte = useMemo(
307
+ () => ({ size, shape, variant }),
308
+ [shape, size, variant],
309
+ );
310
+
311
+ return (
312
+ <AvatarGroupContext.Provider value={contexte}>
313
+ <div
314
+ className={cn(
315
+ "sia-avatar-group",
316
+ `sia-avatar-group--${size}`,
317
+ spreadOnHover && "sia-avatar-group--spread",
318
+ className,
319
+ )}
320
+ style={{
321
+ ...style,
322
+ "--sia-avatar-overlap": String(Math.min(Math.max(overlap, 0), 1)),
323
+ } as Record<string, string>}
324
+ {...props}
325
+ >
326
+ {visibles}
327
+ {reste > 0 && (
328
+ <span
329
+ className={cn(
330
+ "sia-avatar",
331
+ `sia-avatar--${size}`,
332
+ `sia-avatar--${shape}`,
333
+ "sia-avatar--overflow",
334
+ )}
335
+ >
336
+ <span className="sia-avatar__fallback">
337
+ {renderOverflow ? renderOverflow(reste) : `+${reste}`}
338
+ </span>
339
+ </span>
340
+ )}
341
+ </div>
342
+ </AvatarGroupContext.Provider>
343
+ );
344
+ }