@sia-ui/cli 0.7.1 → 0.8.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 (99) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +6 -0
  3. package/dist/registry/package/components/AccountMenu/index.tsx +107 -0
  4. package/dist/registry/package/components/AccountMenu/styles.css +30 -0
  5. package/dist/registry/package/components/ActivityLog/index.tsx +96 -25
  6. package/dist/registry/package/components/AmountDisplay/index.tsx +5 -2
  7. package/dist/registry/package/components/AppShell/index.tsx +44 -8
  8. package/dist/registry/package/components/AppShell/tab-bar.tsx +6 -3
  9. package/dist/registry/package/components/AsyncState/index.tsx +29 -7
  10. package/dist/registry/package/components/AuditMeta/index.tsx +12 -2
  11. package/dist/registry/package/components/Avatar/index.tsx +4 -9
  12. package/dist/registry/package/components/Badge/index.tsx +3 -2
  13. package/dist/registry/package/components/Badge/styles.css +4 -4
  14. package/dist/registry/package/components/BrandIcon/index.tsx +100 -0
  15. package/dist/registry/package/components/BrandIcon/styles.css +31 -0
  16. package/dist/registry/package/components/Breadcrumb/index.tsx +14 -2
  17. package/dist/registry/package/components/Button/index.tsx +3 -2
  18. package/dist/registry/package/components/Button/styles.css +4 -4
  19. package/dist/registry/package/components/Calendar/index.tsx +7 -3
  20. package/dist/registry/package/components/Card/index.tsx +14 -4
  21. package/dist/registry/package/components/ColorModeToggle/index.tsx +44 -0
  22. package/dist/registry/package/components/CommandPalette/index.tsx +3 -2
  23. package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -4
  24. package/dist/registry/package/components/CrudPage/dialogs.tsx +53 -8
  25. package/dist/registry/package/components/CrudPage/index.tsx +99 -25
  26. package/dist/registry/package/components/CurrencyInput/index.tsx +5 -2
  27. package/dist/registry/package/components/DataTable/index.tsx +53 -18
  28. package/dist/registry/package/components/DataTable/row-actions.tsx +20 -8
  29. package/dist/registry/package/components/DataTable/types.ts +100 -0
  30. package/dist/registry/package/components/DatePicker/index.tsx +14 -6
  31. package/dist/registry/package/components/DateRangeFilter/index.tsx +12 -3
  32. package/dist/registry/package/components/DateRangePicker/index.tsx +13 -4
  33. package/dist/registry/package/components/DateTimePicker/index.tsx +3 -1
  34. package/dist/registry/package/components/Drawer/index.tsx +16 -6
  35. package/dist/registry/package/components/DropdownMenu/index.tsx +37 -4
  36. package/dist/registry/package/components/DropdownMenu/styles.css +10 -0
  37. package/dist/registry/package/components/DurationDisplay/index.tsx +20 -2
  38. package/dist/registry/package/components/EntityMeta/index.tsx +14 -9
  39. package/dist/registry/package/components/ErrorState/index.tsx +9 -3
  40. package/dist/registry/package/components/ErrorState/styles.css +1 -1
  41. package/dist/registry/package/components/EventCalendar/index.tsx +657 -9
  42. package/dist/registry/package/components/EventCalendar/styles.css +360 -8
  43. package/dist/registry/package/components/EventManager/index.tsx +456 -0
  44. package/dist/registry/package/components/EventManager/styles.css +12 -0
  45. package/dist/registry/package/components/Field/index.tsx +74 -27
  46. package/dist/registry/package/components/FileUpload/index.tsx +13 -3
  47. package/dist/registry/package/components/FiltersBar/index.tsx +8 -3
  48. package/dist/registry/package/components/FiltersBar/styles.css +7 -0
  49. package/dist/registry/package/components/Form/index.tsx +118 -14
  50. package/dist/registry/package/components/Form/styles.css +3 -0
  51. package/dist/registry/package/components/HoverCard/index.tsx +8 -4
  52. package/dist/registry/package/components/Icons/index.tsx +18 -0
  53. package/dist/registry/package/components/ImageUpload/index.tsx +17 -2
  54. package/dist/registry/package/components/Input/index.tsx +3 -2
  55. package/dist/registry/package/components/JobProgress/index.tsx +289 -0
  56. package/dist/registry/package/components/JobProgress/styles.css +58 -0
  57. package/dist/registry/package/components/JsonEditor/index.tsx +18 -3
  58. package/dist/registry/package/components/JsonView/index.tsx +282 -0
  59. package/dist/registry/package/components/JsonView/styles.css +99 -0
  60. package/dist/registry/package/components/LiveIndicator/index.tsx +43 -0
  61. package/dist/registry/package/components/LiveIndicator/styles.css +41 -0
  62. package/dist/registry/package/components/LogStream/index.tsx +545 -0
  63. package/dist/registry/package/components/LogStream/styles.css +152 -0
  64. package/dist/registry/package/components/MarkdownEditor/index.tsx +14 -3
  65. package/dist/registry/package/components/Modal/index.tsx +7 -1
  66. package/dist/registry/package/components/MultiSelect/index.tsx +14 -3
  67. package/dist/registry/package/components/OtpInput/index.tsx +2 -1
  68. package/dist/registry/package/components/Pagination/index.tsx +13 -2
  69. package/dist/registry/package/components/PasswordInput/index.tsx +23 -9
  70. package/dist/registry/package/components/PhoneInput/index.tsx +66 -30
  71. package/dist/registry/package/components/Popover/index.tsx +19 -4
  72. package/dist/registry/package/components/QrCode/index.tsx +3 -2
  73. package/dist/registry/package/components/RadioGroup/styles.css +3 -0
  74. package/dist/registry/package/components/Rating/index.tsx +6 -2
  75. package/dist/registry/package/components/ReferenceSelect/index.tsx +8 -1
  76. package/dist/registry/package/components/RelativeTime/index.tsx +5 -1
  77. package/dist/registry/package/components/RequireSession/index.tsx +10 -4
  78. package/dist/registry/package/components/Resource/index.tsx +70 -17
  79. package/dist/registry/package/components/RichTextEditor/index.tsx +2 -1
  80. package/dist/registry/package/components/SearchInput/index.tsx +4 -1
  81. package/dist/registry/package/components/SecretFields/index.tsx +70 -17
  82. package/dist/registry/package/components/Select/index.tsx +34 -12
  83. package/dist/registry/package/components/Sidebar/index.tsx +22 -6
  84. package/dist/registry/package/components/Slider/index.tsx +26 -5
  85. package/dist/registry/package/components/Spinner/index.tsx +3 -2
  86. package/dist/registry/package/components/StatCard/index.tsx +11 -3
  87. package/dist/registry/package/components/Statistic/index.tsx +5 -1
  88. package/dist/registry/package/components/Steps/styles.css +2 -2
  89. package/dist/registry/package/components/TagsInput/index.tsx +2 -1
  90. package/dist/registry/package/components/TimePicker/index.tsx +10 -4
  91. package/dist/registry/package/components/Toast/index.tsx +20 -5
  92. package/dist/registry/package/components/Tour/index.tsx +2 -1
  93. package/dist/registry/package/components/Transfer/index.tsx +2 -1
  94. package/dist/registry/package/components/Tree/index.tsx +3 -2
  95. package/dist/registry/package/components/TreeSelect/index.tsx +17 -4
  96. package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +34 -3
  97. package/dist/registry/registry.json +257 -75
  98. package/dist/registry/styles/sia-ui.css +23 -4
  99. package/package.json +5 -5
