@hgdc/ui 0.15.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,17 +102,18 @@ Sur ordinateur, la coque ouvre sur la barre des produits (ADR 0011) : organisati
102
102
 
103
103
  ## Listes, journaux et retours
104
104
 
105
- | Import | Exemple |
106
- | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
- | `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
108
- | `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
109
- | `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
110
- | `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
111
- | `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
112
- | `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
113
- | `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
114
- | `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
115
- | `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
105
+ | Import | Exemple |
106
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
107
+ | `filter-bar` | `<FilterBar search={{ value: q, onChange }} filters={[{ key: "status", label: "Statut", options }]} values={{ status }} onFilterChange={…} onReset={…} />` |
108
+ | `data-table` | `<DataTable label="Déploiements" rows={rows} columns={columns} rowKey={(d) => d.id} sort={sort} onSortChange={…} filters={<FilterBar …/>} loadMore={…} />` |
109
+ | `board` | `<Board label="Tableau du projet" columns={[{ id, label, title, cards }]} cardKey={(c) => c.id} cardLabel={(c) => c.title} renderCard={(c, { handle }) => …} onMove={…} />` : poignée souris, doigt, clavier |
110
+ | `dns-record-table` | `<DnsRecordTable records={[{ type: "MX", name: "send", value, priority: 10, status: "pending" }]} />` (Vérifié, En attente, Échec, À ajouter) |
111
+ | `log-viewer` | `<LogViewer lines={[{ id, time, level: "error", message }]} live />` : niveaux, recherche, « Suivre », copie ; seules les lignes visibles sont rendues |
112
+ | `sparkline` | `<Sparkline label="Envois sur 7 jours" values={[3, 8, 5]} color={2} />` |
113
+ | `middle-truncate` | `<MiddleTruncate text={deployment.url} />` : « …/routes/index.tsx », texte entier dans la page |
114
+ | `error-card` | `<ErrorCard title="Impossible de charger les journaux" detail={message} reference={requestId} onRetry={refetch} />` |
115
+ | `destructive-dialog` | `<DestructiveDialog open={open} onOpenChange={setOpen} title="Supprimer le domaine" question="…" name="acme.ch" onConfirm={remove} />` |
116
+ | `sonner` | `<Toaster />` une fois, puis `toastUndo("Domaine retiré", { onUndo: restore })` ; `errorMessage(error)` pour un toast d'erreur |
116
117
 
117
118
  Filtres, tri et page vivent dans l'URL du produit : les composants sont contrôlés et ne connaissent pas le routeur.
118
119
 
@@ -0,0 +1,45 @@
1
+ import { type ReactNode } from "react";
2
+ export type BoardColumn<Card> = {
3
+ id: string;
4
+ title: ReactNode;
5
+ /** Nom de la colonne pour les annonces : « En cours ». */
6
+ label: string;
7
+ cards: ReadonlyArray<Card>;
8
+ /** Sous les cartes : un bouton « Ajouter », un total. */
9
+ footer?: ReactNode;
10
+ };
11
+ export type BoardMove = {
12
+ cardId: string;
13
+ fromColumnId: string;
14
+ toColumnId: string;
15
+ toIndex: number;
16
+ };
17
+ /**
18
+ * Tableau de colonnes (Kanban) dont les cartes se déplacent à la souris, au doigt ou au clavier,
19
+ * par leur poignée (`handle`, à placer dans la carte) : le reste de la carte garde ses clics et le
20
+ * défilement. Au clavier, Espace ou Entrée sur la poignée saisit la carte, les flèches la déplacent
21
+ * (haut et bas dans la colonne, gauche et droite d'une colonne à l'autre), Espace ou Entrée la
22
+ * pose, Échap annule ; chaque étape est annoncée aux lecteurs d'écran.
23
+ *
24
+ * Contrôlé : `onMove` reçoit le déplacement, le produit l'enregistre et rend les nouvelles
25
+ * colonnes. `canMove` refuse une carte (verrouillée, en lecture seule).
26
+ */
27
+ export declare function Board<Card>({ label, columns, cardKey, cardLabel, renderCard, onMove, canMove, className, }: {
28
+ /** Nom du tableau : « Tableau du projet ». */
29
+ label: string;
30
+ columns: ReadonlyArray<BoardColumn<Card>>;
31
+ cardKey: (card: Card) => string;
32
+ /** Nom de la carte pour les annonces : « Maquette de l'accueil ». */
33
+ cardLabel: (card: Card) => string;
34
+ /**
35
+ * La carte. `handle` est la poignée (bouton « Déplacer … ») à placer dans la carte : elle porte
36
+ * le clavier et le glisser, le reste de la carte garde ses clics. `null` si `canMove` refuse.
37
+ */
38
+ renderCard: (card: Card, state: {
39
+ dragging: boolean;
40
+ handle: ReactNode;
41
+ }) => ReactNode;
42
+ onMove: (move: BoardMove) => void;
43
+ canMove?: (card: Card) => boolean;
44
+ className?: string;
45
+ }): React.JSX.Element;
package/dist/board.js ADDED
@@ -0,0 +1,184 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef, useState } from "react";
3
+ import { cn } from "./cn.js";
4
+ /** Distance avant qu'un appui devienne un glisser : un clic reste un clic. */
5
+ const DRAG_THRESHOLD = 6;
6
+ function moveInColumns(columns, cardKey, grab) {
7
+ const base = columns.map((column) => ({ column, cards: [...column.cards] }));
8
+ if (!grab)
9
+ return base;
10
+ let moving;
11
+ for (const entry of base) {
12
+ const index = entry.cards.findIndex((card) => cardKey(card) === grab.cardId);
13
+ if (index >= 0)
14
+ moving = entry.cards.splice(index, 1)[0];
15
+ }
16
+ const target = base.find((entry) => entry.column.id === grab.to.columnId);
17
+ if (moving !== undefined && target)
18
+ target.cards.splice(Math.min(grab.to.index, target.cards.length), 0, moving);
19
+ return base;
20
+ }
21
+ /**
22
+ * Tableau de colonnes (Kanban) dont les cartes se déplacent à la souris, au doigt ou au clavier,
23
+ * par leur poignée (`handle`, à placer dans la carte) : le reste de la carte garde ses clics et le
24
+ * défilement. Au clavier, Espace ou Entrée sur la poignée saisit la carte, les flèches la déplacent
25
+ * (haut et bas dans la colonne, gauche et droite d'une colonne à l'autre), Espace ou Entrée la
26
+ * pose, Échap annule ; chaque étape est annoncée aux lecteurs d'écran.
27
+ *
28
+ * Contrôlé : `onMove` reçoit le déplacement, le produit l'enregistre et rend les nouvelles
29
+ * colonnes. `canMove` refuse une carte (verrouillée, en lecture seule).
30
+ */
31
+ export function Board({ label, columns, cardKey, cardLabel, renderCard, onMove, canMove = () => true, className, }) {
32
+ const [grab, setGrab] = useState(null);
33
+ const [announcement, setAnnouncement] = useState("");
34
+ const [ghost, setGhost] = useState(null);
35
+ const pressed = useRef(null);
36
+ const instructionsId = useId();
37
+ const view = moveInColumns(columns, cardKey, grab);
38
+ const byId = new Map(columns.flatMap((column) => column.cards.map((card) => [cardKey(card), card])));
39
+ const columnLabel = (id) => columns.find((column) => column.id === id)?.label ?? "";
40
+ const where = (position) => `${columnLabel(position.columnId)}, position ${position.index + 1}`;
41
+ function focusCard(cardId) {
42
+ // Après le rendu : la carte a pu changer de colonne, donc de nœud.
43
+ requestAnimationFrame(() => document.querySelector(`[data-board-handle="${CSS.escape(cardId)}"]`)?.focus());
44
+ }
45
+ function start(card, from, via) {
46
+ setGrab({ cardId: cardKey(card), from, to: from, via });
47
+ setAnnouncement(`${cardLabel(card)} saisie. ${where(from)}.`);
48
+ }
49
+ function finish(commit) {
50
+ if (!grab)
51
+ return;
52
+ const card = byId.get(grab.cardId);
53
+ const changed = grab.to.columnId !== grab.from.columnId || grab.to.index !== grab.from.index;
54
+ if (commit && changed)
55
+ onMove({
56
+ cardId: grab.cardId,
57
+ fromColumnId: grab.from.columnId,
58
+ toColumnId: grab.to.columnId,
59
+ toIndex: grab.to.index,
60
+ });
61
+ setAnnouncement(card
62
+ ? commit && changed
63
+ ? `${cardLabel(card)} posée. ${where(grab.to)}.`
64
+ : `${cardLabel(card)} : déplacement annulé.`
65
+ : "");
66
+ const id = grab.cardId;
67
+ setGrab(null);
68
+ setGhost(null);
69
+ if (grab.via === "keyboard")
70
+ focusCard(id);
71
+ }
72
+ function onKeyDown(event, card, position) {
73
+ if (event.target !== event.currentTarget)
74
+ return;
75
+ const id = cardKey(card);
76
+ if (!grab) {
77
+ if ((event.key === " " || event.key === "Enter") && canMove(card)) {
78
+ event.preventDefault();
79
+ start(card, position, "keyboard");
80
+ }
81
+ return;
82
+ }
83
+ if (grab.cardId !== id)
84
+ return;
85
+ if (event.key === " " || event.key === "Enter") {
86
+ event.preventDefault();
87
+ finish(true);
88
+ return;
89
+ }
90
+ if (event.key === "Escape") {
91
+ event.preventDefault();
92
+ finish(false);
93
+ return;
94
+ }
95
+ const columnIndex = view.findIndex((entry) => entry.column.id === grab.to.columnId);
96
+ let next = null;
97
+ const sizeOf = (index) => view[index]?.cards.filter((c) => cardKey(c) !== id).length ?? 0;
98
+ if (event.key === "ArrowUp")
99
+ next = { ...grab.to, index: Math.max(0, grab.to.index - 1) };
100
+ if (event.key === "ArrowDown")
101
+ next = { ...grab.to, index: Math.min(sizeOf(columnIndex), grab.to.index + 1) };
102
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
103
+ const target = view[columnIndex + (event.key === "ArrowLeft" ? -1 : 1)];
104
+ if (target)
105
+ next = {
106
+ columnId: target.column.id,
107
+ index: Math.min(grab.to.index, sizeOf(columnIndex + (event.key === "ArrowLeft" ? -1 : 1))),
108
+ };
109
+ }
110
+ if (!next)
111
+ return;
112
+ event.preventDefault();
113
+ setGrab({ ...grab, to: next });
114
+ setAnnouncement(where(next));
115
+ focusCard(id);
116
+ }
117
+ /** Colonne et place sous le pointeur, d'après le milieu des cartes. */
118
+ function positionAt(x, y, cardId) {
119
+ const element = document.elementFromPoint(x, y);
120
+ const column = element?.closest("[data-board-column]");
121
+ const columnId = column?.dataset.boardColumn;
122
+ if (!column || !columnId)
123
+ return null;
124
+ const cards = [...column.querySelectorAll("[data-board-card]")].filter((node) => node.dataset.boardCard !== cardId);
125
+ const index = cards.findIndex((node) => {
126
+ const rect = node.getBoundingClientRect();
127
+ return y < rect.top + rect.height / 2;
128
+ });
129
+ return { columnId, index: index === -1 ? cards.length : index };
130
+ }
131
+ function onPointerDown(event, card, position) {
132
+ if (event.button !== 0 || !canMove(card))
133
+ return;
134
+ const rect = (event.currentTarget.closest("[data-board-card]") ?? event.currentTarget).getBoundingClientRect();
135
+ pressed.current = {
136
+ cardId: cardKey(card),
137
+ from: position,
138
+ x: event.clientX,
139
+ y: event.clientY,
140
+ width: rect.width,
141
+ pointerId: event.pointerId,
142
+ };
143
+ }
144
+ function onPointerMove(event) {
145
+ const press = pressed.current;
146
+ if (!press || press.pointerId !== event.pointerId)
147
+ return;
148
+ if (!grab) {
149
+ if (Math.hypot(event.clientX - press.x, event.clientY - press.y) < DRAG_THRESHOLD)
150
+ return;
151
+ const card = byId.get(press.cardId);
152
+ if (!card)
153
+ return;
154
+ event.currentTarget.setPointerCapture(event.pointerId);
155
+ start(card, press.from, "pointer");
156
+ }
157
+ setGhost({ x: event.clientX, y: event.clientY, width: press.width });
158
+ const next = positionAt(event.clientX, event.clientY, press.cardId);
159
+ if (next)
160
+ setGrab((current) => (current ? { ...current, to: next } : current));
161
+ }
162
+ function onPointerUp(event) {
163
+ const press = pressed.current;
164
+ if (!press || press.pointerId !== event.pointerId)
165
+ return;
166
+ pressed.current = null;
167
+ if (grab)
168
+ finish(true);
169
+ }
170
+ const ghostCard = grab?.via === "pointer" ? byId.get(grab.cardId) : undefined;
171
+ return (_jsxs("div", { className: cn("grid gap-2", className), children: [_jsx("p", { id: instructionsId, className: "sr-only", children: "Espace ou Entr\u00E9e pour saisir une carte, fl\u00E8ches pour la d\u00E9placer, Espace pour la poser, \u00C9chap pour annuler." }), _jsx("div", { role: "region", "aria-label": label, className: "-mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2 sm:snap-none", children: view.map(({ column, cards }) => (_jsxs("section", { "data-board-column": column.id, "aria-label": column.label, className: cn("grid w-[85vw] max-w-80 shrink-0 snap-start content-start gap-2 rounded-3xl bg-foreground/[0.04] p-2 sm:w-72", grab?.to.columnId === column.id && "ring-2 ring-ring/40"), children: [_jsx("header", { className: "flex items-center justify-between gap-2 px-2 pt-1 text-sm font-medium", children: column.title }), _jsx("ul", { className: "grid min-h-12 gap-2", children: cards.map((card, index) => {
172
+ const id = cardKey(card);
173
+ const dragging = grab?.cardId === id;
174
+ const movable = canMove(card);
175
+ return (_jsx("li", { "data-board-card": id, className: cn("rounded-2xl", dragging && "opacity-50 ring-2 ring-ring"), children: renderCard(card, {
176
+ dragging,
177
+ handle: movable ? (_jsx("button", { type: "button", "data-board-handle": id, "aria-label": `Déplacer ${cardLabel(card)}`, "aria-roledescription": "poign\u00E9e de d\u00E9placement", "aria-describedby": instructionsId, onKeyDown: (event) => onKeyDown(event, card, { columnId: column.id, index }), onPointerDown: (event) => onPointerDown(event, card, { columnId: column.id, index }), onPointerMove: onPointerMove, onPointerUp: onPointerUp, onPointerCancel: () => {
178
+ pressed.current = null;
179
+ if (grab)
180
+ finish(false);
181
+ }, className: "inline-flex size-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-lg text-muted-foreground outline-none hover:bg-foreground/[0.06] focus-visible:ring-2 focus-visible:ring-ring active:cursor-grabbing", children: _jsxs("svg", { viewBox: "0 0 16 16", "aria-hidden": true, className: "size-4 fill-current", children: [_jsx("circle", { cx: "5.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "3.5", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "8", r: "1.25" }), _jsx("circle", { cx: "5.5", cy: "12.5", r: "1.25" }), _jsx("circle", { cx: "10.5", cy: "12.5", r: "1.25" })] }) })) : null,
182
+ }) }, id));
183
+ }) }), column.footer] }, column.id))) }), ghostCard !== undefined && ghost ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-50 -translate-x-1/2 -translate-y-1/2 rotate-1 opacity-90", style: { left: ghost.x, top: ghost.y, width: ghost.width }, children: renderCard(ghostCard, { dragging: true, handle: null }) })) : null, _jsx("div", { "aria-live": "assertive", className: "sr-only", children: announcement })] }));
184
+ }
@@ -16,12 +16,19 @@ export function familyLoaderStartTime(timeOrigin, period = FAMILY_LOADER_PERIOD_
16
16
  export function familyLoaderPhase(epochMs, period = FAMILY_LOADER_PERIOD_MS) {
17
17
  return ((epochMs % period) + period) % period;
18
18
  }
19
+ function isLoaderAnimation(animation) {
20
+ const name = animation.animationName;
21
+ return typeof name === "string" && name.startsWith("family-loader");
22
+ }
19
23
  /** Cale toutes les animations du H sous `root` sur l'horloge (voir `familyLoaderStartTime`). */
20
24
  export function syncFamilyLoader(root) {
21
25
  if (!root || typeof root.getAnimations !== "function")
22
26
  return;
23
27
  const start = familyLoaderStartTime(performance.timeOrigin);
24
28
  for (const animation of root.getAnimations({ subtree: true })) {
29
+ // Les animations du H seulement : une transition (fondu du chargeur) garde son propre départ.
30
+ if (!isLoaderAnimation(animation))
31
+ continue;
25
32
  if (animation.startTime !== start)
26
33
  animation.startTime = start;
27
34
  }
@@ -31,7 +38,7 @@ export function syncFamilyLoader(root) {
31
38
  * avant l'hydratation, une fois la feuille de style chargée (un script attend les feuilles de
32
39
  * style qui le précèdent). Une animation recréée (mouvement réduit basculé) se recale aussi.
33
40
  */
34
- const SYNC_SCRIPT = `(function(e){if(!e||!e.getAnimations)return;var p=${FAMILY_LOADER_PERIOD_MS};function s(){var t=-(((performance.timeOrigin%p)+p)%p);e.getAnimations({subtree:true}).forEach(function(a){if(a.startTime!==t)a.startTime=t})}s();e.addEventListener("animationstart",s)})(document.currentScript&&document.currentScript.previousElementSibling)`;
41
+ const SYNC_SCRIPT = `(function(e){if(!e||!e.getAnimations)return;var p=${FAMILY_LOADER_PERIOD_MS};function s(){var t=-(((performance.timeOrigin%p)+p)%p);e.getAnimations({subtree:true}).forEach(function(a){if(String(a.animationName).indexOf("family-loader")===0&&a.startTime!==t)a.startTime=t})}s();e.addEventListener("animationstart",s)})(document.currentScript&&document.currentScript.previousElementSibling)`;
35
42
  const useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
36
43
  /** Cale le H sur l'horloge au montage et à chaque animation recréée. */
37
44
  function useClockSync(ref) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hgdc/ui",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
4
4
  "description": "Interface de la H family : jetons de la DA, thèmes, polices et composants.",
5
5
  "license": "UNLICENSED",
6
6
  "homepage": "https://github.com/hugogebel-boop/hgdc-kit/tree/main/packages/ui#readme",