@sia-ui/cli 0.1.0

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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +51 -0
  3. package/dist/diff.mjs +91 -0
  4. package/dist/index.js +325 -0
  5. package/dist/lifecycle.mjs +242 -0
  6. package/dist/lock.mjs +92 -0
  7. package/dist/registry/package/components/Accordion/index.tsx +172 -0
  8. package/dist/registry/package/components/Accordion/styles.css +97 -0
  9. package/dist/registry/package/components/Alert/index.tsx +31 -0
  10. package/dist/registry/package/components/Alert/styles.css +12 -0
  11. package/dist/registry/package/components/AmountDisplay/index.tsx +11 -0
  12. package/dist/registry/package/components/AmountDisplay/styles.css +9 -0
  13. package/dist/registry/package/components/AppShell/index.tsx +6 -0
  14. package/dist/registry/package/components/AppShell/styles.css +7 -0
  15. package/dist/registry/package/components/AspectRatio/index.tsx +29 -0
  16. package/dist/registry/package/components/AspectRatio/styles.css +15 -0
  17. package/dist/registry/package/components/AsyncState/index.tsx +22 -0
  18. package/dist/registry/package/components/AsyncState/styles.css +1 -0
  19. package/dist/registry/package/components/AuditMeta/index.tsx +44 -0
  20. package/dist/registry/package/components/AuditMeta/styles.css +12 -0
  21. package/dist/registry/package/components/AuthLayout/index.tsx +39 -0
  22. package/dist/registry/package/components/AuthLayout/styles.css +6 -0
  23. package/dist/registry/package/components/Autocomplete/index.tsx +8 -0
  24. package/dist/registry/package/components/Avatar/index.tsx +23 -0
  25. package/dist/registry/package/components/Avatar/styles.css +5 -0
  26. package/dist/registry/package/components/Badge/index.tsx +62 -0
  27. package/dist/registry/package/components/Badge/styles.css +26 -0
  28. package/dist/registry/package/components/Breadcrumb/index.tsx +131 -0
  29. package/dist/registry/package/components/Breadcrumb/styles.css +76 -0
  30. package/dist/registry/package/components/Button/index.tsx +91 -0
  31. package/dist/registry/package/components/Button/styles.css +111 -0
  32. package/dist/registry/package/components/Calendar/index.tsx +192 -0
  33. package/dist/registry/package/components/Card/index.tsx +8 -0
  34. package/dist/registry/package/components/Card/styles.css +4 -0
  35. package/dist/registry/package/components/Chart/index.tsx +437 -0
  36. package/dist/registry/package/components/Chart/styles.css +182 -0
  37. package/dist/registry/package/components/Checkbox/index.tsx +193 -0
  38. package/dist/registry/package/components/Checkbox/styles.css +39 -0
  39. package/dist/registry/package/components/ColorPicker/index.tsx +6 -0
  40. package/dist/registry/package/components/ColorPicker/styles.css +2 -0
  41. package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
  42. package/dist/registry/package/components/CommandPalette/styles.css +8 -0
  43. package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -0
  44. package/dist/registry/package/components/Container/index.tsx +11 -0
  45. package/dist/registry/package/components/Container/styles.css +1 -0
  46. package/dist/registry/package/components/Countdown/index.tsx +3 -0
  47. package/dist/registry/package/components/CrudPage/index.tsx +9 -0
  48. package/dist/registry/package/components/CrudPage/styles.css +1 -0
  49. package/dist/registry/package/components/CurrencyInput/index.tsx +15 -0
  50. package/dist/registry/package/components/CurrencyInput/styles.css +1 -0
  51. package/dist/registry/package/components/DataTable/index.tsx +240 -0
  52. package/dist/registry/package/components/DataTable/styles.css +98 -0
  53. package/dist/registry/package/components/DatePicker/index.tsx +102 -0
  54. package/dist/registry/package/components/DateRangeFilter/index.tsx +8 -0
  55. package/dist/registry/package/components/DateRangeFilter/styles.css +4 -0
  56. package/dist/registry/package/components/DateRangePicker/index.tsx +36 -0
  57. package/dist/registry/package/components/DateTimePicker/index.tsx +32 -0
  58. package/dist/registry/package/components/Descriptions/index.tsx +7 -0
  59. package/dist/registry/package/components/Descriptions/styles.css +8 -0
  60. package/dist/registry/package/components/Divider/index.tsx +12 -0
  61. package/dist/registry/package/components/Divider/styles.css +3 -0
  62. package/dist/registry/package/components/Drawer/context.ts +14 -0
  63. package/dist/registry/package/components/Drawer/index.tsx +209 -0
  64. package/dist/registry/package/components/Drawer/styles.css +151 -0
  65. package/dist/registry/package/components/DropdownMenu/index.tsx +350 -0
  66. package/dist/registry/package/components/DropdownMenu/styles.css +100 -0
  67. package/dist/registry/package/components/DurationDisplay/index.tsx +3 -0
  68. package/dist/registry/package/components/EmptyState/index.tsx +23 -0
  69. package/dist/registry/package/components/EmptyState/styles.css +6 -0
  70. package/dist/registry/package/components/EntityMeta/index.tsx +65 -0
  71. package/dist/registry/package/components/EntityMeta/styles.css +9 -0
  72. package/dist/registry/package/components/ErrorState/index.tsx +30 -0
  73. package/dist/registry/package/components/ErrorState/styles.css +6 -0
  74. package/dist/registry/package/components/EventCalendar/index.tsx +8 -0
  75. package/dist/registry/package/components/EventCalendar/styles.css +8 -0
  76. package/dist/registry/package/components/Field/index.tsx +664 -0
  77. package/dist/registry/package/components/Field/styles.css +14 -0
  78. package/dist/registry/package/components/FileUpload/index.tsx +68 -0
  79. package/dist/registry/package/components/FileUpload/styles.css +4 -0
  80. package/dist/registry/package/components/FiltersBar/index.tsx +20 -0
  81. package/dist/registry/package/components/FiltersBar/styles.css +6 -0
  82. package/dist/registry/package/components/Form/index.tsx +211 -0
  83. package/dist/registry/package/components/Form/styles.css +80 -0
  84. package/dist/registry/package/components/FormField/index.tsx +2 -0
  85. package/dist/registry/package/components/HoverCard/index.tsx +121 -0
  86. package/dist/registry/package/components/HoverCard/styles.css +26 -0
  87. package/dist/registry/package/components/IconButton/index.tsx +27 -0
  88. package/dist/registry/package/components/IconButton/styles.css +7 -0
  89. package/dist/registry/package/components/Icons/index.tsx +185 -0
  90. package/dist/registry/package/components/ImageUpload/index.tsx +6 -0
  91. package/dist/registry/package/components/ImageUpload/styles.css +4 -0
  92. package/dist/registry/package/components/Input/index.tsx +103 -0
  93. package/dist/registry/package/components/Input/styles.css +8 -0
  94. package/dist/registry/package/components/InputNumber/index.tsx +196 -0
  95. package/dist/registry/package/components/InputNumber/styles.css +54 -0
  96. package/dist/registry/package/components/JsonEditor/index.tsx +6 -0
  97. package/dist/registry/package/components/JsonEditor/styles.css +2 -0
  98. package/dist/registry/package/components/KpiGrid/index.tsx +11 -0
  99. package/dist/registry/package/components/KpiGrid/styles.css +5 -0
  100. package/dist/registry/package/components/Label/index.tsx +43 -0
  101. package/dist/registry/package/components/Label/styles.css +25 -0
  102. package/dist/registry/package/components/MarkdownEditor/index.tsx +5 -0
  103. package/dist/registry/package/components/MiniCalendar/index.tsx +3 -0
  104. package/dist/registry/package/components/Modal/index.tsx +104 -0
  105. package/dist/registry/package/components/Modal/styles.css +20 -0
  106. package/dist/registry/package/components/MonthPicker/index.tsx +5 -0
  107. package/dist/registry/package/components/MultiSelect/index.tsx +133 -0
  108. package/dist/registry/package/components/MultiSelect/styles.css +3 -0
  109. package/dist/registry/package/components/OtpInput/index.tsx +5 -0
  110. package/dist/registry/package/components/OtpInput/styles.css +2 -0
  111. package/dist/registry/package/components/Overlay/index.tsx +140 -0
  112. package/dist/registry/package/components/Overlay/styles.css +1 -0
  113. package/dist/registry/package/components/PageHeader/index.tsx +24 -0
  114. package/dist/registry/package/components/PageHeader/styles.css +5 -0
  115. package/dist/registry/package/components/Pagination/index.tsx +155 -0
  116. package/dist/registry/package/components/Pagination/styles.css +103 -0
  117. package/dist/registry/package/components/PasswordInput/index.tsx +125 -0
  118. package/dist/registry/package/components/PasswordInput/styles.css +58 -0
  119. package/dist/registry/package/components/PermissionGate/index.tsx +3 -0
  120. package/dist/registry/package/components/PhoneInput/index.tsx +189 -0
  121. package/dist/registry/package/components/PhoneInput/styles.css +35 -0
  122. package/dist/registry/package/components/Popover/index.tsx +12 -0
  123. package/dist/registry/package/components/Popover/styles.css +12 -0
  124. package/dist/registry/package/components/Progress/index.tsx +149 -0
  125. package/dist/registry/package/components/Progress/styles.css +101 -0
  126. package/dist/registry/package/components/QrCode/index.tsx +6 -0
  127. package/dist/registry/package/components/QrCode/styles.css +5 -0
  128. package/dist/registry/package/components/RadioGroup/index.tsx +29 -0
  129. package/dist/registry/package/components/RadioGroup/styles.css +20 -0
  130. package/dist/registry/package/components/Rating/index.tsx +82 -0
  131. package/dist/registry/package/components/Rating/styles.css +41 -0
  132. package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
  133. package/dist/registry/package/components/ReferenceSelect/styles.css +2 -0
  134. package/dist/registry/package/components/RelativeTime/index.tsx +22 -0
  135. package/dist/registry/package/components/RelativeTime/styles.css +1 -0
  136. package/dist/registry/package/components/RequireSession/index.tsx +127 -0
  137. package/dist/registry/package/components/RequireSession/styles.css +13 -0
  138. package/dist/registry/package/components/ResizablePanel/index.tsx +6 -0
  139. package/dist/registry/package/components/ResizablePanel/styles.css +5 -0
  140. package/dist/registry/package/components/RichTextEditor/index.tsx +5 -0
  141. package/dist/registry/package/components/RichTextEditor/styles.css +2 -0
  142. package/dist/registry/package/components/ScrollArea/index.tsx +114 -0
  143. package/dist/registry/package/components/ScrollArea/styles.css +74 -0
  144. package/dist/registry/package/components/SearchInput/index.tsx +121 -0
  145. package/dist/registry/package/components/SearchInput/styles.css +6 -0
  146. package/dist/registry/package/components/Select/index.tsx +324 -0
  147. package/dist/registry/package/components/Select/styles.css +58 -0
  148. package/dist/registry/package/components/Sidebar/index.tsx +232 -0
  149. package/dist/registry/package/components/Sidebar/styles.css +140 -0
  150. package/dist/registry/package/components/Skeleton/index.tsx +21 -0
  151. package/dist/registry/package/components/Skeleton/styles.css +2 -0
  152. package/dist/registry/package/components/Slider/index.tsx +193 -0
  153. package/dist/registry/package/components/Slider/styles.css +53 -0
  154. package/dist/registry/package/components/Spinner/index.tsx +28 -0
  155. package/dist/registry/package/components/Spinner/styles.css +8 -0
  156. package/dist/registry/package/components/StatCard/index.tsx +15 -0
  157. package/dist/registry/package/components/StatCard/styles.css +3 -0
  158. package/dist/registry/package/components/Statistic/index.tsx +23 -0
  159. package/dist/registry/package/components/Statistic/styles.css +6 -0
  160. package/dist/registry/package/components/Steps/index.tsx +108 -0
  161. package/dist/registry/package/components/Steps/styles.css +138 -0
  162. package/dist/registry/package/components/Switch/index.tsx +46 -0
  163. package/dist/registry/package/components/Switch/styles.css +26 -0
  164. package/dist/registry/package/components/Tabs/index.tsx +44 -0
  165. package/dist/registry/package/components/Tabs/styles.css +41 -0
  166. package/dist/registry/package/components/TagsInput/index.tsx +6 -0
  167. package/dist/registry/package/components/TagsInput/styles.css +2 -0
  168. package/dist/registry/package/components/Textarea/index.tsx +21 -0
  169. package/dist/registry/package/components/Textarea/styles.css +7 -0
  170. package/dist/registry/package/components/TimePicker/index.tsx +76 -0
  171. package/dist/registry/package/components/TimePicker/styles.css +13 -0
  172. package/dist/registry/package/components/TimeRangePicker/index.tsx +32 -0
  173. package/dist/registry/package/components/TimeRangePicker/styles.css +1 -0
  174. package/dist/registry/package/components/Timeline/index.tsx +8 -0
  175. package/dist/registry/package/components/Timeline/styles.css +12 -0
  176. package/dist/registry/package/components/Timer/index.tsx +9 -0
  177. package/dist/registry/package/components/Toast/index.tsx +426 -0
  178. package/dist/registry/package/components/Toast/styles.css +175 -0
  179. package/dist/registry/package/components/Toggle/index.tsx +181 -0
  180. package/dist/registry/package/components/Toggle/styles.css +124 -0
  181. package/dist/registry/package/components/Tooltip/index.tsx +163 -0
  182. package/dist/registry/package/components/Tooltip/styles.css +57 -0
  183. package/dist/registry/package/components/Tour/index.tsx +8 -0
  184. package/dist/registry/package/components/Tour/styles.css +6 -0
  185. package/dist/registry/package/components/Transfer/index.tsx +7 -0
  186. package/dist/registry/package/components/Transfer/styles.css +6 -0
  187. package/dist/registry/package/components/Tree/index.tsx +8 -0
  188. package/dist/registry/package/components/Tree/styles.css +6 -0
  189. package/dist/registry/package/components/TreeSelect/index.tsx +10 -0
  190. package/dist/registry/package/components/TreeSelect/styles.css +3 -0
  191. package/dist/registry/package/components/Typography/index.tsx +27 -0
  192. package/dist/registry/package/components/Typography/styles.css +16 -0
  193. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +4 -0
  194. package/dist/registry/package/components/WeekPicker/index.tsx +5 -0
  195. package/dist/registry/package/components/YearPicker/index.tsx +3 -0
  196. package/dist/registry/package/lib/form-adapter-rhf.tsx +141 -0
  197. package/dist/registry/registry.json +1780 -0
  198. package/dist/registry/styles/sia-ui.css +361 -0
  199. package/dist/source.mjs +93 -0
  200. package/package.json +55 -0
