@hgdc/ui 0.15.0 → 0.16.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/README.md +12 -11
- package/dist/board.d.ts +45 -0
- package/dist/board.js +184 -0
- package/package.json +1 -1
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
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
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
|
|
package/dist/board.d.ts
ADDED
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hgdc/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
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",
|