@@ -1,7 +1,7 @@
1
1
  import { type HTMLAttributes } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
3
  import type { ComponentTone } from "@sia-ui/tokens";
4
- import { useComponentDefaults } from "@sia-ui/headless";
4
+ import { useComponentDefaults, useSiaLocale } from "@sia-ui/headless";
5
5
  import "./styles.css";
6
6
 
7
7
  export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
@@ -20,7 +20,8 @@ export interface SpinnerProps extends HTMLAttributes<HTMLSpanElement> {
20
20
  */
21
21
  export function Spinner({ label: labelProp, size: sizeProp, variant: variantProp, tone: toneProp, className, ...props }: SpinnerProps) {
22
22
  const defaults = useComponentDefaults<SpinnerProps>("spinner");
23
- const label = labelProp ?? defaults.label ?? "Chargement";
23
+ const locale = useSiaLocale();
24
+ const label = labelProp ?? defaults.label ?? locale.loading;
24
25
  const size = sizeProp ?? defaults.size ?? "md";
25
26
  const variant = variantProp ?? defaults.variant ?? "ring";
26
27
  const tone = toneProp ?? defaults.tone ?? "current";
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
- import { Card, CardBody } from "../Card";
3
+ import { Card, CardBody, type CardBodyProps, type CardProps } from "../Card";
4
4
  import { Statistic, type StatisticProps } from "../Statistic";
5
5
  import "./styles.css";
6
6
 
@@ -8,6 +8,14 @@ export interface StatCardProps extends StatisticProps {
8
8
  icon?: ReactNode;
9
9
  description?: ReactNode;
10
10
  action?: ReactNode;
11
+ /**
12
+ * Les props de la `Card` qui entoure l'indicateur — un `id`, un
13
+ * `aria-label`, un gestionnaire de clic. Sans `children` : le contenu est
14
+ * celui de l'indicateur. `className` s'ajoute à celui de la carte.
15
+ */
16
+ cardProps?: Partial<Omit<CardProps, "children">>;
17
+ /** Les props du `CardBody`, sans `children`, rempli par l'indicateur. */
18
+ cardBodyProps?: Partial<Omit<CardBodyProps, "children">>;
11
19
  }
12
20
 
13
21
  /**
@@ -17,6 +25,6 @@ export interface StatCardProps extends StatisticProps {
17
25
  * presque toujours ensemble, et qu'il serait fastidieux de recomposer à
18
26
  * chaque tableau de bord.
19
27
  */
20
- export function StatCard({ icon, description, action, className, ...statisticProps }: StatCardProps) {
21
- return <Card className={cn("sia-stat-card", className)}><CardBody><div className="sia-stat-card__top">{icon && <span className="sia-stat-card__icon" aria-hidden="true">{icon}</span>}{action}</div><Statistic {...statisticProps} />{description && <div className="sia-stat-card__description">{description}</div>}</CardBody></Card>;
28
+ export function StatCard({ icon, description, action, className, cardProps, cardBodyProps, ...statisticProps }: StatCardProps) {
29
+ return <Card {...cardProps} className={cn("sia-stat-card", className, cardProps?.className)}><CardBody {...cardBodyProps}><div className="sia-stat-card__top">{icon && <span className="sia-stat-card__icon" aria-hidden="true">{icon}</span>}{action}</div><Statistic {...statisticProps} />{description && <div className="sia-stat-card__description">{description}</div>}</CardBody></Card>;
22
30
  }
@@ -1,11 +1,13 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { cn, formatCurrency } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
 
5
6
  export interface StatisticProps {
6
7
  value: number;
7
8
  label?: ReactNode;
8
9
  format?: "number" | "currency" | "percent";
10
+ /** La langue du format. Par défaut, celle de la locale SIA. */
9
11
  locale?: string;
10
12
  currency?: string;
11
13
  precision?: number;
@@ -22,7 +24,9 @@ export interface StatisticProps {
22
24
  * décide du sens et de la couleur. Une flèche passée en prop finit par
23
25
  * pointer vers le haut sur une baisse.
24
26
  */
25
- export function Statistic({ value, label, format = "number", locale = "fr-FR", currency = "XAF", precision, prefix, suffix, trend, className }: StatisticProps) {
27
+ export function Statistic({ value, label, format = "number", locale: localeProp, currency = "XAF", precision, prefix, suffix, trend, className }: StatisticProps) {
28
+ const messages = useSiaLocale();
29
+ const locale = localeProp ?? messages.language;
26
30
  const formatted = format === "currency"
27
31
  ? formatCurrency(value, { locale, currency, ...(precision !== undefined ? { minimumFractionDigits: precision, maximumFractionDigits: precision } : {}) })
28
32
  : new Intl.NumberFormat(locale, format === "percent" ? { style: "percent", ...(precision !== undefined ? { minimumFractionDigits: precision, maximumFractionDigits: precision } : {}) } : { ...(precision !== undefined ? { minimumFractionDigits: precision, maximumFractionDigits: precision } : {}) }).format(value);
@@ -34,7 +34,7 @@
34
34
  .sia-steps__item[data-status="done"] .sia-steps__bullet {
35
35
  border-color: var(--sia-success);
36
36
  background: var(--sia-success);
37
- color: #fff;
37
+ color: var(--sia-success-foreground);
38
38
  /* La coche tombe avec un léger rebond quand l'étape bascule. */
39
39
  animation: sia-step-done var(--sia-duration-slow) var(--sia-easing-spring);
40
40
  }
@@ -68,7 +68,7 @@
68
68
  .sia-steps__item[data-status="error"] .sia-steps__bullet {
69
69
  border-color: var(--sia-danger);
70
70
  background: var(--sia-danger);
71
- color: #fff;
71
+ color: var(--sia-danger-foreground);
72
72
  }
73
73
 
74
74
  .sia-steps__connector { position: relative; overflow: hidden; background: var(--sia-border); }
@@ -1,5 +1,6 @@
1
1
  import { useState, type KeyboardEvent } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
3
4
  import { XIcon } from "../Icons";
4
5
  import "./styles.css";
5
6
  export interface TagsInputProps { id?: string; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void; placeholder?: string; separators?: string[]; maxTags?: number; disabled?: boolean; className?: string; "aria-describedby"?: string; "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-required"?: boolean; }
@@ -10,4 +11,4 @@ export interface TagsInputProps { id?: string; value?: string[]; defaultValue?:
10
11
  * que ce sont les deux touches que l'on presse sans y penser après avoir
11
12
  * tapé un mot.
12
13
  */
13
- export function TagsInput({ id, value, defaultValue = [], onValueChange, placeholder = "Ajouter un tag", separators = ["Enter", ","], maxTags, disabled, className, ...ariaProps }: TagsInputProps) { const [internal, setInternal] = useState(defaultValue); const [draft, setDraft] = useState(""); const current = value ?? internal; const update = (next: string[]) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; const add = () => { const tag = draft.trim().replace(/,$/, ""); if (!tag || current.includes(tag) || (maxTags !== undefined && current.length >= maxTags)) return; update([...current, tag]); setDraft(""); }; return <div id={id} className={cn("sia-tags-input", className)} {...ariaProps}>{current.map((tag) => <span key={tag}>{tag}<button type="button" disabled={disabled} aria-label={`Retirer ${tag}`} onClick={() => update(current.filter((item) => item !== tag))}><XIcon /></button></span>)}<input value={draft} disabled={disabled} placeholder={placeholder} onChange={(event) => setDraft(event.target.value)} onBlur={add} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { if (separators.includes(event.key)) { event.preventDefault(); add(); } if (event.key === "Backspace" && !draft && current.length) update(current.slice(0, -1)); }} /></div>; }
14
+ export function TagsInput({ id, value, defaultValue = [], onValueChange, placeholder: placeholderProp, separators = ["Enter", ","], maxTags, disabled, className, ...ariaProps }: TagsInputProps) { const messages = useSiaLocale().tagsInput; const placeholder = placeholderProp ?? messages.placeholder; const [internal, setInternal] = useState(defaultValue); const [draft, setDraft] = useState(""); const current = value ?? internal; const update = (next: string[]) => { if (value === undefined) setInternal(next); onValueChange?.(next); }; const add = () => { const tag = draft.trim().replace(/,$/, ""); if (!tag || current.includes(tag) || (maxTags !== undefined && current.length >= maxTags)) return; update([...current, tag]); setDraft(""); }; return <div id={id} className={cn("sia-tags-input", className)} {...ariaProps}>{current.map((tag) => <span key={tag}>{tag}<button type="button" disabled={disabled} aria-label={formatMessage(messages.removeTag, { name: tag })} onClick={() => update(current.filter((item) => item !== tag))}><XIcon /></button></span>)}<input value={draft} disabled={disabled} placeholder={placeholder} onChange={(event) => setDraft(event.target.value)} onBlur={add} onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => { if (separators.includes(event.key)) { event.preventDefault(); add(); } if (event.key === "Backspace" && !draft && current.length) update(current.slice(0, -1)); }} /></div>; }
@@ -1,8 +1,9 @@
1
1
  import { forwardRef, useId, useMemo, useRef, useState, type ReactNode } from "react";
2
2
  import { cn, toneClass } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import type { ComponentTone } from "@sia-ui/tokens";
4
5
  import { CheckIcon, ChevronDownIcon, ClockIcon, XIcon } from "../Icons";
5
- import { Overlay, type OverlayPlacement } from "../Overlay";
6
+ import { Overlay, type OverlayPlacement, type OverlayProps } from "../Overlay";
6
7
  import { formatTimeValue, parseTimeValue, type TimeParts } from "../Timer";
7
8
  import "./styles.css";
8
9
 
@@ -19,6 +20,7 @@ export interface TimePickerProps {
19
20
  hourFormat?: 12 | 24;
20
21
  needConfirm?: boolean;
21
22
  allowClear?: boolean;
23
+ /** Par défaut, « Sélectionner une heure » de la locale SIA. */
22
24
  placeholder?: string;
23
25
  disabledTime?: (value: TimeParts) => boolean;
24
26
  renderExtraFooter?: () => ReactNode;
@@ -26,6 +28,8 @@ export interface TimePickerProps {
26
28
  onOpenChange?: (open: boolean) => void;
27
29
  open?: boolean;
28
30
  placement?: OverlayPlacement;
31
+ /** Réglages du panneau flottant — décalage, marge au bord, classe. */
32
+ overlayProps?: Partial<Omit<OverlayProps, "open" | "anchorRef" | "onOpenChange" | "children">>;
29
33
  tone?: ComponentTone;
30
34
  invalid?: boolean;
31
35
  disabled?: boolean;
@@ -50,8 +54,10 @@ function steppedValues(limit: number, step: number) {
50
54
  * demande — la plupart des heures métier n'en ont pas.
51
55
  */
52
56
  export const TimePicker = forwardRef<HTMLButtonElement, TimePickerProps>(function TimePicker({
53
- id, name, value, defaultValue = "", min, max, showSeconds = false, minuteStep = 1, secondStep = 1, hourFormat = 24, needConfirm = false, allowClear = true, placeholder = "Sélectionner une heure", disabledTime, renderExtraFooter, onValueChange, onOpenChange, open: openProp, placement = "bottom-start", tone = "primary", invalid = false, disabled = false, required = false, className, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, ...ariaProps
57
+ id, name, value, defaultValue = "", min, max, showSeconds = false, minuteStep = 1, secondStep = 1, hourFormat = 24, needConfirm = false, allowClear = true, placeholder: placeholderProp, disabledTime, renderExtraFooter, onValueChange, onOpenChange, open: openProp, placement = "bottom-start", overlayProps, tone = "primary", invalid = false, disabled = false, required = false, className, "aria-invalid": ariaInvalid, "aria-required": ariaRequired, ...ariaProps
54
58
  }, forwardedRef) {
59
+ const locale = useSiaLocale();
60
+ const placeholder = placeholderProp ?? locale.selectTime;
55
61
  const generatedId = useId();
56
62
  const controlId = id ?? `sia-time-picker-${generatedId.replace(/:/g, "")}`;
57
63
  const anchorRef = useRef<HTMLButtonElement>(null);
@@ -76,8 +82,8 @@ export const TimePicker = forwardRef<HTMLButtonElement, TimePickerProps>(functio
76
82
  const renderColumn = (label: string, values: number[], selected: number, key: keyof TimeParts) => <div className="sia-time-panel__column" role="listbox" aria-label={label}>{values.map((option) => { const candidate = { ...draft, [key]: option }; const serialized = formatTimeValue(candidate, showSeconds); const optionDisabled = Boolean((min && serialized < min) || (max && serialized > max) || disabledTime?.(candidate)); const display = key === "hours" && hourFormat === 12 ? ((option + 11) % 12) + 1 : option; return <button type="button" role="option" aria-selected={selected === option} disabled={optionDisabled} key={option} onClick={() => select({ [key]: option })}><span>{String(display).padStart(2, "0")}</span>{selected === option && <CheckIcon />}</button>; })}</div>;
77
83
  return <div className={cn("sia-picker", "sia-time-picker", toneClass("sia-tone", tone), invalid && "sia-picker--invalid", disabled && "sia-picker--disabled", className)}>
78
84
  {name && <input type="hidden" name={name} value={current} />}
79
- <button ref={setRef} id={controlId} type="button" className="sia-picker__trigger" disabled={disabled} aria-haspopup="dialog" aria-expanded={open} aria-invalid={ariaInvalid ?? (invalid || undefined)} aria-required={ariaRequired ?? (required || undefined)} onClick={() => setOpen(!open)} {...ariaProps}><ClockIcon className="sia-picker__icon" /><span className={cn("sia-picker__value", !current && "sia-picker__placeholder")}>{current || placeholder}</span><span className="sia-picker__actions">{allowClear && current && <span className="sia-picker__clear" title="Effacer" aria-hidden="true" onClick={(event) => { event.stopPropagation(); update(""); }}><XIcon /></span>}<ChevronDownIcon /></span></button>
80
- <Overlay open={open} anchorRef={anchorRef} onOpenChange={setOpen} placement={placement} className="sia-picker__overlay"><div className={cn("sia-time-panel", toneClass("sia-tone", tone))}><div className="sia-time-panel__columns">{renderColumn("Heures", hours, draft.hours, "hours")}{renderColumn("Minutes", minutes, draft.minutes, "minutes")}{showSeconds && renderColumn("Secondes", seconds, draft.seconds, "seconds")}</div><footer className="sia-time-panel__footer">{renderExtraFooter?.()}<button type="button" className="sia-time-panel__now" onClick={() => setDraft(parseTimeValue(new Date().toTimeString().slice(0, 8)))}>Maintenant</button>{needConfirm && <button type="button" className="sia-time-panel__confirm" onClick={confirm}>OK</button>}</footer></div></Overlay>
85
+ <button ref={setRef} id={controlId} type="button" className="sia-picker__trigger" disabled={disabled} aria-haspopup="dialog" aria-expanded={open} aria-invalid={ariaInvalid ?? (invalid || undefined)} aria-required={ariaRequired ?? (required || undefined)} onClick={() => setOpen(!open)} {...ariaProps}><ClockIcon className="sia-picker__icon" /><span className={cn("sia-picker__value", !current && "sia-picker__placeholder")}>{current || placeholder}</span><span className="sia-picker__actions">{allowClear && current && <span className="sia-picker__clear" title={locale.clear} aria-hidden="true" onClick={(event) => { event.stopPropagation(); update(""); }}><XIcon /></span>}<ChevronDownIcon /></span></button>
86
+ <Overlay placement={placement} {...overlayProps} className={cn("sia-picker__overlay", overlayProps?.className)} open={open} anchorRef={anchorRef} onOpenChange={setOpen}><div className={cn("sia-time-panel", toneClass("sia-tone", tone))}><div className="sia-time-panel__columns">{renderColumn(locale.timePicker.hours, hours, draft.hours, "hours")}{renderColumn(locale.timePicker.minutes, minutes, draft.minutes, "minutes")}{showSeconds && renderColumn(locale.timePicker.seconds, seconds, draft.seconds, "seconds")}</div><footer className="sia-time-panel__footer">{renderExtraFooter?.()}<button type="button" className="sia-time-panel__now" onClick={() => setDraft(parseTimeValue(new Date().toTimeString().slice(0, 8)))}>{locale.timePicker.now}</button>{needConfirm && <button type="button" className="sia-time-panel__confirm" onClick={confirm}>{locale.timePicker.ok}</button>}</footer></div></Overlay>
81
87
  </div>;
82
88
  });
83
89
  TimePicker.displayName = "TimePicker";
@@ -24,7 +24,7 @@ import {
24
24
  } from "@sia-ui/headless";
25
25
  import { usePortal } from "@sia-ui/react";
26
26
  import { AlertCircleIcon, CheckIcon, InfoIcon, XIcon } from "../Icons";
27
- import { Spinner } from "../Spinner";
27
+ import { Spinner, type SpinnerProps } from "../Spinner";
28
28
  import "./styles.css";
29
29
 
30
30
  export type { ToastId, ToastOptions, ToastPlacement, ToastTone };
@@ -38,14 +38,15 @@ export type { ToastId, ToastOptions, ToastPlacement, ToastTone };
38
38
  */
39
39
  export { toast, toastStore };
40
40
 
41
- const TONE_ICON: Record<ToastTone, ReactNode> = {
41
+ // Le ton `loading` n'y figure pas : son `Spinner` se règle par
42
+ // `spinnerProps`, il est donc rendu par l'annonce elle-même.
43
+ const TONE_ICON: Record<Exclude<ToastTone, "loading">, ReactNode> = {
42
44
  neutral: null,
43
45
  primary: <InfoIcon />,
44
46
  info: <InfoIcon />,
45
47
  success: <CheckIcon />,
46
48
  warning: <AlertCircleIcon />,
47
49
  danger: <AlertCircleIcon />,
48
- loading: <Spinner size="sm" tone="current" />,
49
50
  };
50
51
 
51
52
  /** Le temps laissé à l'animation de sortie avant le retrait définitif. */
@@ -80,6 +81,12 @@ export interface ToasterProps {
80
81
  */
81
82
  container?: HTMLElement | null | undefined;
82
83
  className?: string;
84
+ /**
85
+ * Les props du `Spinner` des annonces en attente (`toast.loading`,
86
+ * `toast.promise`) — variante, taille. Sans effet sur une annonce qui
87
+ * fournit sa propre `icon`.
88
+ */
89
+ spinnerProps?: Partial<SpinnerProps>;
83
90
  }
84
91
 
85
92
  /**
@@ -98,6 +105,7 @@ export function Toaster({
98
105
  offset = "1rem",
99
106
  container,
100
107
  className,
108
+ spinnerProps,
101
109
  }: ToasterProps) {
102
110
  const records = useToasts<ReactNode>();
103
111
  const node = usePortal(container ? { container } : { id: "sia-toaster" });
@@ -152,6 +160,7 @@ export function Toaster({
152
160
  record={record}
153
161
  placement={corner}
154
162
  closeLabel={locale.close}
163
+ {...(spinnerProps ? { spinnerProps } : {})}
155
164
  />
156
165
  ))}
157
166
  </div>
@@ -166,11 +175,17 @@ interface ToastItemProps {
166
175
  record: ToastRecord<ReactNode>;
167
176
  placement: ToastPlacement;
168
177
  closeLabel: string;
178
+ spinnerProps?: Partial<SpinnerProps>;
169
179
  }
170
180
 
171
- function ToastItem({ record, placement, closeLabel }: ToastItemProps) {
181
+ function ToastItem({ record, placement, closeLabel, spinnerProps }: ToastItemProps) {
172
182
  const tone = record.tone ?? "neutral";
173
- const icon = record.icon === undefined ? TONE_ICON[tone] : record.icon;
183
+ const icon =
184
+ record.icon !== undefined
185
+ ? record.icon
186
+ : tone === "loading"
187
+ ? <Spinner size="sm" tone="current" {...spinnerProps} />
188
+ : TONE_ICON[tone];
174
189
  const closable = record.closable ?? tone !== "loading";
175
190
  const duration = toastStore.durationOf(record);
176
191
  const timed = Number.isFinite(duration) && duration > 0;
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState, type ReactNode } from "react";
2
2
  import { createPortal } from "react-dom";
3
3
  import { cn } from "@sia-ui/utils";
4
+ import { formatMessage, useSiaLocale } from "@sia-ui/headless";
4
5
  import "./styles.css";
5
6
 
6
7
  export interface TourStep { target: string; title: ReactNode; description?: ReactNode; }
@@ -12,4 +13,4 @@ export interface TourProps { open: boolean; onOpenChange: (open: boolean) => voi
12
13
  * référence : une visite se déclare souvent loin des composants qu'elle
13
14
  * montre, et parfois avant qu'ils n'existent.
14
15
  */
15
- export function Tour({ open, onOpenChange, steps, initialStep = 0, onStepChange, className }: TourProps) { const [index, setIndex] = useState(initialStep); useEffect(() => { if (open) setIndex(initialStep); }, [open, initialStep]); useEffect(() => { if (!open) return; const target = document.querySelector<HTMLElement>(steps[index]?.target ?? ""); target?.scrollIntoView({ block: "center", behavior: "smooth" }); target?.setAttribute("data-sia-tour-active", "true"); return () => target?.removeAttribute("data-sia-tour-active"); }, [open, index, steps]); if (!open || typeof document === "undefined" || steps.length === 0) return null; const step = steps[index]; const move = (next: number) => { if (next >= steps.length) { onOpenChange(false); return; } setIndex(next); onStepChange?.(next); }; return createPortal(<div className="sia-tour-backdrop"><section className={cn("sia-tour", className)} role="dialog" aria-modal="true"><small>{index + 1} / {steps.length}</small><h2>{step?.title}</h2>{step?.description && <p>{step.description}</p>}<footer><button type="button" onClick={() => onOpenChange(false)}>Fermer</button><button type="button" disabled={index === 0} onClick={() => move(index - 1)}>Précédent</button><button type="button" onClick={() => move(index + 1)}>{index === steps.length - 1 ? "Terminer" : "Suivant"}</button></footer></section></div>, document.body); }
16
+ export function Tour({ open, onOpenChange, steps, initialStep = 0, onStepChange, className }: TourProps) { const locale = useSiaLocale(); const [index, setIndex] = useState(initialStep); useEffect(() => { if (open) setIndex(initialStep); }, [open, initialStep]); useEffect(() => { if (!open) return; const target = document.querySelector<HTMLElement>(steps[index]?.target ?? ""); target?.scrollIntoView({ block: "center", behavior: "smooth" }); target?.setAttribute("data-sia-tour-active", "true"); return () => target?.removeAttribute("data-sia-tour-active"); }, [open, index, steps]); if (!open || typeof document === "undefined" || steps.length === 0) return null; const step = steps[index]; const move = (next: number) => { if (next >= steps.length) { onOpenChange(false); return; } setIndex(next); onStepChange?.(next); }; return createPortal(<div className="sia-tour-backdrop"><section className={cn("sia-tour", className)} role="dialog" aria-modal="true"><small>{formatMessage(locale.tour.progress, { current: index + 1, total: steps.length })}</small><h2>{step?.title}</h2>{step?.description && <p>{step.description}</p>}<footer><button type="button" onClick={() => onOpenChange(false)}>{locale.close}</button><button type="button" disabled={index === 0} onClick={() => move(index - 1)}>{locale.previous}</button><button type="button" onClick={() => move(index + 1)}>{index === steps.length - 1 ? locale.tour.finish : locale.next}</button></footer></section></div>, document.body); }
@@ -1,5 +1,6 @@
1
1
  import { useMemo, useState, type Key, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { useSiaLocale } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
 
5
6
  export interface TransferItem { key: Key; label: ReactNode; disabled?: boolean; }
@@ -11,4 +12,4 @@ export interface TransferProps { items: TransferItem[]; targetKeys: Key[]; onCha
11
12
  * cocher de deux cents lignes ne montre plus ce qui est retenu, alors que
12
13
  * la colonne de droite ne contient que cela.
13
14
  */
14
- export function Transfer({ items, targetKeys, onChange, sourceTitle = "Disponibles", targetTitle = "Sélectionnés", className }: TransferProps) { const [checked, setChecked] = useState<Key[]>([]); const target = useMemo(() => new Set(targetKeys), [targetKeys]); const renderList = (list: TransferItem[], title: string) => <section><header>{title} <span>{list.length}</span></header>{list.map((item) => <label key={item.key}><input type="checkbox" disabled={item.disabled} checked={checked.includes(item.key)} onChange={(event) => setChecked((current) => event.target.checked ? [...current, item.key] : current.filter((key) => key !== item.key))} />{item.label}</label>)}</section>; const sourceItems = items.filter((item) => !target.has(item.key)); const targetItems = items.filter((item) => target.has(item.key)); return <div className={cn("sia-transfer", className)}>{renderList(sourceItems, sourceTitle)}<div className="sia-transfer__actions"><button type="button" aria-label="Ajouter" onClick={() => { onChange([...new Set([...targetKeys, ...checked])]); setChecked([]); }}>→</button><button type="button" aria-label="Retirer" onClick={() => { onChange(targetKeys.filter((key) => !checked.includes(key))); setChecked([]); }}>←</button></div>{renderList(targetItems, targetTitle)}</div>; }
15
+ export function Transfer({ items, targetKeys, onChange, sourceTitle: sourceTitleProp, targetTitle: targetTitleProp, className }: TransferProps) { const messages = useSiaLocale().transfer; const sourceTitle = sourceTitleProp ?? messages.sourceTitle; const targetTitle = targetTitleProp ?? messages.targetTitle; const [checked, setChecked] = useState<Key[]>([]); const target = useMemo(() => new Set(targetKeys), [targetKeys]); const renderList = (list: TransferItem[], title: string) => <section><header>{title} <span>{list.length}</span></header>{list.map((item) => <label key={item.key}><input type="checkbox" disabled={item.disabled} checked={checked.includes(item.key)} onChange={(event) => setChecked((current) => event.target.checked ? [...current, item.key] : current.filter((key) => key !== item.key))} />{item.label}</label>)}</section>; const sourceItems = items.filter((item) => !target.has(item.key)); const targetItems = items.filter((item) => target.has(item.key)); return <div className={cn("sia-transfer", className)}>{renderList(sourceItems, sourceTitle)}<div className="sia-transfer__actions"><button type="button" aria-label={messages.moveToTarget} onClick={() => { onChange([...new Set([...targetKeys, ...checked])]); setChecked([]); }}>→</button><button type="button" aria-label={messages.moveToSource} onClick={() => { onChange(targetKeys.filter((key) => !checked.includes(key))); setChecked([]); }}>←</button></div>{renderList(targetItems, targetTitle)}</div>; }
@@ -1,10 +1,11 @@
1
1
  import { useState, type Key, type ReactNode } from "react";
2
2
  import { cn } from "@sia-ui/utils";
3
+ import { useSiaLocale, type TreeMessages } from "@sia-ui/headless";
3
4
  import "./styles.css";
4
5
 
5
6
  export interface TreeNode { key: Key; label: ReactNode; children?: TreeNode[]; disabled?: boolean; }
6
7
  export interface TreeProps { nodes: TreeNode[]; defaultExpandedKeys?: Key[]; selectedKey?: Key | undefined; onSelect?: ((node: TreeNode) => void) | undefined; className?: string; }
7
- function TreeBranch({ nodes, level, expanded, toggle, selectedKey, onSelect }: { nodes: TreeNode[]; level: number; expanded: Set<Key>; toggle: (key: Key) => void; selectedKey?: Key | undefined; onSelect?: ((node: TreeNode) => void) | undefined }) { return <ul role={level === 1 ? "tree" : "group"}>{nodes.map((node) => { const hasChildren = Boolean(node.children?.length); const isExpanded = expanded.has(node.key); return <li key={node.key} role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={selectedKey === node.key} aria-disabled={node.disabled || undefined}><div className="sia-tree__row" style={{ paddingInlineStart: `${(level - 1) * 1.25}rem` }}>{hasChildren ? <button type="button" aria-label={isExpanded ? "Réduire" : "Développer"} onClick={() => toggle(node.key)}>{isExpanded ? "−" : "+"}</button> : <span className="sia-tree__spacer" />}<button type="button" disabled={node.disabled} className="sia-tree__label" onClick={() => onSelect?.(node)}>{node.label}</button></div>{hasChildren && isExpanded && <TreeBranch nodes={node.children ?? []} level={level + 1} expanded={expanded} toggle={toggle} selectedKey={selectedKey} onSelect={onSelect} />}</li>; })}</ul>; }
8
+ function TreeBranch({ nodes, level, expanded, toggle, selectedKey, onSelect, messages }: { nodes: TreeNode[]; level: number; expanded: Set<Key>; toggle: (key: Key) => void; selectedKey?: Key | undefined; onSelect?: ((node: TreeNode) => void) | undefined; messages: TreeMessages }) { return <ul role={level === 1 ? "tree" : "group"}>{nodes.map((node) => { const hasChildren = Boolean(node.children?.length); const isExpanded = expanded.has(node.key); return <li key={node.key} role="treeitem" aria-expanded={hasChildren ? isExpanded : undefined} aria-selected={selectedKey === node.key} aria-disabled={node.disabled || undefined}><div className="sia-tree__row" style={{ paddingInlineStart: `${(level - 1) * 1.25}rem` }}>{hasChildren ? <button type="button" aria-label={isExpanded ? messages.collapse : messages.expand} onClick={() => toggle(node.key)}>{isExpanded ? "−" : "+"}</button> : <span className="sia-tree__spacer" />}<button type="button" disabled={node.disabled} className="sia-tree__label" onClick={() => onSelect?.(node)}>{node.label}</button></div>{hasChildren && isExpanded && <TreeBranch nodes={node.children ?? []} level={level + 1} expanded={expanded} toggle={toggle} selectedKey={selectedKey} onSelect={onSelect} messages={messages} />}</li>; })}</ul>; }
8
9
  /**
9
10
  * Une arborescence qu'on déplie.
10
11
  *
@@ -12,4 +13,4 @@ function TreeBranch({ nodes, level, expanded, toggle, selectedKey, onSelect }: {
12
13
  * nœud ne désélectionne pas ce qu'il contient, et rouvrir l'écran ne doit
13
14
  * pas tout refermer.
14
15
  */
15
- export function Tree({ nodes, defaultExpandedKeys = [], selectedKey, onSelect, className }: TreeProps) { const [expanded, setExpanded] = useState(new Set(defaultExpandedKeys)); const toggle = (key: Key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); return <div className={cn("sia-tree", className)}><TreeBranch nodes={nodes} level={1} expanded={expanded} toggle={toggle} selectedKey={selectedKey} onSelect={onSelect} /></div>; }
16
+ export function Tree({ nodes, defaultExpandedKeys = [], selectedKey, onSelect, className }: TreeProps) { const messages = useSiaLocale().tree; const [expanded, setExpanded] = useState(new Set(defaultExpandedKeys)); const toggle = (key: Key) => setExpanded((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); else next.add(key); return next; }); return <div className={cn("sia-tree", className)}><TreeBranch nodes={nodes} level={1} expanded={expanded} toggle={toggle} selectedKey={selectedKey} onSelect={onSelect} messages={messages} /></div>; }
@@ -1,10 +1,23 @@
1
1
  import { useState, type Key } from "react";
2
- import { Tree, type TreeNode } from "../Tree";
2
+ import { Tree, type TreeNode, type TreeProps } from "../Tree";
3
3
  import { cn } from "@sia-ui/utils";
4
4
  import { useSiaLocale } from "@sia-ui/headless";
5
5
  import "./styles.css";
6
6
 
7
- export interface TreeSelectProps { nodes: TreeNode[]; value?: Key | undefined; onValueChange?: (value: Key, node: TreeNode) => void; placeholder?: string; className?: string; }
7
+ export interface TreeSelectProps {
8
+ nodes: TreeNode[];
9
+ value?: Key | undefined;
10
+ onValueChange?: (value: Key, node: TreeNode) => void;
11
+ placeholder?: string;
12
+ className?: string;
13
+ /**
14
+ * L'arbre du panneau : nœuds dépliés à l'ouverture, classe.
15
+ *
16
+ * Les nœuds, la sélection et son rappel restent au sélecteur, qui se
17
+ * referme sur le choix.
18
+ */
19
+ treeProps?: Partial<Omit<TreeProps, "nodes" | "selectedKey" | "onSelect">>;
20
+ }
8
21
  /**
9
22
  * Un choix dans une arborescence.
10
23
  *
@@ -12,6 +25,6 @@ export interface TreeSelectProps { nodes: TreeNode[]; value?: Key | undefined; o
12
25
  * attente » ne se réduit pas à « En attente » sans devenir ambigu dès qu'un
13
26
  * autre nœud porte le même nom.
14
27
  */
15
- export function TreeSelect({ nodes, value, onValueChange, placeholder: placeholderProp, className }: TreeSelectProps) {
28
+ export function TreeSelect({ nodes, value, onValueChange, placeholder: placeholderProp, className, treeProps }: TreeSelectProps) {
16
29
  const locale = useSiaLocale();
17
- const placeholder = placeholderProp ?? locale.select; const [open, setOpen] = useState(false); const find = (items: TreeNode[]): TreeNode | undefined => { for (const item of items) { if (item.key === value) return item; const child = item.children ? find(item.children) : undefined; if (child) return child; } }; const selected = find(nodes); return <div className={cn("sia-tree-select", className)}><button type="button" className="sia-tree-select__trigger" aria-expanded={open} onClick={() => setOpen(!open)}>{selected?.label ?? placeholder}</button>{open && <div className="sia-tree-select__panel"><Tree nodes={nodes} defaultExpandedKeys={nodes.map((node) => node.key)} selectedKey={value} onSelect={(node) => { onValueChange?.(node.key, node); setOpen(false); }} /></div>}</div>; }
30
+ const placeholder = placeholderProp ?? locale.select; const [open, setOpen] = useState(false); const find = (items: TreeNode[]): TreeNode | undefined => { for (const item of items) { if (item.key === value) return item; const child = item.children ? find(item.children) : undefined; if (child) return child; } }; const selected = find(nodes); return <div className={cn("sia-tree-select", className)}><button type="button" className="sia-tree-select__trigger" aria-expanded={open} onClick={() => setOpen(!open)}>{selected?.label ?? placeholder}</button>{open && <div className="sia-tree-select__panel"><Tree defaultExpandedKeys={nodes.map((node) => node.key)} {...treeProps} nodes={nodes} selectedKey={value} onSelect={(node) => { onValueChange?.(node.key, node); setOpen(false); }} /></div>}</div>; }
@@ -1,5 +1,10 @@
1
1
  import { useEffect } from "react";
2
- export interface UnsavedChangesGuardProps { when: boolean; message?: string; }
2
+ import { FRENCH, useSiaLocale } from "@sia-ui/headless";
3
+ export interface UnsavedChangesGuardProps {
4
+ when: boolean;
5
+ /** Le message de la confirmation. Par défaut, `unsavedChangesGuard.message` de la locale. */
6
+ message?: string;
7
+ }
3
8
  /**
4
9
  * L'avertissement avant de quitter une saisie en cours.
5
10
  *
@@ -8,5 +13,31 @@ export interface UnsavedChangesGuardProps { when: boolean; message?: string; }
8
13
  * confirmation interne, que `confirmUnsavedNavigation` déclenche avant une
9
14
  * navigation du routeur.
10
15
  */
11
- export function UnsavedChangesGuard({ when, message = "Des modifications non enregistrées seront perdues." }: UnsavedChangesGuardProps) { useEffect(() => { if (!when) return; const handler = (event: BeforeUnloadEvent) => { event.preventDefault(); event.returnValue = message; return message; }; window.addEventListener("beforeunload", handler); return () => window.removeEventListener("beforeunload", handler); }, [when, message]); return null; }
12
- export function confirmUnsavedNavigation(when: boolean, message = "Des modifications non enregistrées seront perdues.") { return !when || window.confirm(message); }
16
+ export function UnsavedChangesGuard({ when, message: messageProp }: UnsavedChangesGuardProps) {
17
+ const locale = useSiaLocale();
18
+ const message = messageProp ?? locale.unsavedChangesGuard.message;
19
+ useEffect(() => {
20
+ if (!when) return;
21
+ const handler = (event: BeforeUnloadEvent) => {
22
+ event.preventDefault();
23
+ event.returnValue = message;
24
+ return message;
25
+ };
26
+ window.addEventListener("beforeunload", handler);
27
+ return () => window.removeEventListener("beforeunload", handler);
28
+ }, [when, message]);
29
+ return null;
30
+ }
31
+ /**
32
+ * La confirmation avant une navigation du routeur.
33
+ *
34
+ * Hors d'un composant, la locale n'est pas lisible : passer
35
+ * `locale.unsavedChangesGuard.message` (lu avec `useSiaLocale()`) pour un
36
+ * message dans la langue de l'application. À défaut, le message français.
37
+ */
38
+ export function confirmUnsavedNavigation(
39
+ when: boolean,
40
+ message: string = FRENCH.unsavedChangesGuard.message,
41
+ ) {
42
+ return !when || window.confirm(message);
43
+ }