@@ -0,0 +1,32 @@
1
+ import { useState } from "react";
2
+ import { TimePicker, type TimePickerProps } from "../TimePicker";
3
+ import { cn, toneClass } from "@sia-ui/utils";
4
+ import type { ComponentTone } from "@sia-ui/tokens";
5
+ import { ArrowRightIcon } from "../Icons";
6
+ import { useSiaLocale } from "@sia-ui/headless";
7
+ import "./styles.css";
8
+
9
+ export interface TimeRangeValue { start: string; end: string; }
10
+ export interface TimeRangePickerProps extends Pick<TimePickerProps, "minuteStep" | "secondStep" | "showSeconds" | "hourFormat" | "needConfirm" | "allowClear"> {
11
+ value?: TimeRangeValue;
12
+ defaultValue?: TimeRangeValue;
13
+ onValueChange?: (value: TimeRangeValue) => void;
14
+ order?: boolean;
15
+ startLabel?: string;
16
+ endLabel?: string;
17
+ tone?: ComponentTone;
18
+ invalid?: boolean;
19
+ disabled?: boolean;
20
+ className?: string;
21
+ }
22
+ export function TimeRangePicker({ value, defaultValue = { start: "", end: "" }, onValueChange, order = true, startLabel: startLabelProp, endLabel: endLabelProp, minuteStep = 1, secondStep = 1, showSeconds = false, hourFormat = 24, needConfirm = false, allowClear = true, tone = "primary", invalid, disabled, className }: TimeRangePickerProps) {
23
+ const locale = useSiaLocale();
24
+ const endLabel = endLabelProp ?? locale.endTime;
25
+ const startLabel = startLabelProp ?? locale.startTime;
26
+ const [internal, setInternal] = useState(defaultValue);
27
+ const current = value ?? internal;
28
+ const update = (next: TimeRangeValue) => { const normalized = order && next.start && next.end && next.start > next.end ? { start: next.end, end: next.start } : next; if (value === undefined) setInternal(normalized); onValueChange?.(normalized); };
29
+ const rangeInvalid = Boolean(invalid || (!order && current.start && current.end && current.start > current.end));
30
+ const shared = { minuteStep, secondStep, showSeconds, hourFormat, needConfirm, allowClear, tone, ...(disabled !== undefined ? { disabled } : {}) };
31
+ return <div className={cn("sia-time-range-picker", toneClass("sia-tone", tone), rangeInvalid && "sia-time-range-picker--invalid", className)}><TimePicker {...shared} aria-label={startLabel} value={current.start} {...(!order && current.end ? { max: current.end } : {})} invalid={rangeInvalid} onValueChange={(start) => update({ ...current, start })} /><ArrowRightIcon className="sia-range-picker__separator" /><TimePicker {...shared} aria-label={endLabel} value={current.end} {...(!order && current.start ? { min: current.start } : {})} invalid={rangeInvalid} onValueChange={(end) => update({ ...current, end })} /></div>;
32
+ }
@@ -0,0 +1 @@
1
+ .sia-time-range-picker--invalid { color:var(--sia-danger); }
@@ -0,0 +1,8 @@
1
+ import { type ReactNode } from "react";
2
+ import { cn } from "@sia-ui/utils";
3
+ import type { ComponentTone } from "@sia-ui/tokens";
4
+ import "./styles.css";
5
+
6
+ export interface TimelineItem { key: string; title: ReactNode; description?: ReactNode; time?: ReactNode; tone?: ComponentTone; }
7
+ export interface TimelineProps { items: TimelineItem[]; pending?: ReactNode; className?: string; }
8
+ export function Timeline({ items, pending, className }: TimelineProps) { return <ol className={cn("sia-timeline", className)}>{items.map((item) => <li key={item.key} className={`sia-timeline__item sia-timeline__item--${item.tone ?? "primary"}`}><span className="sia-timeline__dot" /><div><strong>{item.title}</strong>{item.description && <p>{item.description}</p>}{item.time && <small>{item.time}</small>}</div></li>)}{pending && <li className="sia-timeline__item sia-timeline__item--pending"><span className="sia-timeline__dot" /><div>{pending}</div></li>}</ol>; }
@@ -0,0 +1,12 @@
1
+ .sia-timeline { display:grid; gap:0; padding:0; margin:0; list-style:none; }
2
+ .sia-timeline__item { position:relative; display:grid; grid-template-columns:1rem 1fr; gap:.75rem; padding-bottom:1.25rem; }
3
+ .sia-timeline__item:not(:last-child)::before { content:""; position:absolute; top:.8rem; bottom:0; left:.32rem; width:1px; background:var(--sia-border); }
4
+ .sia-timeline__dot { position:relative; z-index:1; width:.7rem; height:.7rem; margin-top:.3rem; border-radius:50%; background:var(--sia-primary); }
5
+ .sia-timeline__item--neutral .sia-timeline__dot { background:var(--sia-muted); }
6
+ .sia-timeline__item--info .sia-timeline__dot { background:var(--sia-info); }
7
+ .sia-timeline__item--success .sia-timeline__dot { background:var(--sia-success); }
8
+ .sia-timeline__item--warning .sia-timeline__dot { background:var(--sia-warning); }
9
+ .sia-timeline__item--danger .sia-timeline__dot { background:var(--sia-danger); }
10
+ .sia-timeline p { margin:.25rem 0; color:var(--sia-muted); }
11
+ .sia-timeline small { color:var(--sia-muted); }
12
+ .sia-timeline__item--primary .sia-timeline__dot { background:var(--sia-primary); }
@@ -0,0 +1,9 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { cn } from "@sia-ui/utils";
3
+
4
+ export interface TimerProps { duration?: number; initialValue?: number; direction?: "down" | "up"; running?: boolean; interval?: number; onChange?: (milliseconds: number) => void; onFinish?: () => void; format?: (milliseconds: number) => string; className?: string; }
5
+ export interface TimeParts { hours: number; minutes: number; seconds: number; }
6
+ export function parseTimeValue(value = ""): TimeParts { const [hours = 0, minutes = 0, seconds = 0] = value.split(":").map(Number); return { hours: Math.min(Math.max(hours || 0, 0), 23), minutes: Math.min(Math.max(minutes || 0, 0), 59), seconds: Math.min(Math.max(seconds || 0, 0), 59) }; }
7
+ export function formatTimeValue(parts: TimeParts, showSeconds = false) { const base = `${String(parts.hours).padStart(2, "0")}:${String(parts.minutes).padStart(2, "0")}`; return showSeconds ? `${base}:${String(parts.seconds).padStart(2, "0")}` : base; }
8
+ export function formatDuration(milliseconds: number, showMilliseconds = false) { const total = Math.max(0, milliseconds); const hours = Math.floor(total / 3600000); const minutes = Math.floor((total % 3600000) / 60000); const seconds = Math.floor((total % 60000) / 1000); const ms = Math.floor((total % 1000) / 10); return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}${showMilliseconds ? `.${String(ms).padStart(2, "0")}` : ""}`; }
9
+ export function Timer({ duration = 60000, initialValue = 0, direction = "down", running = true, interval = 250, onChange, onFinish, format = formatDuration, className }: TimerProps) { const initial = direction === "down" ? duration : initialValue; const [value, setValue] = useState(initial); const finishCalled = useRef(false); useEffect(() => { setValue(direction === "down" ? duration : initialValue); finishCalled.current = false; }, [direction, duration, initialValue]); useEffect(() => { if (!running) return; const startedAt = Date.now(); const base = value; const update = () => { const elapsed = Date.now() - startedAt; const next = direction === "down" ? Math.max(0, base - elapsed) : base + elapsed; setValue(next); onChange?.(next); if (direction === "down" && next === 0 && !finishCalled.current) { finishCalled.current = true; onFinish?.(); } }; update(); const id = window.setInterval(update, interval); return () => window.clearInterval(id); }, [running, direction, interval]); return <time className={cn("sia-timer", className)} aria-live="polite">{format(value)}</time>; }
@@ -0,0 +1,426 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ type PointerEvent as ReactPointerEvent,
9
+ type PropsWithChildren,
10
+ type ReactNode,
11
+ } from "react";
12
+ import { createPortal } from "react-dom";
13
+ import { cn } from "@sia-ui/utils";
14
+ import {
15
+ toast,
16
+ toastStore,
17
+ useSiaLocale,
18
+ useToasts,
19
+ type ToastId,
20
+ type ToastOptions,
21
+ type ToastPlacement,
22
+ type ToastRecord,
23
+ type ToastTone,
24
+ } from "@sia-ui/headless";
25
+ import { usePortal } from "@sia-ui/react";
26
+ import { AlertCircleIcon, CheckIcon, InfoIcon, XIcon } from "../Icons";
27
+ import { Spinner } from "../Spinner";
28
+ import "./styles.css";
29
+
30
+ export type { ToastId, ToastOptions, ToastPlacement, ToastTone };
31
+
32
+ /**
33
+ * L'API des annonces, appelable de n'importe où.
34
+ *
35
+ * `toast("Enregistré")`, `toast.success(…)`, `toast.promise(…)`. Ce n'est pas
36
+ * un hook : le magasin vit hors de React, donc un intercepteur HTTP ou une
37
+ * fonction utilitaire peut poser une annonce sans être un composant.
38
+ */
39
+ export { toast, toastStore };
40
+
41
+ const TONE_ICON: Record<ToastTone, ReactNode> = {
42
+ neutral: null,
43
+ primary: <InfoIcon />,
44
+ info: <InfoIcon />,
45
+ success: <CheckIcon />,
46
+ warning: <AlertCircleIcon />,
47
+ danger: <AlertCircleIcon />,
48
+ loading: <Spinner size="sm" tone="current" />,
49
+ };
50
+
51
+ /** Le temps laissé à l'animation de sortie avant le retrait définitif. */
52
+ const EXIT_DELAY = 260;
53
+
54
+ const PLACEMENTS: ToastPlacement[] = [
55
+ "top-left",
56
+ "top-center",
57
+ "top-right",
58
+ "bottom-left",
59
+ "bottom-center",
60
+ "bottom-right",
61
+ ];
62
+
63
+ export interface ToasterProps {
64
+ /** Coin par défaut des annonces qui n'en précisent pas. */
65
+ placement?: ToastPlacement;
66
+ /** Nombre visible simultanément. Au-delà, la plus ancienne s'efface. */
67
+ limit?: number;
68
+ /** Durée par défaut, pour les tons qui n'en imposent pas une. */
69
+ duration?: number;
70
+ /** Largeur d'une annonce. */
71
+ width?: number | string;
72
+ /** Décalage depuis le bord de l'écran. */
73
+ offset?: number | string;
74
+ /**
75
+ * Où rendre la pile. Par défaut, un nœud créé sous `document.body`.
76
+ *
77
+ * À fournir quand le thème est porté par un conteneur plutôt que par la
78
+ * racine du document : sorties de ce conteneur, les annonces perdraient
79
+ * ses variables et s'afficheraient dans le thème clair.
80
+ */
81
+ container?: HTMLElement | null | undefined;
82
+ className?: string;
83
+ }
84
+
85
+ /**
86
+ * La pile visible.
87
+ *
88
+ * Un seul `<Toaster />` à la racine de l'application. Il rend les six coins :
89
+ * une annonce peut choisir le sien, ce qui permet à une confirmation
90
+ * d'apparaître près du geste qui l'a déclenchée et à une panne générale de
91
+ * s'imposer en haut de l'écran.
92
+ */
93
+ export function Toaster({
94
+ placement = "bottom-right",
95
+ limit,
96
+ duration,
97
+ width = "24rem",
98
+ offset = "1rem",
99
+ container,
100
+ className,
101
+ }: ToasterProps) {
102
+ const records = useToasts<ReactNode>();
103
+ const node = usePortal(container ? { container } : { id: "sia-toaster" });
104
+ const locale = useSiaLocale();
105
+
106
+ useEffect(() => {
107
+ toastStore.configure({
108
+ ...(limit !== undefined ? { limit } : {}),
109
+ ...(duration !== undefined ? { defaultDuration: duration } : {}),
110
+ });
111
+ }, [duration, limit]);
112
+
113
+ const byPlacement = useMemo(() => {
114
+ const groups = new Map<ToastPlacement, Array<ToastRecord<ReactNode>>>();
115
+ for (const record of records) {
116
+ const corner = record.placement ?? placement;
117
+ const list = groups.get(corner);
118
+ if (list) list.push(record);
119
+ else groups.set(corner, [record]);
120
+ }
121
+ return groups;
122
+ }, [placement, records]);
123
+
124
+ if (!node) return null;
125
+
126
+ return createPortal(
127
+ <>
128
+ {PLACEMENTS.filter((corner) => byPlacement.has(corner)).map((corner) => {
129
+ const list = byPlacement.get(corner) ?? [];
130
+ // En haut, la plus récente arrive au-dessus de la pile; en bas, elle
131
+ // arrive au-dessous. Dans les deux cas elle est la plus proche du
132
+ // bord, donc la première lue.
133
+ const ordered = corner.startsWith("top") ? [...list].reverse() : list;
134
+
135
+ return (
136
+ <div
137
+ key={corner}
138
+ className={cn("sia-toaster", `sia-toaster--${corner}`, className)}
139
+ style={
140
+ {
141
+ "--sia-toaster-width": typeof width === "number" ? `${width}px` : width,
142
+ "--sia-toaster-offset":
143
+ typeof offset === "number" ? `${offset}px` : offset,
144
+ } as CSSProperties
145
+ }
146
+ role="region"
147
+ aria-label={locale.notifications}
148
+ >
149
+ {ordered.map((record) => (
150
+ <ToastItem
151
+ key={record.id}
152
+ record={record}
153
+ placement={corner}
154
+ closeLabel={locale.close}
155
+ />
156
+ ))}
157
+ </div>
158
+ );
159
+ })}
160
+ </>,
161
+ node,
162
+ );
163
+ }
164
+
165
+ interface ToastItemProps {
166
+ record: ToastRecord<ReactNode>;
167
+ placement: ToastPlacement;
168
+ closeLabel: string;
169
+ }
170
+
171
+ function ToastItem({ record, placement, closeLabel }: ToastItemProps) {
172
+ const tone = record.tone ?? "neutral";
173
+ const icon = record.icon === undefined ? TONE_ICON[tone] : record.icon;
174
+ const closable = record.closable ?? tone !== "loading";
175
+ const duration = toastStore.durationOf(record);
176
+ const timed = Number.isFinite(duration) && duration > 0;
177
+
178
+ const dismiss = useCallback(() => toastStore.dismiss(record.id), [record.id]);
179
+
180
+ // Le retrait définitif est déclenché par une minuterie, pas par la fin de
181
+ // l'animation : `animationend` ne se produit pas si l'animation n'a jamais
182
+ // tourné — préférence de mouvement réduit, onglet en arrière-plan — et
183
+ // l'annonce resterait alors dans le magasin pour toujours.
184
+ useEffect(() => {
185
+ if (record.open) return undefined;
186
+ const timer = setTimeout(() => toastStore.remove(record.id), EXIT_DELAY);
187
+ return () => clearTimeout(timer);
188
+ }, [record.id, record.open]);
189
+
190
+ // La fermeture automatique, suspendue au survol et au focus clavier.
191
+ const [paused, setPaused] = useState(false);
192
+ const remaining = useRef(duration);
193
+ const startedAt = useRef(Date.now());
194
+
195
+ // Une mise à jour — ce que fait `toast.promise` — rend son temps entier à
196
+ // l'annonce : la nouvelle phrase doit pouvoir être lue.
197
+ //
198
+ // La remise à zéro se fait pendant le rendu, pas dans un effet. Un effet
199
+ // s'exécuterait *après* celui qui arme la minuterie, qui lirait alors la
200
+ // durée précédente. Quand elle valait `Infinity` — le cas d'une annonce
201
+ // en chargement qui devient un succès — `setTimeout` reçoit une valeur
202
+ // qui déborde son entier 32 bits et se déclenche immédiatement : la carte
203
+ // de succès disparaissait avant d'avoir été vue.
204
+ const signature = `${duration}|${record.title}|${record.description}|${record.tone}`;
205
+ const lastSignature = useRef(signature);
206
+ if (lastSignature.current !== signature) {
207
+ lastSignature.current = signature;
208
+ remaining.current = duration;
209
+ }
210
+
211
+ useEffect(() => {
212
+ if (!timed || !record.open || paused) return undefined;
213
+ if (!Number.isFinite(remaining.current)) return undefined;
214
+
215
+ startedAt.current = Date.now();
216
+ const timer = setTimeout(dismiss, remaining.current);
217
+ return () => {
218
+ clearTimeout(timer);
219
+ remaining.current = Math.max(
220
+ 0,
221
+ remaining.current - (Date.now() - startedAt.current),
222
+ );
223
+ };
224
+ }, [dismiss, paused, record.open, signature, timed]);
225
+
226
+ const swipe = useSwipeToDismiss(placement, dismiss);
227
+
228
+ const run = (action: NonNullable<ToastRecord["action"]>) => {
229
+ action.onSelect();
230
+ if (action.dismiss ?? true) dismiss();
231
+ };
232
+
233
+ return (
234
+ <div
235
+ className={cn(
236
+ "sia-toast",
237
+ `sia-toast--${tone}`,
238
+ `sia-toast--from-${edgeOf(placement)}`,
239
+ record.className,
240
+ )}
241
+ data-state={record.open ? "open" : "closed"}
242
+ data-swiping={swipe.swiping || undefined}
243
+ style={swipe.style}
244
+ // `alert` interrompt la lecture d'écran, `status` attend une pause. Une
245
+ // erreur mérite l'interruption; une confirmation ne la mérite pas.
246
+ role={tone === "danger" ? "alert" : "status"}
247
+ aria-live={tone === "danger" ? "assertive" : "polite"}
248
+ onMouseEnter={() => setPaused(true)}
249
+ onMouseLeave={() => setPaused(false)}
250
+ onFocusCapture={() => setPaused(true)}
251
+ onBlurCapture={() => setPaused(false)}
252
+ onPointerDown={swipe.onPointerDown}
253
+ onPointerMove={swipe.onPointerMove}
254
+ onPointerUp={swipe.onPointerUp}
255
+ onPointerCancel={swipe.onPointerUp}
256
+ >
257
+ {record.render ? (
258
+ record.render({ id: record.id, dismiss })
259
+ ) : (
260
+ <>
261
+ {icon && (
262
+ <span className="sia-toast__icon" aria-hidden="true">
263
+ {icon}
264
+ </span>
265
+ )}
266
+
267
+ <div className="sia-toast__body">
268
+ {record.title && (
269
+ <strong className="sia-toast__title">{record.title}</strong>
270
+ )}
271
+ {record.description && (
272
+ <p className="sia-toast__description">{record.description}</p>
273
+ )}
274
+
275
+ {(record.action || record.cancel) && (
276
+ <div className="sia-toast__actions">
277
+ {record.action && (
278
+ <button
279
+ type="button"
280
+ className="sia-toast__action"
281
+ onClick={() => run(record.action!)}
282
+ >
283
+ {record.action.label}
284
+ </button>
285
+ )}
286
+ {record.cancel && (
287
+ <button
288
+ type="button"
289
+ className="sia-toast__cancel"
290
+ onClick={() => run(record.cancel!)}
291
+ >
292
+ {record.cancel.label}
293
+ </button>
294
+ )}
295
+ </div>
296
+ )}
297
+ </div>
298
+
299
+ {closable && (
300
+ <button
301
+ type="button"
302
+ className="sia-toast__close"
303
+ aria-label={closeLabel}
304
+ onClick={dismiss}
305
+ >
306
+ <XIcon />
307
+ </button>
308
+ )}
309
+
310
+ {timed && record.open && (
311
+ <span
312
+ className="sia-toast__timer"
313
+ style={{ animationDuration: `${duration}ms` }}
314
+ data-paused={paused || undefined}
315
+ aria-hidden="true"
316
+ />
317
+ )}
318
+ </>
319
+ )}
320
+ </div>
321
+ );
322
+ }
323
+
324
+ function edgeOf(placement: ToastPlacement): "left" | "right" | "center" {
325
+ if (placement.endsWith("left")) return "left";
326
+ if (placement.endsWith("center")) return "center";
327
+ return "right";
328
+ }
329
+
330
+ /** Seuil, en pixels, au-delà duquel le geste ferme l'annonce. */
331
+ const SWIPE_THRESHOLD = 60;
332
+
333
+ /**
334
+ * Balayer pour écarter.
335
+ *
336
+ * Le sens suit le bord le plus proche : une annonce à droite se chasse vers
337
+ * la droite. Une pile au centre se chasse verticalement, vers le bord dont
338
+ * elle vient.
339
+ */
340
+ function useSwipeToDismiss(placement: ToastPlacement, dismiss: () => void) {
341
+ const axis = placement.endsWith("center") ? "y" : "x";
342
+ const sign = useMemo(() => {
343
+ if (axis === "y") return placement.startsWith("top") ? -1 : 1;
344
+ return placement.endsWith("left") ? -1 : 1;
345
+ }, [axis, placement]);
346
+
347
+ const origin = useRef<{ x: number; y: number } | null>(null);
348
+ const [offset, setOffset] = useState(0);
349
+ const [swiping, setSwiping] = useState(false);
350
+
351
+ const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
352
+ // Un clic sur un bouton n'est pas un balayage : capter le pointeur ici
353
+ // avalerait l'action.
354
+ if ((event.target as HTMLElement).closest("button")) return;
355
+ if (event.pointerType === "mouse" && event.button !== 0) return;
356
+
357
+ origin.current = { x: event.clientX, y: event.clientY };
358
+ setSwiping(true);
359
+ };
360
+
361
+ const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
362
+ if (!origin.current) return;
363
+
364
+ const delta =
365
+ axis === "x"
366
+ ? event.clientX - origin.current.x
367
+ : event.clientY - origin.current.y;
368
+
369
+ // Seul le sens de la sortie répond; tirer dans l'autre sens ne fait rien,
370
+ // ce qui évite de décoller la carte de son bord.
371
+ setOffset(delta * sign > 0 ? delta : 0);
372
+ };
373
+
374
+ const onPointerUp = () => {
375
+ if (!origin.current) return;
376
+ origin.current = null;
377
+ setSwiping(false);
378
+
379
+ if (Math.abs(offset) >= SWIPE_THRESHOLD) dismiss();
380
+ else setOffset(0);
381
+ };
382
+
383
+ return {
384
+ swiping,
385
+ onPointerDown,
386
+ onPointerMove,
387
+ onPointerUp,
388
+ style:
389
+ offset === 0
390
+ ? undefined
391
+ : ({
392
+ transform:
393
+ axis === "x"
394
+ ? `translate3d(${offset}px, 0, 0)`
395
+ : `translate3d(0, ${offset}px, 0)`,
396
+ opacity: Math.max(0.3, 1 - Math.abs(offset) / 160),
397
+ } as CSSProperties),
398
+ };
399
+ }
400
+
401
+ export interface ToastProviderProps extends PropsWithChildren, ToasterProps {}
402
+
403
+ /**
404
+ * Enveloppe les enfants et monte le `Toaster`.
405
+ *
406
+ * Aucun contexte : le magasin vit hors de React. Ce composant n'existe que
407
+ * pour monter la pile au bon endroit sans y penser.
408
+ */
409
+ export function ToastProvider({ children, ...props }: ToastProviderProps) {
410
+ return (
411
+ <>
412
+ {children}
413
+ <Toaster {...props} />
414
+ </>
415
+ );
416
+ }
417
+
418
+ /**
419
+ * L'API des annonces, pour ceux qui préfèrent un hook.
420
+ *
421
+ * Elle rend exactement l'objet `toast`, dont l'identité ne change jamais :
422
+ * l'appeler dans un `useCallback` ou un `useEffect` ne relance rien.
423
+ */
424
+ export function useToast() {
425
+ return toast;
426
+ }
@@ -0,0 +1,175 @@
1
+ .sia-toaster {
2
+ position: fixed;
3
+ z-index: 70;
4
+ display: flex;
5
+ flex-direction: column;
6
+ gap: .625rem;
7
+ width: min(var(--sia-toaster-width, 24rem), calc(100vw - 2rem));
8
+ padding: var(--sia-toaster-offset, 1rem);
9
+ /* La pile ne doit pas voler les clics à la page; chaque annonce les reprend. */
10
+ pointer-events: none;
11
+ }
12
+ .sia-toaster > * { pointer-events: auto; }
13
+
14
+ .sia-toaster--top-left { top: 0; left: 0; }
15
+ .sia-toaster--top-center { top: 0; left: 50%; transform: translateX(-50%); }
16
+ .sia-toaster--top-right { top: 0; right: 0; }
17
+ .sia-toaster--bottom-left { bottom: 0; left: 0; }
18
+ .sia-toaster--bottom-center { bottom: 0; left: 50%; transform: translateX(-50%); }
19
+ .sia-toaster--bottom-right { bottom: 0; right: 0; }
20
+
21
+ .sia-toast {
22
+ --sia-toast-color: var(--sia-muted);
23
+ position: relative;
24
+ display: flex;
25
+ align-items: flex-start;
26
+ gap: .75rem;
27
+ overflow: hidden;
28
+ padding: .875rem 1rem;
29
+ border: 1px solid var(--sia-border);
30
+ border-radius: calc(var(--sia-radius) + 2px);
31
+ background: var(--sia-surface-raised);
32
+ box-shadow: var(--sia-elevation-overlay);
33
+ /* Le balayage ne doit pas déclencher le défilement de la page. */
34
+ touch-action: none;
35
+ user-select: none;
36
+ }
37
+
38
+ .sia-toast[data-state="open"] {
39
+ animation: sia-toast-in var(--sia-duration-slow) var(--sia-easing-spring) both;
40
+ }
41
+
42
+ /* La sortie est plus courte que l'entrée : ce qui arrive se remarque, ce qui
43
+ part ne doit pas se faire attendre. */
44
+ .sia-toast[data-state="closed"] {
45
+ animation: sia-toast-out var(--sia-duration-base) var(--sia-easing-exit) forwards;
46
+ pointer-events: none;
47
+ }
48
+
49
+ /* Pendant le geste, la carte suit le doigt sans transition : une interpolation
50
+ donnerait un retard visible entre le pointeur et la carte. */
51
+ .sia-toast[data-swiping] { transition: none; cursor: grabbing; }
52
+ .sia-toast:not([data-swiping]) {
53
+ transition:
54
+ transform var(--sia-duration-base) var(--sia-easing-standard),
55
+ opacity var(--sia-duration-base) var(--sia-easing-standard);
56
+ }
57
+
58
+ @keyframes sia-toast-in {
59
+ from { opacity: 0; transform: translate3d(var(--sia-toast-from, 110%), 0, 0) scale(.96); }
60
+ to { opacity: 1; transform: none; }
61
+ }
62
+ @keyframes sia-toast-out {
63
+ from { opacity: 1; transform: none; max-height: 10rem; }
64
+ to { opacity: 0; transform: translate3d(0, -.5rem, 0) scale(.94); max-height: 0; margin-top: -.625rem; padding-block: 0; }
65
+ }
66
+
67
+ .sia-toast--from-right { --sia-toast-from: 110%; }
68
+ .sia-toast--from-left { --sia-toast-from: -110%; }
69
+ .sia-toast--from-center { --sia-toast-from: 0; }
70
+
71
+ .sia-toast__icon {
72
+ display: grid;
73
+ flex: none;
74
+ place-items: center;
75
+ width: 1.5rem;
76
+ height: 1.5rem;
77
+ border-radius: 999px;
78
+ font-size: .875rem;
79
+ background: color-mix(in srgb, var(--sia-toast-color) 14%, transparent);
80
+ color: var(--sia-toast-color);
81
+ /* L'icône entre après le panneau : l'œil suit le bloc, puis le signe. */
82
+ animation: sia-toast-icon var(--sia-duration-slow) var(--sia-easing-spring) both;
83
+ animation-delay: 60ms;
84
+ }
85
+ @keyframes sia-toast-icon {
86
+ from { opacity: 0; transform: scale(.4); }
87
+ to { opacity: 1; transform: none; }
88
+ }
89
+
90
+ .sia-toast__body { flex: 1; display: grid; gap: .15rem; min-width: 0; }
91
+ .sia-toast__title { color: var(--sia-foreground); font-size: .9rem; line-height: 1.35; }
92
+ .sia-toast__description { margin: 0; color: var(--sia-muted); font-size: .825rem; line-height: 1.45; }
93
+
94
+ .sia-toast__actions { display: flex; align-items: center; gap: .5rem; margin-top: .4rem; }
95
+
96
+ .sia-toast__action,
97
+ .sia-toast__cancel {
98
+ padding: .3rem .65rem;
99
+ border-radius: var(--sia-radius);
100
+ font-size: .8rem;
101
+ font-weight: 600;
102
+ cursor: pointer;
103
+ transition:
104
+ background-color var(--sia-duration-fast) var(--sia-easing-standard),
105
+ color var(--sia-duration-fast) var(--sia-easing-standard);
106
+ }
107
+
108
+ /* L'action principale porte la couleur du ton : c'est le geste qu'on attend
109
+ de la personne, et il doit se voir sans lire. */
110
+ .sia-toast__action {
111
+ border: 1px solid color-mix(in srgb, var(--sia-toast-color) 32%, transparent);
112
+ background: color-mix(in srgb, var(--sia-toast-color) 12%, transparent);
113
+ color: var(--sia-toast-color);
114
+ }
115
+ .sia-toast__action:hover {
116
+ background: color-mix(in srgb, var(--sia-toast-color) 20%, transparent);
117
+ }
118
+
119
+ .sia-toast__cancel {
120
+ border: 0;
121
+ background: transparent;
122
+ color: var(--sia-muted);
123
+ }
124
+ .sia-toast__cancel:hover { color: var(--sia-foreground); }
125
+
126
+ .sia-toast__close {
127
+ flex: none;
128
+ padding: .1rem;
129
+ border: 0;
130
+ background: transparent;
131
+ color: var(--sia-muted);
132
+ font-size: 1rem;
133
+ line-height: 1;
134
+ cursor: pointer;
135
+ opacity: .6;
136
+ transition: opacity var(--sia-duration-fast) var(--sia-easing-standard);
137
+ }
138
+ .sia-toast__close:hover { opacity: 1; }
139
+
140
+ /* La barre de temps : elle dit combien il reste, et se fige au survol — la
141
+ suspension de la minuterie et l'animation doivent raconter la même chose. */
142
+ .sia-toast__timer {
143
+ position: absolute;
144
+ inset: auto 0 0 0;
145
+ height: 2px;
146
+ transform-origin: left;
147
+ background: var(--sia-toast-color);
148
+ opacity: .4;
149
+ animation: sia-toast-timer linear forwards;
150
+ }
151
+ .sia-toast__timer[data-paused] { animation-play-state: paused; }
152
+
153
+ @keyframes sia-toast-timer {
154
+ from { transform: scaleX(1); }
155
+ to { transform: scaleX(0); }
156
+ }
157
+
158
+ /* Les tons reprennent ceux d'`Alert` : un même événement doit avoir la même
159
+ couleur en bandeau dans la page et en annonce flottante. */
160
+ .sia-toast--neutral { --sia-toast-color: var(--sia-muted); }
161
+ .sia-toast--loading { --sia-toast-color: var(--sia-muted); }
162
+ .sia-toast--primary { --sia-toast-color: var(--sia-primary); }
163
+ .sia-toast--info { --sia-toast-color: var(--sia-info); }
164
+ .sia-toast--success { --sia-toast-color: var(--sia-success); }
165
+ .sia-toast--warning { --sia-toast-color: var(--sia-warning); }
166
+ .sia-toast--danger { --sia-toast-color: var(--sia-danger); }
167
+
168
+ .sia-toast--danger { border-color: color-mix(in srgb, var(--sia-danger) 28%, var(--sia-border)); }
169
+ .sia-toast--loading .sia-toast__icon { background: transparent; }
170
+
171
+ @media (max-width: 640px) {
172
+ .sia-toaster { width: 100vw; padding: .75rem; }
173
+ .sia-toast--from-right,
174
+ .sia-toast--from-left { --sia-toast-from: 0; }
175
+ }