@happyvertical/smrt-svelte 0.40.65 → 0.40.67
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 +31 -1
- package/dist/components/board/Board.svelte +612 -0
- package/dist/components/board/Board.svelte.d.ts +26 -0
- package/dist/components/board/Board.svelte.d.ts.map +1 -0
- package/dist/components/board/__tests__/Board.test.js +498 -0
- package/dist/components/board/__tests__/BoardSsrHarness.svelte +30 -0
- package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts +19 -0
- package/dist/components/board/__tests__/BoardSsrHarness.svelte.d.ts.map +1 -0
- package/dist/components/board/index.d.ts +3 -0
- package/dist/components/board/index.d.ts.map +1 -0
- package/dist/components/board/index.js +1 -0
- package/dist/components/board/types.d.ts +84 -0
- package/dist/components/board/types.d.ts.map +1 -0
- package/dist/components/board/types.js +1 -0
- package/dist/i18n/__tests__/server.spec.js +7 -0
- package/dist/i18n/server.d.ts +1 -0
- package/dist/i18n/server.d.ts.map +1 -1
- package/dist/i18n/server.js +1 -0
- package/dist/i18n/strings.board.d.ts +14 -0
- package/dist/i18n/strings.board.d.ts.map +1 -0
- package/dist/i18n/strings.board.js +14 -0
- package/package.json +12 -6
package/README.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @happyvertical/smrt-svelte
|
|
2
2
|
|
|
3
|
+
## Board
|
|
4
|
+
|
|
5
|
+
`@happyvertical/smrt-svelte/board` provides a generic, accessible Svelte 5
|
|
6
|
+
Kanban-style board. It has no project, sales, or support dependency: supply
|
|
7
|
+
your own cards, columns, card-to-column getter/setter, and typed card snippet.
|
|
8
|
+
`cards` is controlled; `defaultCards` enables local state. `onmove` receives a
|
|
9
|
+
typed intent and can be async—rejections restore the previous view. Set
|
|
10
|
+
`optimistic` to present controlled moves while persistence is pending.
|
|
11
|
+
Set `allowSameColumnReorder={false}` when an adapter persists only lane/status
|
|
12
|
+
transitions rather than a position within a lane.
|
|
13
|
+
|
|
14
|
+
```svelte
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import { Board } from '@happyvertical/smrt-svelte/board';
|
|
17
|
+
</script>
|
|
18
|
+
|
|
19
|
+
<Board
|
|
20
|
+
{columns}
|
|
21
|
+
{cards}
|
|
22
|
+
getCardColumnId={(card) => card.stage}
|
|
23
|
+
setCardColumnId={(card, stage) => ({ ...card, stage })}
|
|
24
|
+
getCardLabel={(card) => card.subject}
|
|
25
|
+
card={cardSnippet}
|
|
26
|
+
onmove={({ card, target }) => save({ ...card, stage: target.columnId })}
|
|
27
|
+
/>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The same primitive can represent support queues (`card.queue`) or a sales
|
|
31
|
+
pipeline (`opportunity.stage`) without importing `@happyvertical/smrt-projects`.
|
|
32
|
+
|
|
3
33
|
Svelte 5 component library for the s-m-r-t framework. Provides UI components, browser AI integration (STT/TTS/LLM with warm cache), a theme system, permission-aware rendering, and module UI registry for agent admin panels.
|
|
4
34
|
|
|
5
35
|
## Installation
|
|
@@ -176,7 +206,7 @@ resolve against `smrt-ui`:
|
|
|
176
206
|
| `@happyvertical/smrt-ui/feedback` | Modal, ConfirmDialog, LoadingOverlay, ProgressBar |
|
|
177
207
|
| `@happyvertical/smrt-ui/chat` | Message bubble, reaction picker, typing indicator |
|
|
178
208
|
| `@happyvertical/smrt-ui/registry` | ModuleUIRegistry for agent admin panels |
|
|
179
|
-
| `@happyvertical/smrt-ui/themes` | Canonical ThemeProvider, Material/Glass/Studio/s-m-r-t presets, CSS generation |
|
|
209
|
+
| `@happyvertical/smrt-ui/themes` | Canonical ThemeProvider, Material/Glass/Studio/s-m-r-t/HappyVertical presets, CSS generation |
|
|
180
210
|
| `@happyvertical/smrt-ui/i18n` | Client i18n (`useI18n`, `Trans`) — the counterpart to `smrt-svelte`'s `/i18n/server` |
|
|
181
211
|
| `@happyvertical/smrt-ui/styles/tokens.css` | Design tokens CSS |
|
|
182
212
|
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
<script lang="ts" generics="Card extends BoardCard, Column extends BoardColumn">
|
|
2
|
+
import { useI18n } from '@happyvertical/smrt-ui/i18n';
|
|
3
|
+
import { BROWSER } from 'esm-env';
|
|
4
|
+
import { tick, untrack } from 'svelte';
|
|
5
|
+
import { M } from '../../i18n/strings.board.js';
|
|
6
|
+
import type {
|
|
7
|
+
BoardCard,
|
|
8
|
+
BoardColumn,
|
|
9
|
+
BoardMoveIntent,
|
|
10
|
+
BoardPosition,
|
|
11
|
+
BoardProps,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
interface DragState {
|
|
15
|
+
cardId: string;
|
|
16
|
+
source: BoardPosition;
|
|
17
|
+
target: BoardPosition;
|
|
18
|
+
mode: 'keyboard' | 'pointer';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PointerSession {
|
|
22
|
+
cardId: string;
|
|
23
|
+
pointerId: number;
|
|
24
|
+
startX: number;
|
|
25
|
+
startY: number;
|
|
26
|
+
active: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
columns,
|
|
31
|
+
cards,
|
|
32
|
+
defaultCards = [],
|
|
33
|
+
getCardColumnId,
|
|
34
|
+
setCardColumnId,
|
|
35
|
+
getCardLabel,
|
|
36
|
+
card,
|
|
37
|
+
columnHeader,
|
|
38
|
+
label,
|
|
39
|
+
collapsible = false,
|
|
40
|
+
allowSameColumnReorder = true,
|
|
41
|
+
optimistic = false,
|
|
42
|
+
onselect,
|
|
43
|
+
onmove,
|
|
44
|
+
}: BoardProps<Card, Column> = $props();
|
|
45
|
+
|
|
46
|
+
const { t } = useI18n();
|
|
47
|
+
const instanceId = $props.id();
|
|
48
|
+
const liveId = `${instanceId}-live`;
|
|
49
|
+
let root: HTMLElement;
|
|
50
|
+
let localCards = $state<Card[]>(untrack(() => [...defaultCards]));
|
|
51
|
+
let optimisticCards = $state<Card[] | undefined>();
|
|
52
|
+
let lastAuthoritativeCards = untrack(() => cards);
|
|
53
|
+
let drag = $state<DragState | undefined>();
|
|
54
|
+
let collapsed = $state<Set<string>>(new Set());
|
|
55
|
+
let announcement = $state('');
|
|
56
|
+
let focusCardId = $state<string | undefined>();
|
|
57
|
+
let suppressedSelectionCardId = $state<string | undefined>();
|
|
58
|
+
let movePending = $state(false);
|
|
59
|
+
let pointerSession = $state<PointerSession | undefined>();
|
|
60
|
+
|
|
61
|
+
// A new owner array is an explicit reconciliation point. Do not clear an
|
|
62
|
+
// optimistic move merely because this component has re-rendered.
|
|
63
|
+
$effect(() => {
|
|
64
|
+
if (!BROWSER) return;
|
|
65
|
+
if (cards !== lastAuthoritativeCards) {
|
|
66
|
+
lastAuthoritativeCards = cards;
|
|
67
|
+
optimisticCards = undefined;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const presentationCards = $derived(
|
|
72
|
+
cards === undefined ? localCards : (optimisticCards ?? cards),
|
|
73
|
+
);
|
|
74
|
+
const cardsByColumn = $derived.by(() => {
|
|
75
|
+
const next = new Map<string, Card[]>();
|
|
76
|
+
for (const column of columns) next.set(column.id, []);
|
|
77
|
+
for (const item of presentationCards) {
|
|
78
|
+
const columnCards = next.get(getCardColumnId(item));
|
|
79
|
+
if (columnCards) columnCards.push(item);
|
|
80
|
+
}
|
|
81
|
+
return next;
|
|
82
|
+
});
|
|
83
|
+
const movable = $derived(
|
|
84
|
+
(cards === undefined || onmove !== undefined) && !movePending,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
$effect(() => {
|
|
88
|
+
if (!BROWSER) return;
|
|
89
|
+
if (!focusCardId) return;
|
|
90
|
+
const cardId = focusCardId;
|
|
91
|
+
focusCardId = undefined;
|
|
92
|
+
tick().then(() => {
|
|
93
|
+
Array.from(
|
|
94
|
+
root.querySelectorAll<HTMLButtonElement>('[data-smrt-board-card-id]'),
|
|
95
|
+
)
|
|
96
|
+
.find((element) => element.dataset.smrtBoardCardId === cardId)
|
|
97
|
+
?.focus();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function laneListId(index: number): string {
|
|
102
|
+
return `${instanceId}-lane-${index}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function cardsInColumn(columnId: string, withoutCardId?: string): Card[] {
|
|
106
|
+
const items = cardsByColumn.get(columnId) ?? [];
|
|
107
|
+
return withoutCardId
|
|
108
|
+
? items.filter((item) => item.id !== withoutCardId)
|
|
109
|
+
: items;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function findCard(cardId: string): Card | undefined {
|
|
113
|
+
return presentationCards.find((item) => item.id === cardId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function findColumn(columnId: string): Column | undefined {
|
|
117
|
+
return columns.find((column) => column.id === columnId);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function positionFor(cardId: string): BoardPosition | undefined {
|
|
121
|
+
const item = findCard(cardId);
|
|
122
|
+
if (!item) return undefined;
|
|
123
|
+
const columnId = getCardColumnId(item);
|
|
124
|
+
const index = cardsInColumn(columnId).findIndex(
|
|
125
|
+
(candidate) => candidate.id === cardId,
|
|
126
|
+
);
|
|
127
|
+
return index < 0 ? undefined : { columnId, index };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function clamp(index: number, max: number): number {
|
|
131
|
+
return Math.max(0, Math.min(index, max));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createIntent(
|
|
135
|
+
state: DragState,
|
|
136
|
+
): BoardMoveIntent<Card, Column> | undefined {
|
|
137
|
+
const movingCard = findCard(state.cardId);
|
|
138
|
+
const sourceColumn = findColumn(state.source.columnId);
|
|
139
|
+
const targetColumn = findColumn(state.target.columnId);
|
|
140
|
+
if (!movingCard || !sourceColumn || !targetColumn || targetColumn.disabled)
|
|
141
|
+
return undefined;
|
|
142
|
+
return {
|
|
143
|
+
card: movingCard,
|
|
144
|
+
source: state.source,
|
|
145
|
+
target: state.target,
|
|
146
|
+
sourceColumn,
|
|
147
|
+
targetColumn,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function moveCards(intent: BoardMoveIntent<Card, Column>): Card[] {
|
|
152
|
+
const remaining = presentationCards.filter(
|
|
153
|
+
(item) => item.id !== intent.card.id,
|
|
154
|
+
);
|
|
155
|
+
const insertAt = (() => {
|
|
156
|
+
if (intent.target.index === 0) {
|
|
157
|
+
return remaining.findIndex(
|
|
158
|
+
(item) => getCardColumnId(item) === intent.target.columnId,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
let seen = 0;
|
|
162
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
163
|
+
if (getCardColumnId(remaining[index]) !== intent.target.columnId)
|
|
164
|
+
continue;
|
|
165
|
+
seen += 1;
|
|
166
|
+
if (seen === intent.target.index) return index + 1;
|
|
167
|
+
}
|
|
168
|
+
return remaining.length;
|
|
169
|
+
})();
|
|
170
|
+
|
|
171
|
+
const moved = setCardColumnId(intent.card, intent.target.columnId);
|
|
172
|
+
return [
|
|
173
|
+
...remaining.slice(0, insertAt < 0 ? remaining.length : insertAt),
|
|
174
|
+
moved,
|
|
175
|
+
...remaining.slice(insertAt < 0 ? remaining.length : insertAt),
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function pickup(cardId: string, mode: DragState['mode']): void {
|
|
180
|
+
if (!movable) return;
|
|
181
|
+
const source = positionFor(cardId);
|
|
182
|
+
const movingCard = findCard(cardId);
|
|
183
|
+
if (!source || !movingCard) return;
|
|
184
|
+
drag = { cardId, source, target: { ...source }, mode };
|
|
185
|
+
announcement = t(M['ui.board.pickup'], {
|
|
186
|
+
card: getCardLabel(movingCard),
|
|
187
|
+
destinations: columns
|
|
188
|
+
.filter((column) => !column.disabled)
|
|
189
|
+
.map((column) => column.label)
|
|
190
|
+
.join(', '),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function announcePosition(state: DragState): void {
|
|
195
|
+
const movingCard = findCard(state.cardId);
|
|
196
|
+
const column = findColumn(state.target.columnId);
|
|
197
|
+
if (!movingCard || !column) return;
|
|
198
|
+
const count = cardsInColumn(column.id, state.cardId).length + 1;
|
|
199
|
+
announcement = t(M['ui.board.position'], {
|
|
200
|
+
card: getCardLabel(movingCard),
|
|
201
|
+
column: column.label,
|
|
202
|
+
position: state.target.index + 1,
|
|
203
|
+
count,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function moveKeyboardTarget(
|
|
208
|
+
direction: 'horizontal' | 'vertical',
|
|
209
|
+
delta: number,
|
|
210
|
+
): void {
|
|
211
|
+
if (drag?.mode !== 'keyboard') return;
|
|
212
|
+
const target = { ...drag.target };
|
|
213
|
+
if (direction === 'vertical') {
|
|
214
|
+
if (!allowSameColumnReorder) return;
|
|
215
|
+
target.index = clamp(
|
|
216
|
+
target.index + delta,
|
|
217
|
+
cardsInColumn(target.columnId, drag.cardId).length,
|
|
218
|
+
);
|
|
219
|
+
} else {
|
|
220
|
+
const currentIndex = columns.findIndex(
|
|
221
|
+
(column) => column.id === target.columnId,
|
|
222
|
+
);
|
|
223
|
+
const candidate = columns[currentIndex + delta];
|
|
224
|
+
if (!candidate) return;
|
|
225
|
+
if (candidate.disabled) {
|
|
226
|
+
announcement = t(M['ui.board.unavailable_column'], {
|
|
227
|
+
column: candidate.label,
|
|
228
|
+
});
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
target.columnId = candidate.id;
|
|
232
|
+
target.index = clamp(
|
|
233
|
+
target.index,
|
|
234
|
+
cardsInColumn(candidate.id, drag.cardId).length,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
drag = { ...drag, target };
|
|
238
|
+
announcePosition(drag);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function drop(): Promise<void> {
|
|
242
|
+
if (!drag) return;
|
|
243
|
+
const state = drag;
|
|
244
|
+
const intent = createIntent(state);
|
|
245
|
+
drag = undefined;
|
|
246
|
+
suppressSelection(state.cardId);
|
|
247
|
+
if (!intent) return;
|
|
248
|
+
const changed =
|
|
249
|
+
intent.source.columnId !== intent.target.columnId ||
|
|
250
|
+
intent.source.index !== intent.target.index;
|
|
251
|
+
if (!changed) {
|
|
252
|
+
focusCardId = intent.card.id;
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const reordered = moveCards(intent);
|
|
256
|
+
expandColumn(intent.target.columnId);
|
|
257
|
+
if (cards !== undefined && optimistic) optimisticCards = reordered;
|
|
258
|
+
movePending = true;
|
|
259
|
+
try {
|
|
260
|
+
await onmove?.(intent);
|
|
261
|
+
} catch {
|
|
262
|
+
optimisticCards = undefined;
|
|
263
|
+
announcement = t(M['ui.board.move_failed'], {
|
|
264
|
+
card: getCardLabel(intent.card),
|
|
265
|
+
});
|
|
266
|
+
focusCardId = intent.card.id;
|
|
267
|
+
return;
|
|
268
|
+
} finally {
|
|
269
|
+
movePending = false;
|
|
270
|
+
}
|
|
271
|
+
if (cards === undefined) localCards = reordered;
|
|
272
|
+
const count =
|
|
273
|
+
cardsInColumn(intent.target.columnId, intent.card.id).length + 1;
|
|
274
|
+
announcement = t(M['ui.board.drop'], {
|
|
275
|
+
card: getCardLabel(intent.card),
|
|
276
|
+
column: intent.targetColumn.label,
|
|
277
|
+
position: intent.target.index + 1,
|
|
278
|
+
count,
|
|
279
|
+
});
|
|
280
|
+
focusCardId = intent.card.id;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function cancel(announce = true): void {
|
|
284
|
+
if (!drag) return;
|
|
285
|
+
const movingCard = findCard(drag.cardId);
|
|
286
|
+
if (movingCard && announce) {
|
|
287
|
+
announcement = t(M['ui.board.cancel'], { card: getCardLabel(movingCard) });
|
|
288
|
+
}
|
|
289
|
+
if (movingCard) {
|
|
290
|
+
focusCardId = movingCard.id;
|
|
291
|
+
}
|
|
292
|
+
suppressSelection(drag.cardId);
|
|
293
|
+
drag = undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function suppressSelection(cardId: string): void {
|
|
297
|
+
suppressedSelectionCardId = cardId;
|
|
298
|
+
window.setTimeout(() => {
|
|
299
|
+
if (suppressedSelectionCardId === cardId)
|
|
300
|
+
suppressedSelectionCardId = undefined;
|
|
301
|
+
}, 0);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function selectCard(item: Card): void {
|
|
305
|
+
// Keyboard pickup/drop and native drag gestures can both dispatch a click
|
|
306
|
+
// after their key/pointer sequence. Consume it so a move never doubles as
|
|
307
|
+
// an application-level selection.
|
|
308
|
+
if (suppressedSelectionCardId === item.id) {
|
|
309
|
+
suppressedSelectionCardId = undefined;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!drag) onselect?.(item);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function handleCardKeydown(event: KeyboardEvent, cardId: string): void {
|
|
316
|
+
if (!drag) {
|
|
317
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
318
|
+
if (!movable) return;
|
|
319
|
+
event.preventDefault();
|
|
320
|
+
pickup(cardId, 'keyboard');
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (drag.cardId !== cardId || drag.mode !== 'keyboard') return;
|
|
325
|
+
if (event.key === 'Escape') {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
cancel();
|
|
328
|
+
} else if (event.key === ' ' || event.key === 'Enter') {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
void drop();
|
|
331
|
+
} else if (event.key === 'ArrowLeft') {
|
|
332
|
+
event.preventDefault();
|
|
333
|
+
moveKeyboardTarget('horizontal', -1);
|
|
334
|
+
} else if (event.key === 'ArrowRight') {
|
|
335
|
+
event.preventDefault();
|
|
336
|
+
moveKeyboardTarget('horizontal', 1);
|
|
337
|
+
} else if (event.key === 'ArrowUp') {
|
|
338
|
+
event.preventDefault();
|
|
339
|
+
moveKeyboardTarget('vertical', -1);
|
|
340
|
+
} else if (event.key === 'ArrowDown') {
|
|
341
|
+
event.preventDefault();
|
|
342
|
+
moveKeyboardTarget('vertical', 1);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function handleDragStart(event: DragEvent, cardId: string): void {
|
|
347
|
+
if (!movable) {
|
|
348
|
+
event.preventDefault();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
pickup(cardId, 'pointer');
|
|
352
|
+
event.dataTransfer?.setData('text/plain', cardId);
|
|
353
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function handlePointerDown(event: PointerEvent, cardId: string): void {
|
|
357
|
+
if (!movable || event.button !== 0) return;
|
|
358
|
+
pointerSession = {
|
|
359
|
+
cardId,
|
|
360
|
+
pointerId: event.pointerId,
|
|
361
|
+
startX: event.clientX,
|
|
362
|
+
startY: event.clientY,
|
|
363
|
+
active: false,
|
|
364
|
+
};
|
|
365
|
+
const target = event.currentTarget as HTMLElement;
|
|
366
|
+
target.setPointerCapture?.(event.pointerId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function elementAtPointer(event: PointerEvent): Element | null {
|
|
370
|
+
return (
|
|
371
|
+
document.elementFromPoint?.(event.clientX, event.clientY) ??
|
|
372
|
+
(event.target as Element | null)
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function setPointerTargetFromElement(
|
|
377
|
+
element: Element | null,
|
|
378
|
+
clientY: number,
|
|
379
|
+
): Column | undefined {
|
|
380
|
+
// Column ids belong to the consumer's domain and may repeat in another
|
|
381
|
+
// Board instance. Pointer hit-testing must never escape this Board's root.
|
|
382
|
+
if (!element || !root.contains(element)) return undefined;
|
|
383
|
+
const lane = element?.closest<HTMLElement>('[data-smrt-board-column-id]');
|
|
384
|
+
const columnId = lane?.dataset.smrtBoardColumnId;
|
|
385
|
+
if (!columnId) return undefined;
|
|
386
|
+
const column = findColumn(columnId);
|
|
387
|
+
if (!column || column.disabled) return column;
|
|
388
|
+
const cardElement = element?.closest<HTMLElement>(
|
|
389
|
+
'[data-smrt-board-card-id]',
|
|
390
|
+
);
|
|
391
|
+
if (!cardElement) {
|
|
392
|
+
pointerTarget(columnId, cardsInColumn(columnId, drag?.cardId).length);
|
|
393
|
+
return column;
|
|
394
|
+
}
|
|
395
|
+
const rawIndex = cardsInColumn(columnId).findIndex(
|
|
396
|
+
(item) => item.id === cardElement.dataset.smrtBoardCardId,
|
|
397
|
+
);
|
|
398
|
+
if (rawIndex < 0) return column;
|
|
399
|
+
const rect = cardElement.getBoundingClientRect();
|
|
400
|
+
const before = clientY < rect.top + rect.height / 2;
|
|
401
|
+
let targetIndex = rawIndex + (before ? 0 : 1);
|
|
402
|
+
if (drag?.source.columnId === columnId && targetIndex > drag.source.index) {
|
|
403
|
+
targetIndex -= 1;
|
|
404
|
+
}
|
|
405
|
+
pointerTarget(columnId, targetIndex);
|
|
406
|
+
return column;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function finishPointerSession(event: PointerEvent, cancelled = false): void {
|
|
410
|
+
const session = pointerSession;
|
|
411
|
+
pointerSession = undefined;
|
|
412
|
+
const target = event.currentTarget as HTMLElement;
|
|
413
|
+
if (target.hasPointerCapture?.(event.pointerId))
|
|
414
|
+
target.releasePointerCapture(event.pointerId);
|
|
415
|
+
if (!session?.active || drag?.cardId !== session.cardId) return;
|
|
416
|
+
if (cancelled) {
|
|
417
|
+
cancel();
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const column = setPointerTargetFromElement(
|
|
421
|
+
elementAtPointer(event),
|
|
422
|
+
event.clientY,
|
|
423
|
+
);
|
|
424
|
+
if (column?.disabled) {
|
|
425
|
+
announcement = t(M['ui.board.unavailable_column'], {
|
|
426
|
+
column: column.label,
|
|
427
|
+
});
|
|
428
|
+
cancel(false);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!column) {
|
|
432
|
+
cancel();
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
void drop();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function handlePointerMove(event: PointerEvent): void {
|
|
439
|
+
const session = pointerSession;
|
|
440
|
+
if (!session || session.pointerId !== event.pointerId) return;
|
|
441
|
+
if (!session.active) {
|
|
442
|
+
const distance = Math.hypot(
|
|
443
|
+
event.clientX - session.startX,
|
|
444
|
+
event.clientY - session.startY,
|
|
445
|
+
);
|
|
446
|
+
if (distance < 6) return;
|
|
447
|
+
pickup(session.cardId, 'pointer');
|
|
448
|
+
pointerSession = { ...session, active: true };
|
|
449
|
+
}
|
|
450
|
+
event.preventDefault();
|
|
451
|
+
setPointerTargetFromElement(elementAtPointer(event), event.clientY);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function pointerTarget(columnId: string, index: number): void {
|
|
455
|
+
if (drag?.mode !== 'pointer') return;
|
|
456
|
+
if (!allowSameColumnReorder && columnId === drag.source.columnId) {
|
|
457
|
+
drag = { ...drag, target: { ...drag.source } };
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const max = cardsInColumn(columnId, drag.cardId).length;
|
|
461
|
+
drag = { ...drag, target: { columnId, index: clamp(index, max) } };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function handleCardDrop(
|
|
465
|
+
event: DragEvent,
|
|
466
|
+
columnId: string,
|
|
467
|
+
rawIndex: number,
|
|
468
|
+
): void {
|
|
469
|
+
event.preventDefault();
|
|
470
|
+
if (drag?.mode !== 'pointer') return;
|
|
471
|
+
const targetColumn = findColumn(columnId);
|
|
472
|
+
if (targetColumn?.disabled) {
|
|
473
|
+
announcement = t(M['ui.board.unavailable_column'], {
|
|
474
|
+
column: targetColumn.label,
|
|
475
|
+
});
|
|
476
|
+
cancel(false);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
|
480
|
+
const before = event.clientY < rect.top + rect.height / 2;
|
|
481
|
+
let targetIndex = rawIndex + (before ? 0 : 1);
|
|
482
|
+
if (drag.source.columnId === columnId && targetIndex > drag.source.index) {
|
|
483
|
+
targetIndex -= 1;
|
|
484
|
+
}
|
|
485
|
+
pointerTarget(columnId, targetIndex);
|
|
486
|
+
void drop();
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function handleLaneDrop(event: DragEvent, column: Column): void {
|
|
490
|
+
event.preventDefault();
|
|
491
|
+
if (column.disabled) {
|
|
492
|
+
announcement = t(M['ui.board.unavailable_column'], {
|
|
493
|
+
column: column.label,
|
|
494
|
+
});
|
|
495
|
+
cancel(false);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
pointerTarget(column.id, cardsInColumn(column.id, drag?.cardId).length);
|
|
499
|
+
void drop();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function expandColumn(columnId: string): void {
|
|
503
|
+
if (!collapsed.has(columnId)) return;
|
|
504
|
+
const next = new Set(collapsed);
|
|
505
|
+
next.delete(columnId);
|
|
506
|
+
collapsed = next;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function toggleColumn(columnId: string): void {
|
|
510
|
+
const next = new Set(collapsed);
|
|
511
|
+
if (next.has(columnId)) next.delete(columnId);
|
|
512
|
+
else next.add(columnId);
|
|
513
|
+
collapsed = next;
|
|
514
|
+
}
|
|
515
|
+
</script>
|
|
516
|
+
|
|
517
|
+
<section bind:this={root} class="smrt-board" aria-label={label ?? t(M['ui.board.label'])} aria-roledescription="board">
|
|
518
|
+
<div class="smrt-board__lanes">
|
|
519
|
+
{#each columns as column, columnIndex (column.id)}
|
|
520
|
+
{@const items = cardsInColumn(column.id)}
|
|
521
|
+
{@const isCollapsed = collapsed.has(column.id)}
|
|
522
|
+
<section
|
|
523
|
+
class:smrt-board__lane--disabled={column.disabled}
|
|
524
|
+
class="smrt-board__lane"
|
|
525
|
+
aria-label={t(M['ui.board.column'], { label: column.label, count: items.length })}
|
|
526
|
+
>
|
|
527
|
+
<header class="smrt-board__header">
|
|
528
|
+
{#if collapsible}
|
|
529
|
+
<!-- raw-primitive-allow: native button owns collapsible-lane ARIA state -->
|
|
530
|
+
<button
|
|
531
|
+
type="button"
|
|
532
|
+
class="smrt-board__collapse"
|
|
533
|
+
aria-expanded={!isCollapsed}
|
|
534
|
+
aria-controls={laneListId(columnIndex)}
|
|
535
|
+
aria-label={t(isCollapsed ? M['ui.board.expand_column'] : M['ui.board.collapse_column'], { label: column.label })}
|
|
536
|
+
onclick={() => toggleColumn(column.id)}
|
|
537
|
+
>
|
|
538
|
+
<span>{column.label}</span><span aria-hidden="true">{items.length}</span>
|
|
539
|
+
</button>
|
|
540
|
+
{:else}
|
|
541
|
+
<div class="smrt-board__title"><span>{column.label}</span><span>{items.length}</span></div>
|
|
542
|
+
{/if}
|
|
543
|
+
{#if columnHeader}
|
|
544
|
+
{@render columnHeader({ column, count: items.length, collapsed: isCollapsed })}
|
|
545
|
+
{/if}
|
|
546
|
+
</header>
|
|
547
|
+
{#if !isCollapsed}
|
|
548
|
+
<div
|
|
549
|
+
id={laneListId(columnIndex)}
|
|
550
|
+
class="smrt-board__cards"
|
|
551
|
+
role="list"
|
|
552
|
+
data-smrt-board-column-id={column.id}
|
|
553
|
+
ondragover={(event) => event.preventDefault()}
|
|
554
|
+
ondrop={(event) => handleLaneDrop(event, column)}
|
|
555
|
+
>
|
|
556
|
+
{#each items as item, index (item.id)}
|
|
557
|
+
<div role="listitem">
|
|
558
|
+
<!-- raw-primitive-allow: native button owns keyboard pickup and HTML drag/drop -->
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
class:smrt-board__card--dragging={drag?.cardId === item.id}
|
|
562
|
+
class:smrt-board__card--touch-drag={movable}
|
|
563
|
+
class="smrt-board__card"
|
|
564
|
+
data-smrt-board-card-id={item.id}
|
|
565
|
+
draggable={movable && pointerSession === undefined}
|
|
566
|
+
aria-pressed={drag?.cardId === item.id}
|
|
567
|
+
aria-describedby={drag?.cardId === item.id ? liveId : undefined}
|
|
568
|
+
onclick={() => selectCard(item)}
|
|
569
|
+
onkeydown={(event) => handleCardKeydown(event, item.id)}
|
|
570
|
+
onpointerdown={(event) => handlePointerDown(event, item.id)}
|
|
571
|
+
onpointermove={handlePointerMove}
|
|
572
|
+
onpointerup={(event) => finishPointerSession(event)}
|
|
573
|
+
onpointercancel={(event) => finishPointerSession(event, true)}
|
|
574
|
+
ondragstart={(event) => handleDragStart(event, item.id)}
|
|
575
|
+
ondragend={() => { if (drag?.mode === 'pointer') cancel(); }}
|
|
576
|
+
ondragover={(event) => event.preventDefault()}
|
|
577
|
+
ondrop={(event) => handleCardDrop(event, column.id, index)}
|
|
578
|
+
>
|
|
579
|
+
{@render card({ card: item, column, index, isDragging: drag?.cardId === item.id })}
|
|
580
|
+
</button>
|
|
581
|
+
</div>
|
|
582
|
+
{:else}
|
|
583
|
+
<div role="listitem" class="smrt-board__empty">
|
|
584
|
+
{t(M['ui.board.empty_column'], { column: column.label })}
|
|
585
|
+
</div>
|
|
586
|
+
{/each}
|
|
587
|
+
</div>
|
|
588
|
+
{/if}
|
|
589
|
+
</section>
|
|
590
|
+
{/each}
|
|
591
|
+
</div>
|
|
592
|
+
<div id={liveId} class="smrt-board__live" aria-live="assertive" aria-atomic="true">{announcement}</div>
|
|
593
|
+
</section>
|
|
594
|
+
|
|
595
|
+
<style>
|
|
596
|
+
.smrt-board { min-width: 0; }
|
|
597
|
+
.smrt-board__lanes { display: flex; gap: var(--smrt-spacing-4); overflow-x: auto; overscroll-behavior-x: contain; padding-block-end: var(--smrt-spacing-2); }
|
|
598
|
+
.smrt-board__lane { display: grid; grid-template-rows: auto minmax(0, 1fr); flex: 0 0 min(18rem, 82vw); min-block-size: 12rem; max-block-size: min(42rem, 70vh); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-md); background: var(--smrt-color-surface-container); overflow: hidden; }
|
|
599
|
+
.smrt-board__lane--disabled { opacity: 0.6; }
|
|
600
|
+
.smrt-board__header { display: flex; align-items: center; justify-content: space-between; gap: var(--smrt-spacing-2); min-block-size: 2.75rem; padding: 0 var(--smrt-spacing-3); border-block-end: 1px solid var(--smrt-color-outline-variant); }
|
|
601
|
+
.smrt-board__title, .smrt-board__collapse { display: flex; align-items: center; justify-content: space-between; gap: var(--smrt-spacing-2); inline-size: 100%; color: inherit; font: var(--smrt-typography-label-large-font); }
|
|
602
|
+
.smrt-board__collapse { padding: 0; border: 0; background: transparent; cursor: pointer; text-align: start; }
|
|
603
|
+
.smrt-board__collapse:focus-visible, .smrt-board__card:focus-visible { outline: 2px solid var(--smrt-color-primary); outline-offset: 2px; }
|
|
604
|
+
.smrt-board__cards { display: grid; align-content: start; gap: var(--smrt-spacing-2); min-block-size: 0; overflow-y: auto; overscroll-behavior-y: contain; padding: var(--smrt-spacing-3); }
|
|
605
|
+
.smrt-board__card { display: block; inline-size: 100%; padding: var(--smrt-spacing-3); border: 1px solid var(--smrt-color-outline-variant); border-radius: var(--smrt-radius-sm); background: var(--smrt-color-surface); color: inherit; cursor: grab; text-align: start; }
|
|
606
|
+
.smrt-board__card--touch-drag { touch-action: none; }
|
|
607
|
+
.smrt-board__card:active { cursor: grabbing; }
|
|
608
|
+
.smrt-board__card--dragging { opacity: 0.55; }
|
|
609
|
+
.smrt-board__empty { margin: 0; color: var(--smrt-color-on-surface-variant); font: var(--smrt-typography-body-small-font); }
|
|
610
|
+
.smrt-board__live { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
611
|
+
@media (prefers-reduced-motion: reduce) { .smrt-board__card { scroll-behavior: auto; } }
|
|
612
|
+
</style>
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { BoardCard, BoardColumn, BoardProps } from './types.js';
|
|
2
|
+
declare function $$render<Card extends BoardCard, Column extends BoardColumn>(): {
|
|
3
|
+
props: BoardProps<Card, Column>;
|
|
4
|
+
exports: {};
|
|
5
|
+
bindings: "";
|
|
6
|
+
slots: {};
|
|
7
|
+
events: {};
|
|
8
|
+
};
|
|
9
|
+
declare class __sveltets_Render<Card extends BoardCard, Column extends BoardColumn> {
|
|
10
|
+
props(): ReturnType<typeof $$render<Card, Column>>['props'];
|
|
11
|
+
events(): ReturnType<typeof $$render<Card, Column>>['events'];
|
|
12
|
+
slots(): ReturnType<typeof $$render<Card, Column>>['slots'];
|
|
13
|
+
bindings(): "";
|
|
14
|
+
exports(): {};
|
|
15
|
+
}
|
|
16
|
+
interface $$IsomorphicComponent {
|
|
17
|
+
new <Card extends BoardCard, Column extends BoardColumn>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Card, Column>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Card, Column>['props']>, ReturnType<__sveltets_Render<Card, Column>['events']>, ReturnType<__sveltets_Render<Card, Column>['slots']>> & {
|
|
18
|
+
$$bindings?: ReturnType<__sveltets_Render<Card, Column>['bindings']>;
|
|
19
|
+
} & ReturnType<__sveltets_Render<Card, Column>['exports']>;
|
|
20
|
+
<Card extends BoardCard, Column extends BoardColumn>(internal: unknown, props: ReturnType<__sveltets_Render<Card, Column>['props']> & {}): ReturnType<__sveltets_Render<Card, Column>['exports']>;
|
|
21
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any, any>['bindings']>;
|
|
22
|
+
}
|
|
23
|
+
declare const Board: $$IsomorphicComponent;
|
|
24
|
+
type Board<Card extends BoardCard, Column extends BoardColumn> = InstanceType<typeof Board<Card, Column>>;
|
|
25
|
+
export default Board;
|
|
26
|
+
//# sourceMappingURL=Board.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"Board.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/board/Board.svelte.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EACV,SAAS,EACT,WAAW,EAGX,UAAU,EACX,MAAM,YAAY,CAAC;AACpB,iBAAS,QAAQ,CAAC,IAAI,SAAS,SAAS,EAAE,MAAM,SAAS,WAAW;WA2iBvC,UAAU,CAAC,IAAI,EAAE,MAAM,CAAC;;;;;EAA4E;AACjI,cAAM,iBAAiB,CAAC,IAAI,SAAS,SAAS,EAAC,MAAM,SAAS,WAAW;IACrE,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IAC1D,MAAM,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,CAAC,QAAQ,CAAC;IAC5D,KAAK,IAAI,UAAU,CAAC,OAAO,QAAQ,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;IAC1D,QAAQ;IACR,OAAO;CACV;AAED,UAAU,qBAAqB;IAC3B,KAAK,IAAI,SAAS,SAAS,EAAC,MAAM,SAAS,WAAW,EAAE,OAAO,EAAE,OAAO,QAAQ,EAAE,2BAA2B,CAAC,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,QAAQ,EAAE,eAAe,CAAC,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG;QAAE,UAAU,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,UAAU,CAAC,CAAC,CAAA;KAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IACxe,CAAC,IAAI,SAAS,SAAS,EAAC,MAAM,SAAS,WAAW,EAAE,QAAQ,EAAE,OAAO,EAAE,KAAK,EAAE,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,OAAO,CAAC,CAAC,GAAG,EAAE,GAAG,UAAU,CAAC,iBAAiB,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC;IAC/L,YAAY,CAAC,EAAE,UAAU,CAAC,iBAAiB,CAAC,GAAG,EAAC,GAAG,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;CACrE;AACD,QAAA,MAAM,KAAK,EAAE,qBAAmC,CAAC;AAC/B,KAAK,KAAK,CAAC,IAAI,SAAS,SAAS,EAAC,MAAM,SAAS,WAAW,IAAI,YAAY,CAAC,OAAO,KAAK,CAAC,IAAI,EAAC,MAAM,CAAC,CAAC,CAAC;AAC1G,eAAe,KAAK,CAAC"}
|