@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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/diff.mjs +91 -0
- package/dist/index.js +325 -0
- package/dist/lifecycle.mjs +242 -0
- package/dist/lock.mjs +92 -0
- package/dist/registry/package/components/Accordion/index.tsx +172 -0
- package/dist/registry/package/components/Accordion/styles.css +97 -0
- package/dist/registry/package/components/Alert/index.tsx +31 -0
- package/dist/registry/package/components/Alert/styles.css +12 -0
- package/dist/registry/package/components/AmountDisplay/index.tsx +11 -0
- package/dist/registry/package/components/AmountDisplay/styles.css +9 -0
- package/dist/registry/package/components/AppShell/index.tsx +6 -0
- package/dist/registry/package/components/AppShell/styles.css +7 -0
- package/dist/registry/package/components/AspectRatio/index.tsx +29 -0
- package/dist/registry/package/components/AspectRatio/styles.css +15 -0
- package/dist/registry/package/components/AsyncState/index.tsx +22 -0
- package/dist/registry/package/components/AsyncState/styles.css +1 -0
- package/dist/registry/package/components/AuditMeta/index.tsx +44 -0
- package/dist/registry/package/components/AuditMeta/styles.css +12 -0
- package/dist/registry/package/components/AuthLayout/index.tsx +39 -0
- package/dist/registry/package/components/AuthLayout/styles.css +6 -0
- package/dist/registry/package/components/Autocomplete/index.tsx +8 -0
- package/dist/registry/package/components/Avatar/index.tsx +23 -0
- package/dist/registry/package/components/Avatar/styles.css +5 -0
- package/dist/registry/package/components/Badge/index.tsx +62 -0
- package/dist/registry/package/components/Badge/styles.css +26 -0
- package/dist/registry/package/components/Breadcrumb/index.tsx +131 -0
- package/dist/registry/package/components/Breadcrumb/styles.css +76 -0
- package/dist/registry/package/components/Button/index.tsx +91 -0
- package/dist/registry/package/components/Button/styles.css +111 -0
- package/dist/registry/package/components/Calendar/index.tsx +192 -0
- package/dist/registry/package/components/Card/index.tsx +8 -0
- package/dist/registry/package/components/Card/styles.css +4 -0
- package/dist/registry/package/components/Chart/index.tsx +437 -0
- package/dist/registry/package/components/Chart/styles.css +182 -0
- package/dist/registry/package/components/Checkbox/index.tsx +193 -0
- package/dist/registry/package/components/Checkbox/styles.css +39 -0
- package/dist/registry/package/components/ColorPicker/index.tsx +6 -0
- package/dist/registry/package/components/ColorPicker/styles.css +2 -0
- package/dist/registry/package/components/CommandPalette/index.tsx +7 -0
- package/dist/registry/package/components/CommandPalette/styles.css +8 -0
- package/dist/registry/package/components/ConfirmDialog/index.tsx +24 -0
- package/dist/registry/package/components/Container/index.tsx +11 -0
- package/dist/registry/package/components/Container/styles.css +1 -0
- package/dist/registry/package/components/Countdown/index.tsx +3 -0
- package/dist/registry/package/components/CrudPage/index.tsx +9 -0
- package/dist/registry/package/components/CrudPage/styles.css +1 -0
- package/dist/registry/package/components/CurrencyInput/index.tsx +15 -0
- package/dist/registry/package/components/CurrencyInput/styles.css +1 -0
- package/dist/registry/package/components/DataTable/index.tsx +240 -0
- package/dist/registry/package/components/DataTable/styles.css +98 -0
- package/dist/registry/package/components/DatePicker/index.tsx +102 -0
- package/dist/registry/package/components/DateRangeFilter/index.tsx +8 -0
- package/dist/registry/package/components/DateRangeFilter/styles.css +4 -0
- package/dist/registry/package/components/DateRangePicker/index.tsx +36 -0
- package/dist/registry/package/components/DateTimePicker/index.tsx +32 -0
- package/dist/registry/package/components/Descriptions/index.tsx +7 -0
- package/dist/registry/package/components/Descriptions/styles.css +8 -0
- package/dist/registry/package/components/Divider/index.tsx +12 -0
- package/dist/registry/package/components/Divider/styles.css +3 -0
- package/dist/registry/package/components/Drawer/context.ts +14 -0
- package/dist/registry/package/components/Drawer/index.tsx +209 -0
- package/dist/registry/package/components/Drawer/styles.css +151 -0
- package/dist/registry/package/components/DropdownMenu/index.tsx +350 -0
- package/dist/registry/package/components/DropdownMenu/styles.css +100 -0
- package/dist/registry/package/components/DurationDisplay/index.tsx +3 -0
- package/dist/registry/package/components/EmptyState/index.tsx +23 -0
- package/dist/registry/package/components/EmptyState/styles.css +6 -0
- package/dist/registry/package/components/EntityMeta/index.tsx +65 -0
- package/dist/registry/package/components/EntityMeta/styles.css +9 -0
- package/dist/registry/package/components/ErrorState/index.tsx +30 -0
- package/dist/registry/package/components/ErrorState/styles.css +6 -0
- package/dist/registry/package/components/EventCalendar/index.tsx +8 -0
- package/dist/registry/package/components/EventCalendar/styles.css +8 -0
- package/dist/registry/package/components/Field/index.tsx +664 -0
- package/dist/registry/package/components/Field/styles.css +14 -0
- package/dist/registry/package/components/FileUpload/index.tsx +68 -0
- package/dist/registry/package/components/FileUpload/styles.css +4 -0
- package/dist/registry/package/components/FiltersBar/index.tsx +20 -0
- package/dist/registry/package/components/FiltersBar/styles.css +6 -0
- package/dist/registry/package/components/Form/index.tsx +211 -0
- package/dist/registry/package/components/Form/styles.css +80 -0
- package/dist/registry/package/components/FormField/index.tsx +2 -0
- package/dist/registry/package/components/HoverCard/index.tsx +121 -0
- package/dist/registry/package/components/HoverCard/styles.css +26 -0
- package/dist/registry/package/components/IconButton/index.tsx +27 -0
- package/dist/registry/package/components/IconButton/styles.css +7 -0
- package/dist/registry/package/components/Icons/index.tsx +185 -0
- package/dist/registry/package/components/ImageUpload/index.tsx +6 -0
- package/dist/registry/package/components/ImageUpload/styles.css +4 -0
- package/dist/registry/package/components/Input/index.tsx +103 -0
- package/dist/registry/package/components/Input/styles.css +8 -0
- package/dist/registry/package/components/InputNumber/index.tsx +196 -0
- package/dist/registry/package/components/InputNumber/styles.css +54 -0
- package/dist/registry/package/components/JsonEditor/index.tsx +6 -0
- package/dist/registry/package/components/JsonEditor/styles.css +2 -0
- package/dist/registry/package/components/KpiGrid/index.tsx +11 -0
- package/dist/registry/package/components/KpiGrid/styles.css +5 -0
- package/dist/registry/package/components/Label/index.tsx +43 -0
- package/dist/registry/package/components/Label/styles.css +25 -0
- package/dist/registry/package/components/MarkdownEditor/index.tsx +5 -0
- package/dist/registry/package/components/MiniCalendar/index.tsx +3 -0
- package/dist/registry/package/components/Modal/index.tsx +104 -0
- package/dist/registry/package/components/Modal/styles.css +20 -0
- package/dist/registry/package/components/MonthPicker/index.tsx +5 -0
- package/dist/registry/package/components/MultiSelect/index.tsx +133 -0
- package/dist/registry/package/components/MultiSelect/styles.css +3 -0
- package/dist/registry/package/components/OtpInput/index.tsx +5 -0
- package/dist/registry/package/components/OtpInput/styles.css +2 -0
- package/dist/registry/package/components/Overlay/index.tsx +140 -0
- package/dist/registry/package/components/Overlay/styles.css +1 -0
- package/dist/registry/package/components/PageHeader/index.tsx +24 -0
- package/dist/registry/package/components/PageHeader/styles.css +5 -0
- package/dist/registry/package/components/Pagination/index.tsx +155 -0
- package/dist/registry/package/components/Pagination/styles.css +103 -0
- package/dist/registry/package/components/PasswordInput/index.tsx +125 -0
- package/dist/registry/package/components/PasswordInput/styles.css +58 -0
- package/dist/registry/package/components/PermissionGate/index.tsx +3 -0
- package/dist/registry/package/components/PhoneInput/index.tsx +189 -0
- package/dist/registry/package/components/PhoneInput/styles.css +35 -0
- package/dist/registry/package/components/Popover/index.tsx +12 -0
- package/dist/registry/package/components/Popover/styles.css +12 -0
- package/dist/registry/package/components/Progress/index.tsx +149 -0
- package/dist/registry/package/components/Progress/styles.css +101 -0
- package/dist/registry/package/components/QrCode/index.tsx +6 -0
- package/dist/registry/package/components/QrCode/styles.css +5 -0
- package/dist/registry/package/components/RadioGroup/index.tsx +29 -0
- package/dist/registry/package/components/RadioGroup/styles.css +20 -0
- package/dist/registry/package/components/Rating/index.tsx +82 -0
- package/dist/registry/package/components/Rating/styles.css +41 -0
- package/dist/registry/package/components/ReferenceSelect/index.tsx +7 -0
- package/dist/registry/package/components/ReferenceSelect/styles.css +2 -0
- package/dist/registry/package/components/RelativeTime/index.tsx +22 -0
- package/dist/registry/package/components/RelativeTime/styles.css +1 -0
- package/dist/registry/package/components/RequireSession/index.tsx +127 -0
- package/dist/registry/package/components/RequireSession/styles.css +13 -0
- package/dist/registry/package/components/ResizablePanel/index.tsx +6 -0
- package/dist/registry/package/components/ResizablePanel/styles.css +5 -0
- package/dist/registry/package/components/RichTextEditor/index.tsx +5 -0
- package/dist/registry/package/components/RichTextEditor/styles.css +2 -0
- package/dist/registry/package/components/ScrollArea/index.tsx +114 -0
- package/dist/registry/package/components/ScrollArea/styles.css +74 -0
- package/dist/registry/package/components/SearchInput/index.tsx +121 -0
- package/dist/registry/package/components/SearchInput/styles.css +6 -0
- package/dist/registry/package/components/Select/index.tsx +324 -0
- package/dist/registry/package/components/Select/styles.css +58 -0
- package/dist/registry/package/components/Sidebar/index.tsx +232 -0
- package/dist/registry/package/components/Sidebar/styles.css +140 -0
- package/dist/registry/package/components/Skeleton/index.tsx +21 -0
- package/dist/registry/package/components/Skeleton/styles.css +2 -0
- package/dist/registry/package/components/Slider/index.tsx +193 -0
- package/dist/registry/package/components/Slider/styles.css +53 -0
- package/dist/registry/package/components/Spinner/index.tsx +28 -0
- package/dist/registry/package/components/Spinner/styles.css +8 -0
- package/dist/registry/package/components/StatCard/index.tsx +15 -0
- package/dist/registry/package/components/StatCard/styles.css +3 -0
- package/dist/registry/package/components/Statistic/index.tsx +23 -0
- package/dist/registry/package/components/Statistic/styles.css +6 -0
- package/dist/registry/package/components/Steps/index.tsx +108 -0
- package/dist/registry/package/components/Steps/styles.css +138 -0
- package/dist/registry/package/components/Switch/index.tsx +46 -0
- package/dist/registry/package/components/Switch/styles.css +26 -0
- package/dist/registry/package/components/Tabs/index.tsx +44 -0
- package/dist/registry/package/components/Tabs/styles.css +41 -0
- package/dist/registry/package/components/TagsInput/index.tsx +6 -0
- package/dist/registry/package/components/TagsInput/styles.css +2 -0
- package/dist/registry/package/components/Textarea/index.tsx +21 -0
- package/dist/registry/package/components/Textarea/styles.css +7 -0
- package/dist/registry/package/components/TimePicker/index.tsx +76 -0
- package/dist/registry/package/components/TimePicker/styles.css +13 -0
- package/dist/registry/package/components/TimeRangePicker/index.tsx +32 -0
- package/dist/registry/package/components/TimeRangePicker/styles.css +1 -0
- package/dist/registry/package/components/Timeline/index.tsx +8 -0
- package/dist/registry/package/components/Timeline/styles.css +12 -0
- package/dist/registry/package/components/Timer/index.tsx +9 -0
- package/dist/registry/package/components/Toast/index.tsx +426 -0
- package/dist/registry/package/components/Toast/styles.css +175 -0
- package/dist/registry/package/components/Toggle/index.tsx +181 -0
- package/dist/registry/package/components/Toggle/styles.css +124 -0
- package/dist/registry/package/components/Tooltip/index.tsx +163 -0
- package/dist/registry/package/components/Tooltip/styles.css +57 -0
- package/dist/registry/package/components/Tour/index.tsx +8 -0
- package/dist/registry/package/components/Tour/styles.css +6 -0
- package/dist/registry/package/components/Transfer/index.tsx +7 -0
- package/dist/registry/package/components/Transfer/styles.css +6 -0
- package/dist/registry/package/components/Tree/index.tsx +8 -0
- package/dist/registry/package/components/Tree/styles.css +6 -0
- package/dist/registry/package/components/TreeSelect/index.tsx +10 -0
- package/dist/registry/package/components/TreeSelect/styles.css +3 -0
- package/dist/registry/package/components/Typography/index.tsx +27 -0
- package/dist/registry/package/components/Typography/styles.css +16 -0
- package/dist/registry/package/components/UnsavedChangesGuard/index.tsx +4 -0
- package/dist/registry/package/components/WeekPicker/index.tsx +5 -0
- package/dist/registry/package/components/YearPicker/index.tsx +3 -0
- package/dist/registry/package/lib/form-adapter-rhf.tsx +141 -0
- package/dist/registry/registry.json +1780 -0
- package/dist/registry/styles/sia-ui.css +361 -0
- package/dist/source.mjs +93 -0
- package/package.json +55 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn, toneClass } from "@sia-ui/utils";
|
|
3
|
+
import type { ComponentSize, ComponentTone } from "@sia-ui/tokens";
|
|
4
|
+
import { useComponentDefaults, useSiaLocale } from "@sia-ui/headless";
|
|
5
|
+
import "./styles.css";
|
|
6
|
+
|
|
7
|
+
export interface ProgressProps {
|
|
8
|
+
/** La valeur courante. Omise, la barre passe en indéterminé. */
|
|
9
|
+
value?: number | undefined;
|
|
10
|
+
max?: number;
|
|
11
|
+
shape?: "bar" | "circle";
|
|
12
|
+
tone?: ComponentTone;
|
|
13
|
+
size?: ComponentSize;
|
|
14
|
+
/** Épaisseur du trait, en pixels. Par défaut selon la taille. */
|
|
15
|
+
thickness?: number | undefined;
|
|
16
|
+
/** Affiche le pourcentage — au centre du cercle, en bout de barre. */
|
|
17
|
+
showValue?: boolean;
|
|
18
|
+
/** Remplace l'affichage par défaut. */
|
|
19
|
+
format?: ((percent: number, value: number, max: number) => ReactNode) | undefined;
|
|
20
|
+
label?: ReactNode;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const BAR_HEIGHT: Record<ComponentSize, number> = { sm: 4, md: 8, lg: 12 };
|
|
25
|
+
const CIRCLE_SIZE: Record<ComponentSize, number> = { sm: 36, md: 56, lg: 84 };
|
|
26
|
+
const CIRCLE_STROKE: Record<ComponentSize, number> = { sm: 4, md: 6, lg: 8 };
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* L'avancement d'une tâche.
|
|
30
|
+
*
|
|
31
|
+
* Sans `value`, le composant passe en indéterminé plutôt que d'afficher zéro :
|
|
32
|
+
* « je travaille » et « je n'ai rien fait » ne se ressemblent pas.
|
|
33
|
+
*/
|
|
34
|
+
export function Progress(props: ProgressProps) {
|
|
35
|
+
const defaults = useComponentDefaults<ProgressProps>("progress");
|
|
36
|
+
const {
|
|
37
|
+
value,
|
|
38
|
+
max = 100,
|
|
39
|
+
shape = "bar",
|
|
40
|
+
tone = "primary",
|
|
41
|
+
size = "md",
|
|
42
|
+
thickness,
|
|
43
|
+
showValue = false,
|
|
44
|
+
format,
|
|
45
|
+
label,
|
|
46
|
+
className,
|
|
47
|
+
} = { ...defaults, ...props };
|
|
48
|
+
|
|
49
|
+
const locale = useSiaLocale();
|
|
50
|
+
const indeterminate = value === undefined || value === null;
|
|
51
|
+
const safeMax = max > 0 ? max : 100;
|
|
52
|
+
const clamped = indeterminate ? 0 : Math.min(Math.max(value, 0), safeMax);
|
|
53
|
+
const percent = Math.round((clamped / safeMax) * 100);
|
|
54
|
+
const text = format
|
|
55
|
+
? format(percent, clamped, safeMax)
|
|
56
|
+
: `${percent}\u00A0%`;
|
|
57
|
+
|
|
58
|
+
const aria = {
|
|
59
|
+
role: "progressbar" as const,
|
|
60
|
+
"aria-valuemin": 0,
|
|
61
|
+
"aria-valuemax": safeMax,
|
|
62
|
+
...(indeterminate ? {} : { "aria-valuenow": clamped }),
|
|
63
|
+
...(indeterminate ? { "aria-label": locale.loading } : {}),
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
if (shape === "circle") {
|
|
67
|
+
const box = CIRCLE_SIZE[size];
|
|
68
|
+
const stroke = thickness ?? CIRCLE_STROKE[size];
|
|
69
|
+
const radius = (box - stroke) / 2;
|
|
70
|
+
const circumference = 2 * Math.PI * radius;
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
className={cn(
|
|
75
|
+
"sia-progress",
|
|
76
|
+
"sia-progress--circle",
|
|
77
|
+
toneClass("sia-progress", tone),
|
|
78
|
+
indeterminate && "sia-progress--indeterminate",
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...aria}
|
|
82
|
+
>
|
|
83
|
+
<svg width={box} height={box} viewBox={`0 0 ${box} ${box}`} aria-hidden="true">
|
|
84
|
+
<circle
|
|
85
|
+
className="sia-progress__track"
|
|
86
|
+
cx={box / 2}
|
|
87
|
+
cy={box / 2}
|
|
88
|
+
r={radius}
|
|
89
|
+
strokeWidth={stroke}
|
|
90
|
+
/>
|
|
91
|
+
<circle
|
|
92
|
+
className="sia-progress__indicator"
|
|
93
|
+
cx={box / 2}
|
|
94
|
+
cy={box / 2}
|
|
95
|
+
r={radius}
|
|
96
|
+
strokeWidth={stroke}
|
|
97
|
+
strokeDasharray={circumference}
|
|
98
|
+
// Le trait se dessine par le décalage du pointillé : une seule
|
|
99
|
+
// propriété animée, sur le compositeur, sans reflow.
|
|
100
|
+
strokeDashoffset={
|
|
101
|
+
indeterminate
|
|
102
|
+
? circumference * 0.75
|
|
103
|
+
: circumference - (clamped / safeMax) * circumference
|
|
104
|
+
}
|
|
105
|
+
/>
|
|
106
|
+
</svg>
|
|
107
|
+
{(showValue || label) && (
|
|
108
|
+
<span className="sia-progress__center">
|
|
109
|
+
{showValue && !indeterminate && (
|
|
110
|
+
<strong className="sia-progress__value">{text}</strong>
|
|
111
|
+
)}
|
|
112
|
+
{label && <span className="sia-progress__label">{label}</span>}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<div
|
|
121
|
+
className={cn(
|
|
122
|
+
"sia-progress",
|
|
123
|
+
"sia-progress--bar",
|
|
124
|
+
toneClass("sia-progress", tone),
|
|
125
|
+
indeterminate && "sia-progress--indeterminate",
|
|
126
|
+
className,
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{(label || (showValue && !indeterminate)) && (
|
|
130
|
+
<div className="sia-progress__header">
|
|
131
|
+
{label && <span className="sia-progress__label">{label}</span>}
|
|
132
|
+
{showValue && !indeterminate && (
|
|
133
|
+
<span className="sia-progress__value">{text}</span>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
<div
|
|
138
|
+
className="sia-progress__track"
|
|
139
|
+
style={{ height: thickness ?? BAR_HEIGHT[size] }}
|
|
140
|
+
{...aria}
|
|
141
|
+
>
|
|
142
|
+
<div
|
|
143
|
+
className="sia-progress__indicator"
|
|
144
|
+
style={indeterminate ? undefined : { width: `${percent}%` }}
|
|
145
|
+
/>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
.sia-progress { --sia-progress-color: var(--sia-primary); display: grid; gap: .375rem; }
|
|
2
|
+
|
|
3
|
+
.sia-progress__header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
|
|
4
|
+
.sia-progress__label { color: var(--sia-muted); font-size: .8rem; }
|
|
5
|
+
.sia-progress__value { color: var(--sia-foreground); font-size: .8rem; font-variant-numeric: tabular-nums; font-weight: 600; }
|
|
6
|
+
|
|
7
|
+
/* ── Barre ─────────────────────────────────────────────────────────────── */
|
|
8
|
+
|
|
9
|
+
.sia-progress--bar .sia-progress__track {
|
|
10
|
+
position: relative;
|
|
11
|
+
overflow: hidden;
|
|
12
|
+
width: 100%;
|
|
13
|
+
border-radius: 999px;
|
|
14
|
+
background: color-mix(in srgb, var(--sia-progress-color) 14%, var(--sia-surface));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.sia-progress--bar .sia-progress__indicator {
|
|
18
|
+
height: 100%;
|
|
19
|
+
border-radius: inherit;
|
|
20
|
+
background: linear-gradient(
|
|
21
|
+
90deg,
|
|
22
|
+
color-mix(in srgb, var(--sia-progress-color) 80%, white),
|
|
23
|
+
var(--sia-progress-color)
|
|
24
|
+
);
|
|
25
|
+
/* La largeur s'anime : un saut de 20 % à 80 % doit se voir monter, sinon on
|
|
26
|
+
ne sait pas si la valeur a changé ou si la page a été rechargée. */
|
|
27
|
+
transition: width var(--sia-duration-slow) var(--sia-easing-standard);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Le reflet : une bande claire qui traverse la barre, lentement. Elle ne dit
|
|
31
|
+
rien de plus que « ça avance », mais c'est précisément ce qu'on regarde. */
|
|
32
|
+
.sia-progress--bar .sia-progress__indicator::after {
|
|
33
|
+
content: "";
|
|
34
|
+
position: absolute;
|
|
35
|
+
inset: 0;
|
|
36
|
+
background: linear-gradient(
|
|
37
|
+
90deg,
|
|
38
|
+
transparent,
|
|
39
|
+
rgb(255 255 255 / 0.35),
|
|
40
|
+
transparent
|
|
41
|
+
);
|
|
42
|
+
transform: translateX(-100%);
|
|
43
|
+
animation: sia-progress-sheen 2.2s var(--sia-easing-standard) infinite;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.sia-progress--bar.sia-progress--indeterminate .sia-progress__indicator {
|
|
47
|
+
width: 40%;
|
|
48
|
+
transition: none;
|
|
49
|
+
animation: sia-progress-slide 1.4s var(--sia-easing-standard) infinite;
|
|
50
|
+
}
|
|
51
|
+
.sia-progress--bar.sia-progress--indeterminate .sia-progress__indicator::after { animation: none; }
|
|
52
|
+
|
|
53
|
+
@keyframes sia-progress-sheen {
|
|
54
|
+
0% { transform: translateX(-100%); }
|
|
55
|
+
60%, 100% { transform: translateX(100%); }
|
|
56
|
+
}
|
|
57
|
+
@keyframes sia-progress-slide {
|
|
58
|
+
0% { margin-left: -40%; }
|
|
59
|
+
100% { margin-left: 100%; }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* ── Cercle ────────────────────────────────────────────────────────────── */
|
|
63
|
+
|
|
64
|
+
.sia-progress--circle { position: relative; display: inline-grid; place-items: center; gap: 0; }
|
|
65
|
+
.sia-progress--circle svg { display: block; transform: rotate(-90deg); }
|
|
66
|
+
|
|
67
|
+
.sia-progress--circle .sia-progress__track {
|
|
68
|
+
fill: none;
|
|
69
|
+
stroke: color-mix(in srgb, var(--sia-progress-color) 14%, var(--sia-surface));
|
|
70
|
+
}
|
|
71
|
+
.sia-progress--circle .sia-progress__indicator {
|
|
72
|
+
fill: none;
|
|
73
|
+
stroke: var(--sia-progress-color);
|
|
74
|
+
stroke-linecap: round;
|
|
75
|
+
transition: stroke-dashoffset var(--sia-duration-slow) var(--sia-easing-standard);
|
|
76
|
+
}
|
|
77
|
+
.sia-progress--circle.sia-progress--indeterminate svg {
|
|
78
|
+
animation: sia-spin 1.1s linear infinite;
|
|
79
|
+
}
|
|
80
|
+
.sia-progress--circle.sia-progress--indeterminate .sia-progress__indicator {
|
|
81
|
+
transition: none;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.sia-progress__center {
|
|
85
|
+
position: absolute;
|
|
86
|
+
display: grid;
|
|
87
|
+
gap: 0;
|
|
88
|
+
justify-items: center;
|
|
89
|
+
line-height: 1.1;
|
|
90
|
+
text-align: center;
|
|
91
|
+
}
|
|
92
|
+
.sia-progress--circle .sia-progress__label { font-size: .7rem; }
|
|
93
|
+
|
|
94
|
+
/* ── Tons ──────────────────────────────────────────────────────────────── */
|
|
95
|
+
|
|
96
|
+
.sia-progress--neutral { --sia-progress-color: var(--sia-muted); }
|
|
97
|
+
.sia-progress--primary { --sia-progress-color: var(--sia-primary); }
|
|
98
|
+
.sia-progress--info { --sia-progress-color: var(--sia-info); }
|
|
99
|
+
.sia-progress--success { --sia-progress-color: var(--sia-success); }
|
|
100
|
+
.sia-progress--warning { --sia-progress-color: var(--sia-warning); }
|
|
101
|
+
.sia-progress--danger { --sia-progress-color: var(--sia-danger); }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
|
|
5
|
+
export interface QRCodeProps { value: string; size?: number; label?: string; render?: (value: string, size: number) => ReactNode; className?: string; }
|
|
6
|
+
export function QRCode({ value, size = 160, label = "Code QR", render, className }: QRCodeProps) { return <figure className={cn("sia-qr-code", className)} aria-label={label} style={{ width: size }}><div className="sia-qr-code__frame" style={{ width: size, height: size }}>{render ? render(value, size) : <div className="sia-qr-code__adapter"><strong>QR</strong><span>Encodeur requis</span></div>}</div><figcaption>{value}</figcaption></figure>; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
.sia-qr-code { display:grid; gap:.5rem; margin:0; }
|
|
2
|
+
.sia-qr-code__frame { display:grid; place-items:center; overflow:hidden; border:1px solid var(--sia-border); background:#fff; }
|
|
3
|
+
.sia-qr-code__adapter { display:grid; place-items:center; gap:.25rem; color:#0f172a; }
|
|
4
|
+
.sia-qr-code__adapter strong { font-size:2rem; }
|
|
5
|
+
.sia-qr-code__adapter span,.sia-qr-code figcaption { overflow:hidden; color:var(--sia-muted); font-size:.72rem; text-overflow:ellipsis; white-space:nowrap; }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createContext, useContext, useId, useState, type InputHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn, toneClass } from "@sia-ui/utils";
|
|
3
|
+
import type { ComponentTone } from "@sia-ui/tokens";
|
|
4
|
+
import "./styles.css";
|
|
5
|
+
|
|
6
|
+
type RadioValue = string | number;
|
|
7
|
+
type RadioContextValue = { name: string; value: RadioValue | undefined; setValue: (value: RadioValue) => void; disabled: boolean | undefined; size: "sm" | "md" | "lg"; tone: ComponentTone; };
|
|
8
|
+
const RadioGroupContext = createContext<RadioContextValue | null>(null);
|
|
9
|
+
|
|
10
|
+
export interface RadioGroupProps {
|
|
11
|
+
id?: string; name?: string; label?: ReactNode; value?: RadioValue; defaultValue?: RadioValue; onValueChange?: (value: RadioValue) => void; orientation?: "horizontal" | "vertical"; disabled?: boolean; size?: "sm" | "md" | "lg"; tone?: ComponentTone; className?: string; "aria-describedby"?: string; "aria-labelledby"?: string; "aria-invalid"?: boolean; "aria-required"?: boolean; children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
export function RadioGroup({ id, name, label, value, defaultValue, onValueChange, orientation = "vertical", disabled, size = "md", tone = "primary", className, children, ...ariaProps }: RadioGroupProps) {
|
|
14
|
+
const generatedName = useId(); const [internal, setInternal] = useState(defaultValue); const current = value ?? internal;
|
|
15
|
+
const setValue = (next: RadioValue) => { if (value === undefined) setInternal(next); onValueChange?.(next); };
|
|
16
|
+
return <fieldset id={id} className={cn("sia-radio-group", `sia-radio-group--${orientation}`, toneClass("sia-tone", tone), className)} disabled={disabled} {...ariaProps}>{label && <legend className="sia-radio-group__legend">{label}</legend>}<RadioGroupContext.Provider value={{ name: name ?? generatedName, value: current, setValue, disabled, size, tone }}>{children}</RadioGroupContext.Provider></fieldset>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RadioProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "name" | "value" | "size"> { value: RadioValue; label?: ReactNode; children?: ReactNode; }
|
|
20
|
+
export function Radio({ value, label, children, className, disabled, onChange, ...props }: RadioProps) {
|
|
21
|
+
const group = useContext(RadioGroupContext); if (!group) throw new Error("Radio must be used inside RadioGroup."); const checked = group.value === value; const resolvedDisabled = Boolean(disabled || group.disabled);
|
|
22
|
+
return <label className={cn("sia-radio", `sia-radio--${group.size}`, toneClass("sia-tone", group.tone), checked && "sia-radio--checked", resolvedDisabled && "sia-radio--disabled", className)}><input {...props} className="sia-visually-hidden" type="radio" name={group.name} value={value} checked={checked} disabled={resolvedDisabled} onChange={(event) => { group.setValue(value); onChange?.(event); }} /><span className="sia-radio__dot" aria-hidden="true" /><span className="sia-radio__label">{label ?? children}</span></label>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RadioButtonProps { value: RadioValue; disabled?: boolean; children: ReactNode; className?: string; }
|
|
26
|
+
export function RadioButton({ value, disabled, children, className }: RadioButtonProps) {
|
|
27
|
+
const group = useContext(RadioGroupContext); if (!group) throw new Error("RadioButton must be used inside RadioGroup."); const checked = group.value === value; const resolvedDisabled = Boolean(disabled || group.disabled);
|
|
28
|
+
return <button type="button" role="radio" aria-checked={checked} disabled={resolvedDisabled} className={cn("sia-radio-button", `sia-radio-button--${group.size}`, toneClass("sia-tone", group.tone), checked && "sia-radio-button--checked", className)} onClick={() => group.setValue(value)}>{children}</button>;
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.sia-radio-group { display:flex; gap:.75rem; padding:0; margin:0; border:0; }
|
|
2
|
+
.sia-radio-group--vertical { flex-direction:column; }
|
|
3
|
+
.sia-radio-group--horizontal { flex-direction:row; flex-wrap:wrap; }
|
|
4
|
+
.sia-radio-group__legend { margin-bottom:.65rem; font-weight:600; }
|
|
5
|
+
.sia-radio { --sia-accent:var(--sia-primary); display:inline-flex; align-items:center; gap:.55rem; cursor:pointer; }
|
|
6
|
+
.sia-radio__dot { display:grid; place-items:center; width:1.1rem; height:1.1rem; border:1.5px solid var(--sia-border); border-radius:50%; }
|
|
7
|
+
.sia-radio__dot::after { content:""; width:.52rem; height:.52rem; border-radius:50%; background:transparent; }
|
|
8
|
+
.sia-radio--checked .sia-radio__dot { border-color:var(--sia-accent); }
|
|
9
|
+
.sia-radio--checked .sia-radio__dot::after { background:var(--sia-accent); }
|
|
10
|
+
.sia-radio:focus-within .sia-radio__dot { box-shadow:0 0 0 3px color-mix(in srgb,var(--sia-accent) 20%,transparent); }
|
|
11
|
+
.sia-radio--disabled { opacity:.5; cursor:not-allowed; }
|
|
12
|
+
.sia-radio--sm { font-size:.85rem; }
|
|
13
|
+
.sia-radio--lg { font-size:1.05rem; }
|
|
14
|
+
.sia-radio-button { --sia-accent:var(--sia-primary); min-height:2.35rem; padding:0 .8rem; border:1px solid var(--sia-border); color:var(--sia-foreground); background:var(--sia-background); font:inherit; cursor:pointer; }
|
|
15
|
+
.sia-radio-button:first-of-type { border-radius:var(--sia-radius) 0 0 var(--sia-radius); }
|
|
16
|
+
.sia-radio-button:last-of-type { border-radius:0 var(--sia-radius) var(--sia-radius) 0; }
|
|
17
|
+
.sia-radio-button + .sia-radio-button { margin-left:-1px; }
|
|
18
|
+
.sia-radio-button--checked { position:relative; z-index:1; color:var(--sia-accent); border-color:var(--sia-accent); background:color-mix(in srgb,var(--sia-accent) 9%,transparent); }
|
|
19
|
+
.sia-radio-button--sm { min-height:2rem; }
|
|
20
|
+
.sia-radio-button--lg { min-height:2.75rem; }
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import { StarIcon } from "../Icons";
|
|
4
|
+
import "./styles.css";
|
|
5
|
+
export interface RatingProps {
|
|
6
|
+
id?: string;
|
|
7
|
+
value?: number;
|
|
8
|
+
defaultValue?: number;
|
|
9
|
+
onValueChange?: (value: number) => void;
|
|
10
|
+
max?: number;
|
|
11
|
+
/** Autorise les demi-valeurs — 3,5 sur 5. */
|
|
12
|
+
allowHalf?: boolean;
|
|
13
|
+
allowClear?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
label?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
"aria-describedby"?: string;
|
|
18
|
+
"aria-labelledby"?: string;
|
|
19
|
+
"aria-invalid"?: boolean;
|
|
20
|
+
"aria-required"?: boolean;
|
|
21
|
+
}
|
|
22
|
+
export function Rating({
|
|
23
|
+
id,
|
|
24
|
+
value,
|
|
25
|
+
defaultValue = 0,
|
|
26
|
+
onValueChange,
|
|
27
|
+
max = 5,
|
|
28
|
+
allowHalf = false,
|
|
29
|
+
allowClear = true,
|
|
30
|
+
disabled,
|
|
31
|
+
label = "Note",
|
|
32
|
+
className,
|
|
33
|
+
...ariaProps
|
|
34
|
+
}: RatingProps) {
|
|
35
|
+
const [internal, setInternal] = useState(defaultValue);
|
|
36
|
+
const current = value ?? internal;
|
|
37
|
+
const update = (next: number) => {
|
|
38
|
+
const resolved = allowClear && next === current ? 0 : next;
|
|
39
|
+
if (value === undefined) setInternal(resolved);
|
|
40
|
+
onValueChange?.(resolved);
|
|
41
|
+
};
|
|
42
|
+
return (
|
|
43
|
+
<div
|
|
44
|
+
id={id}
|
|
45
|
+
role="radiogroup"
|
|
46
|
+
aria-label={label}
|
|
47
|
+
className={cn("sia-rating", className)}
|
|
48
|
+
{...ariaProps}
|
|
49
|
+
>
|
|
50
|
+
{Array.from({ length: max }, (_, index) => index + 1).map((item) => {
|
|
51
|
+
// Part remplie de CETTE étoile : pleine, vide, ou entre les deux.
|
|
52
|
+
// Impossible avec un caractère — d'où le passage au tracé.
|
|
53
|
+
const filled = Math.min(1, Math.max(0, current - item + 1));
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<button
|
|
57
|
+
key={item}
|
|
58
|
+
type="button"
|
|
59
|
+
role="radio"
|
|
60
|
+
aria-checked={current === item}
|
|
61
|
+
aria-label={`${item} sur ${max}`}
|
|
62
|
+
disabled={disabled}
|
|
63
|
+
className={filled > 0 ? "is-active" : ""}
|
|
64
|
+
style={{ "--sia-rating-fill": `${filled * 100}%` } as React.CSSProperties}
|
|
65
|
+
onClick={(event) => {
|
|
66
|
+
if (!allowHalf) return update(item);
|
|
67
|
+
// Moitié gauche du bouton = demi-étoile.
|
|
68
|
+
const box = event.currentTarget.getBoundingClientRect();
|
|
69
|
+
const half = event.clientX - box.left < box.width / 2;
|
|
70
|
+
update(half ? item - 0.5 : item);
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
<span className="sia-rating__star" aria-hidden="true">
|
|
74
|
+
<StarIcon className="sia-rating__outline" />
|
|
75
|
+
<StarIcon className="sia-rating__fill" fill="currentColor" />
|
|
76
|
+
</span>
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
})}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.sia-rating { display:inline-flex; gap:.2rem; }
|
|
2
|
+
.sia-rating button { padding:.1rem; border:0; color:var(--sia-border); background:transparent; font-size:1.5rem; cursor:pointer; }
|
|
3
|
+
.sia-rating button.is-active { color:var(--sia-warning); }
|
|
4
|
+
|
|
5
|
+
/* La demi-étoile : deux tracés superposés, celui du dessus rogné à la part
|
|
6
|
+
remplie. Un caractère ne permettait pas ça — d'où son remplacement. */
|
|
7
|
+
.sia-rating__star {
|
|
8
|
+
position: relative;
|
|
9
|
+
display: inline-grid;
|
|
10
|
+
place-items: center;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.sia-rating__outline,
|
|
14
|
+
.sia-rating__fill {
|
|
15
|
+
grid-area: 1 / 1;
|
|
16
|
+
width: 1em;
|
|
17
|
+
height: 1em;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.sia-rating__outline {
|
|
21
|
+
color: var(--sia-border-strong);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
.sia-rating__fill {
|
|
25
|
+
color: var(--sia-warning);
|
|
26
|
+
clip-path: inset(0 calc(100% - var(--sia-rating-fill, 0%)) 0 0);
|
|
27
|
+
transition: clip-path var(--sia-duration-base) var(--sia-easing-standard);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.sia-rating button {
|
|
31
|
+
transition: transform var(--sia-duration-fast) var(--sia-easing-spring);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.sia-rating button:hover:not(:disabled) {
|
|
35
|
+
transform: scale(1.15);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.sia-rating button:active:not(:disabled) {
|
|
39
|
+
transform: scale(0.95);
|
|
40
|
+
transition-duration: var(--sia-duration-instant);
|
|
41
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { Autocomplete, type AutocompleteProps } from "../Autocomplete";
|
|
3
|
+
import type { SelectOption } from "../Select";
|
|
4
|
+
import "./styles.css";
|
|
5
|
+
export interface ReferenceOption extends SelectOption { description?: ReactNode; }
|
|
6
|
+
export interface ReferenceSelectProps extends Omit<AutocompleteProps, "options"> { options: ReferenceOption[]; }
|
|
7
|
+
export function ReferenceSelect({ options, ...props }: ReferenceSelectProps) { return <Autocomplete {...props} options={options.map((option) => ({ ...option, label: <span className="sia-reference-select__option"><strong>{option.label}</strong>{option.description && <small>{option.description}</small>}</span> }))} />; }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useEffect, useState, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn, formatDate, formatTimeAgo } from "@sia-ui/utils";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
|
|
5
|
+
export interface RelativeTimeProps extends Omit<HTMLAttributes<HTMLTimeElement>, "dateTime"> {
|
|
6
|
+
value: Date | string | number;
|
|
7
|
+
locale?: string;
|
|
8
|
+
prefix?: string;
|
|
9
|
+
refreshInterval?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function RelativeTime({ value, locale = "fr-FR", prefix, refreshInterval = 60_000, className, title, ...props }: RelativeTimeProps) {
|
|
13
|
+
const [, refresh] = useState(0);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
if (refreshInterval <= 0) return;
|
|
16
|
+
const id = window.setInterval(() => refresh((current) => current + 1), refreshInterval);
|
|
17
|
+
return () => window.clearInterval(id);
|
|
18
|
+
}, [refreshInterval]);
|
|
19
|
+
const date = new Date(value);
|
|
20
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
21
|
+
return <time className={cn("sia-relative-time", className)} dateTime={date.toISOString()} title={title ?? formatDate(date, { locale, dateStyle:"full", timeStyle:"short" })} {...props}>{formatTimeAgo(date, { locale, ...(prefix ? { prefix } : {}) })}</time>;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.sia-relative-time { color:var(--sia-muted); font-size:.875rem; }
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { useEffect, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import {
|
|
4
|
+
useSession,
|
|
5
|
+
type PermissionRule,
|
|
6
|
+
type SessionState,
|
|
7
|
+
type SessionStore,
|
|
8
|
+
} from "@sia-ui/headless";
|
|
9
|
+
import { Spinner } from "../Spinner";
|
|
10
|
+
import "./styles.css";
|
|
11
|
+
|
|
12
|
+
export interface RequireSessionProps<TUser, TTenant, TRule> {
|
|
13
|
+
session: SessionStore<TUser, TTenant, TRule>;
|
|
14
|
+
children: ReactNode;
|
|
15
|
+
/** La règle de droits exigée, s'il y en a une. */
|
|
16
|
+
require?: TRule;
|
|
17
|
+
/**
|
|
18
|
+
* Affiché pendant la vérification.
|
|
19
|
+
*
|
|
20
|
+
* Ce rendu est ce qui distingue cette garde d'un `if (!user) redirect` :
|
|
21
|
+
* tant que le serveur n'a pas répondu, on attend. Rediriger pendant la
|
|
22
|
+
* vérification renvoie à la page de connexion quelqu'un qui est connecté.
|
|
23
|
+
*/
|
|
24
|
+
pending?: ReactNode;
|
|
25
|
+
/** Rendu quand il n'y a pas de session. */
|
|
26
|
+
fallback?: ReactNode;
|
|
27
|
+
/** Rendu quand la session existe mais que les droits manquent. */
|
|
28
|
+
forbidden?: ReactNode;
|
|
29
|
+
/** Laisse passer une session `incomplete` — profil pas encore rempli. */
|
|
30
|
+
allowIncomplete?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Appelé une fois quand la vérification conclut à l'absence de session.
|
|
33
|
+
*
|
|
34
|
+
* C'est ici que l'application navigue : `navigate("/login")`,
|
|
35
|
+
* `router.push(…)`, ou rien du tout si `fallback` suffit. La garde ne
|
|
36
|
+
* connaît aucun routeur.
|
|
37
|
+
*/
|
|
38
|
+
onUnauthenticated?: (state: SessionState<TUser, TTenant>) => void;
|
|
39
|
+
onForbidden?: (state: SessionState<TUser, TTenant>) => void;
|
|
40
|
+
onIncomplete?: (state: SessionState<TUser, TTenant>) => void;
|
|
41
|
+
className?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Ce qui se trouve derrière une session valide.
|
|
46
|
+
*
|
|
47
|
+
* Écrite cinq fois dans les projets du disque D, cinq fois de la même façon
|
|
48
|
+
* à la cible de redirection près. Elle ne dépend d'aucun routeur : elle rend
|
|
49
|
+
* un état et prévient, l'application décide où aller.
|
|
50
|
+
*
|
|
51
|
+
* > Ceci protège l'interface, pas les données. Une route masquée reste une
|
|
52
|
+
* > route ouverte tant que le serveur ne refuse pas l'appel.
|
|
53
|
+
*/
|
|
54
|
+
export function RequireSession<TUser, TTenant, TRule = PermissionRule>({
|
|
55
|
+
session,
|
|
56
|
+
children,
|
|
57
|
+
require: rule,
|
|
58
|
+
pending,
|
|
59
|
+
fallback = null,
|
|
60
|
+
forbidden = null,
|
|
61
|
+
allowIncomplete = false,
|
|
62
|
+
onUnauthenticated,
|
|
63
|
+
onForbidden,
|
|
64
|
+
onIncomplete,
|
|
65
|
+
className,
|
|
66
|
+
}: RequireSessionProps<TUser, TTenant, TRule>) {
|
|
67
|
+
const state = useSession(session);
|
|
68
|
+
|
|
69
|
+
const denied =
|
|
70
|
+
state.status === "authenticated" &&
|
|
71
|
+
rule !== undefined &&
|
|
72
|
+
!session.access.can(rule);
|
|
73
|
+
const incomplete = state.status === "incomplete" && !allowIncomplete;
|
|
74
|
+
|
|
75
|
+
// Les rappels partent d'un effet, jamais du rendu : naviguer pendant un
|
|
76
|
+
// rendu démonte l'arbre que React est en train de construire.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (state.status === "anonymous") onUnauthenticated?.(state);
|
|
79
|
+
else if (incomplete) onIncomplete?.(state);
|
|
80
|
+
else if (denied) onForbidden?.(state);
|
|
81
|
+
// Seul le statut compte : réagir à chaque changement de l'objet d'état
|
|
82
|
+
// relancerait la navigation à chaque rendu.
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [state.status, denied, incomplete]);
|
|
85
|
+
|
|
86
|
+
if (state.status === "checking") {
|
|
87
|
+
return (
|
|
88
|
+
<div className={cn("sia-require-session", className)} aria-busy="true">
|
|
89
|
+
{pending ?? (
|
|
90
|
+
<div className="sia-require-session__pending">
|
|
91
|
+
<Spinner />
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (state.status === "anonymous") return <>{fallback}</>;
|
|
99
|
+
if (incomplete) return <>{fallback}</>;
|
|
100
|
+
if (denied) return <>{forbidden}</>;
|
|
101
|
+
|
|
102
|
+
return <>{children}</>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface CanProps<TUser, TTenant, TRule> {
|
|
106
|
+
session: SessionStore<TUser, TTenant, TRule>;
|
|
107
|
+
require: TRule;
|
|
108
|
+
children: ReactNode;
|
|
109
|
+
fallback?: ReactNode;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Un morceau d'interface derrière un droit.
|
|
114
|
+
*
|
|
115
|
+
* `PermissionGate` fait la même chose à partir d'un tableau de permissions
|
|
116
|
+
* passé à la main; celui-ci lit la session et suit son évaluateur — jokers,
|
|
117
|
+
* expression textuelle ou égalité stricte selon le serveur.
|
|
118
|
+
*/
|
|
119
|
+
export function Can<TUser, TTenant, TRule = PermissionRule>({
|
|
120
|
+
session,
|
|
121
|
+
require: rule,
|
|
122
|
+
children,
|
|
123
|
+
fallback = null,
|
|
124
|
+
}: CanProps<TUser, TTenant, TRule>) {
|
|
125
|
+
useSession(session);
|
|
126
|
+
return <>{session.access.can(rule) ? children : fallback}</>;
|
|
127
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
.sia-require-session { display: contents; }
|
|
2
|
+
|
|
3
|
+
/* Pendant la vérification, la place est tenue plutôt que laissée vide : une
|
|
4
|
+
page qui se remplit d'un coup après un écran blanc se lit comme un
|
|
5
|
+
rechargement, pas comme un chargement. */
|
|
6
|
+
.sia-require-session__pending {
|
|
7
|
+
display: grid;
|
|
8
|
+
place-items: center;
|
|
9
|
+
min-height: 12rem;
|
|
10
|
+
padding: 2rem;
|
|
11
|
+
color: var(--sia-muted);
|
|
12
|
+
animation: sia-fade-in var(--sia-duration-base) var(--sia-easing-standard);
|
|
13
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { useState, type PointerEvent, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
|
|
5
|
+
export interface ResizablePanelProps { first: ReactNode; second: ReactNode; defaultSize?: number; minSize?: number; maxSize?: number; direction?: "horizontal" | "vertical"; className?: string; }
|
|
6
|
+
export function ResizablePanel({ first, second, defaultSize = 50, minSize = 20, maxSize = 80, direction = "horizontal", className }: ResizablePanelProps) { const [size, setSize] = useState(defaultSize); const start = (event: PointerEvent<HTMLDivElement>) => { const container = event.currentTarget.parentElement; if (!container) return; event.currentTarget.setPointerCapture(event.pointerId); const move = (moveEvent: globalThis.PointerEvent) => { const rect = container.getBoundingClientRect(); const next = direction === "horizontal" ? ((moveEvent.clientX - rect.left) / rect.width) * 100 : ((moveEvent.clientY - rect.top) / rect.height) * 100; setSize(Math.min(maxSize, Math.max(minSize, next))); }; const stop = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); }; const firstStyle = { flexBasis: `${size}%` }; return <div className={cn("sia-resizable-panel", `sia-resizable-panel--${direction}`, className)}><section style={firstStyle}>{first}</section><div className="sia-resizable-panel__handle" role="separator" aria-orientation={direction} aria-valuenow={Math.round(size)} tabIndex={0} onPointerDown={start} onKeyDown={(event) => { const delta = event.key === "ArrowLeft" || event.key === "ArrowUp" ? -5 : event.key === "ArrowRight" || event.key === "ArrowDown" ? 5 : 0; if (delta) { event.preventDefault(); setSize((current) => Math.min(maxSize, Math.max(minSize, current + delta))); } }} /><section>{second}</section></div>; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
.sia-resizable-panel { display:flex; min-height:18rem; overflow:hidden; border:1px solid var(--sia-border); }
|
|
2
|
+
.sia-resizable-panel--vertical { flex-direction:column; }
|
|
3
|
+
.sia-resizable-panel > section { min-width:0; min-height:0; flex:1; overflow:auto; padding:1rem; }
|
|
4
|
+
.sia-resizable-panel__handle { flex:none; width:.4rem; background:var(--sia-border); cursor:col-resize; }
|
|
5
|
+
.sia-resizable-panel--vertical .sia-resizable-panel__handle { width:auto; height:.4rem; cursor:row-resize; }
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { useEffect, useRef, type HTMLAttributes } from "react";
|
|
2
|
+
import { cn } from "@sia-ui/utils";
|
|
3
|
+
import "./styles.css";
|
|
4
|
+
export interface RichTextEditorProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange" | "defaultValue"> { value?: string; defaultValue?: string; onValueChange?: (html: string) => void; placeholder?: string; disabled?: boolean; }
|
|
5
|
+
export function RichTextEditor({ value, defaultValue = "", onValueChange, placeholder = "Saisir du contenu...", disabled, className, ...props }: RichTextEditorProps) { const editorRef = useRef<HTMLDivElement>(null); useEffect(() => { if (value !== undefined && editorRef.current && editorRef.current.innerHTML !== value) editorRef.current.innerHTML = value; }, [value]); const command = (name: string) => { editorRef.current?.focus(); document.execCommand(name); onValueChange?.(editorRef.current?.innerHTML ?? ""); }; return <div className={cn("sia-rich-text-editor", disabled && "sia-rich-text-editor--disabled", className)}><div className="sia-editor-toolbar"><button type="button" disabled={disabled} aria-label="Gras" onClick={() => command("bold")}><strong>B</strong></button><button type="button" disabled={disabled} aria-label="Italique" onClick={() => command("italic")}><em>I</em></button><button type="button" disabled={disabled} aria-label="Liste" onClick={() => command("insertUnorderedList")}>• Liste</button></div><div {...props} ref={editorRef} role="textbox" aria-multiline="true" contentEditable={!disabled} suppressContentEditableWarning data-placeholder={placeholder} dangerouslySetInnerHTML={value === undefined ? { __html: defaultValue } : undefined} onInput={(event) => onValueChange?.(event.currentTarget.innerHTML)} /></div>; }
|