@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.
- package/CHANGELOG.md +168 -0
- package/LICENSE +21 -21
- package/README.md +183 -3
- package/dist/index.js +0 -0
- package/dist/registry/package/components/Alert/index.tsx +7 -0
- package/dist/registry/package/components/AmountDisplay/index.tsx +8 -0
- package/dist/registry/package/components/AppShell/index.tsx +413 -3
- package/dist/registry/package/components/AppShell/styles.css +259 -7
- package/dist/registry/package/components/AppShell/tab-bar.tsx +134 -0
- package/dist/registry/package/components/AsyncState/index.tsx +7 -0
- package/dist/registry/package/components/AuditMeta/index.tsx +8 -0
- package/dist/registry/package/components/AuthLayout/index.tsx +7 -0
- package/dist/registry/package/components/Autocomplete/index.tsx +7 -0
- package/dist/registry/package/components/Avatar/index.tsx +333 -12
- package/dist/registry/package/components/Avatar/styles.css +236 -5
- package/dist/registry/package/components/Badge/index.tsx +7 -0
- package/dist/registry/package/components/Button/index.tsx +7 -0
- package/dist/registry/package/components/Calendar/index.tsx +11 -0
- package/dist/registry/package/components/Card/index.tsx +8 -0
- package/dist/registry/package/components/Checkbox/index.tsx +8 -0
- package/dist/registry/package/components/ColorPicker/index.tsx +7 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
- package/dist/registry/package/components/ConfirmDialog/index.tsx +7 -0
- package/dist/registry/package/components/Container/index.tsx +7 -0
- package/dist/registry/package/components/Countdown/index.tsx +7 -0
- package/dist/registry/package/components/CrudPage/dialogs.tsx +230 -0
- package/dist/registry/package/components/CrudPage/index.tsx +546 -5
- package/dist/registry/package/components/CrudPage/styles.css +25 -1
- package/dist/registry/package/components/CurrencyInput/index.tsx +7 -0
- package/dist/registry/package/components/DataTable/index.tsx +548 -200
- package/dist/registry/package/components/DataTable/query.ts +68 -0
- package/dist/registry/package/components/DataTable/row-actions.tsx +158 -0
- package/dist/registry/package/components/DataTable/styles.css +285 -73
- package/dist/registry/package/components/DataTable/types.ts +194 -0
- package/dist/registry/package/components/DataTable/values.ts +28 -0
- package/dist/registry/package/components/DatePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateRangeFilter/index.tsx +7 -0
- package/dist/registry/package/components/DateRangePicker/index.tsx +7 -0
- package/dist/registry/package/components/DateTimePicker/index.tsx +7 -0
- package/dist/registry/package/components/Descriptions/index.tsx +7 -0
- package/dist/registry/package/components/Divider/index.tsx +7 -0
- package/dist/registry/package/components/Drawer/index.tsx +8 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +6 -0
- package/dist/registry/package/components/EmptyState/index.tsx +7 -0
- package/dist/registry/package/components/EntityMeta/index.tsx +7 -0
- package/dist/registry/package/components/ErrorState/index.tsx +8 -0
- package/dist/registry/package/components/EventCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Field/index.tsx +12 -0
- package/dist/registry/package/components/FileUpload/index.tsx +7 -0
- package/dist/registry/package/components/FiltersBar/index.tsx +7 -0
- package/dist/registry/package/components/HoverCard/index.tsx +14 -37
- package/dist/registry/package/components/IconButton/index.tsx +8 -0
- package/dist/registry/package/components/Icons/index.tsx +15 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +7 -0
- package/dist/registry/package/components/Input/index.tsx +7 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +7 -0
- package/dist/registry/package/components/KpiGrid/index.tsx +7 -0
- package/dist/registry/package/components/Label/index.tsx +7 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +7 -0
- package/dist/registry/package/components/MiniCalendar/index.tsx +7 -0
- package/dist/registry/package/components/Modal/index.tsx +8 -0
- package/dist/registry/package/components/MonthPicker/index.tsx +7 -0
- package/dist/registry/package/components/MultiSelect/index.tsx +7 -0
- package/dist/registry/package/components/OtpInput/index.tsx +7 -0
- package/dist/registry/package/components/Overlay/index.tsx +34 -24
- package/dist/registry/package/components/PageHeader/index.tsx +7 -0
- package/dist/registry/package/components/PermissionGate/index.tsx +6 -0
- package/dist/registry/package/components/Popover/index.tsx +7 -0
- package/dist/registry/package/components/QrCode/index.tsx +7 -0
- package/dist/registry/package/components/RadioGroup/index.tsx +7 -0
- package/dist/registry/package/components/Rating/index.tsx +6 -0
- package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
- package/dist/registry/package/components/RelativeTime/index.tsx +6 -0
- package/dist/registry/package/components/ResizablePanel/index.tsx +7 -0
- package/dist/registry/package/components/Resource/index.tsx +514 -0
- package/dist/registry/package/components/RichTextEditor/index.tsx +7 -0
- package/dist/registry/package/components/SearchInput/index.tsx +8 -0
- package/dist/registry/package/components/Select/index.tsx +11 -0
- package/dist/registry/package/components/Sidebar/index.tsx +489 -113
- package/dist/registry/package/components/Sidebar/styles.css +96 -4
- package/dist/registry/package/components/Skeleton/index.tsx +7 -0
- package/dist/registry/package/components/Spinner/index.tsx +7 -0
- package/dist/registry/package/components/StatCard/index.tsx +7 -0
- package/dist/registry/package/components/Statistic/index.tsx +7 -0
- package/dist/registry/package/components/Switch/index.tsx +7 -0
- package/dist/registry/package/components/Tabs/index.tsx +7 -0
- package/dist/registry/package/components/TagsInput/index.tsx +7 -0
- package/dist/registry/package/components/Textarea/index.tsx +7 -0
- package/dist/registry/package/components/TimePicker/index.tsx +7 -0
- package/dist/registry/package/components/TimeRangePicker/index.tsx +6 -0
- package/dist/registry/package/components/Timeline/index.tsx +7 -0
- package/dist/registry/package/components/Timer/index.tsx +7 -0
- package/dist/registry/package/components/Tour/index.tsx +7 -0
- package/dist/registry/package/components/Transfer/index.tsx +7 -0
- package/dist/registry/package/components/Tree/index.tsx +7 -0
- package/dist/registry/package/components/TreeSelect/index.tsx +7 -0
- package/dist/registry/package/components/Typography/index.tsx +8 -0
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +8 -0
- package/dist/registry/package/components/WeekPicker/index.tsx +7 -0
- package/dist/registry/package/components/YearPicker/index.tsx +7 -0
- package/dist/registry/registry.json +69 -14
- 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 {
|
|
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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
97
|
+
interface AvatarGroupContextValue {
|
|
98
|
+
size: AvatarSize;
|
|
99
|
+
shape: AvatarShape;
|
|
100
|
+
variant: AvatarVariant;
|
|
13
101
|
}
|
|
14
102
|
|
|
15
|
-
|
|
16
|
-
|
|
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={
|
|
19
|
-
{
|
|
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
|
+
}
|