@heyramzi/tasks 0.0.0-stage → 0.3.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 +192 -2
- package/package.json +75 -3
- package/src/ambient.d.ts +6 -0
- package/src/board-order.ts +137 -0
- package/src/dates.ts +31 -0
- package/src/define-statuses.ts +32 -0
- package/src/demo.svelte +299 -0
- package/src/icons/board-icon.svelte +9 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/gantt-icon.svelte +11 -0
- package/src/icons/list-icon.svelte +16 -0
- package/src/icons/spinner.svg +1 -0
- package/src/index.ts +41 -0
- package/src/kanban-board.svelte +243 -0
- package/src/kanban-column.svelte +117 -0
- package/src/kanban-drag.ts +38 -0
- package/src/kanban-types.ts +48 -0
- package/src/lib/cn.ts +11 -0
- package/src/presets/sales.ts +16 -0
- package/src/reorder.ts +20 -0
- package/src/status-badge.svelte +44 -0
- package/src/status-dot.svelte +208 -0
- package/src/status-icon.svelte +30 -0
- package/src/status-types.ts +39 -0
- package/src/task-gantt.svelte +672 -0
- package/src/task-list.svelte +358 -0
- package/src/task-types.ts +30 -0
- package/src/task-view-switch.svelte +118 -0
- package/src/tone.ts +51 -0
- package/src/use-kanban-dnd.svelte.ts +168 -0
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { flip } from "svelte/animate";
|
|
4
|
+
import { cubicOut } from "svelte/easing";
|
|
5
|
+
import { liftDragImage } from "./kanban-drag.js";
|
|
6
|
+
import { boardOrderAt } from "./board-order.js";
|
|
7
|
+
import { cn } from "./lib/cn.js";
|
|
8
|
+
import { createKanbanDnd } from "./use-kanban-dnd.svelte.js";
|
|
9
|
+
import chevronIcon from "./icons/chevron-right.svg?raw";
|
|
10
|
+
import type { KanbanColumn as KanbanColumnDef, KanbanMove, KanbanSortKey } from "./kanban-types.js";
|
|
11
|
+
import type { TaskListGroupContext, TaskListRowContext } from "./task-types.js";
|
|
12
|
+
|
|
13
|
+
type Props<T> = {
|
|
14
|
+
/** The same shape as board columns, so one config feeds both views. */
|
|
15
|
+
groups: KanbanColumnDef[];
|
|
16
|
+
items: T[];
|
|
17
|
+
getId: (item: T) => string;
|
|
18
|
+
/** The group id a row belongs to. */
|
|
19
|
+
getGroup: (item: T) => string;
|
|
20
|
+
/** Called on drop with what to persist. Same contract as the board. */
|
|
21
|
+
onMove: (id: string, move: KanbanMove) => void | Promise<void>;
|
|
22
|
+
renderRow: Snippet<[T, TaskListRowContext]>;
|
|
23
|
+
/** Optional sticky header row above every group, e.g. column labels. */
|
|
24
|
+
head?: Snippet;
|
|
25
|
+
groupHeader?: Snippet<[KanbanColumnDef, TaskListGroupContext]>;
|
|
26
|
+
boardOrder?: (item: T) => string | null | undefined;
|
|
27
|
+
sortKeys?: KanbanSortKey<T>[];
|
|
28
|
+
sortKey?: string;
|
|
29
|
+
/** Groups can be folded from their header. Defaults to true. */
|
|
30
|
+
collapsible?: boolean;
|
|
31
|
+
/** Group ids that start folded. */
|
|
32
|
+
collapsed?: string[];
|
|
33
|
+
emptyLabel?: string;
|
|
34
|
+
isDroppable?: (groupId: string) => boolean;
|
|
35
|
+
class?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
let {
|
|
39
|
+
groups,
|
|
40
|
+
items,
|
|
41
|
+
getId,
|
|
42
|
+
getGroup,
|
|
43
|
+
onMove,
|
|
44
|
+
renderRow,
|
|
45
|
+
head,
|
|
46
|
+
groupHeader,
|
|
47
|
+
boardOrder,
|
|
48
|
+
sortKeys = [],
|
|
49
|
+
sortKey = "manual",
|
|
50
|
+
collapsible = true,
|
|
51
|
+
collapsed = [],
|
|
52
|
+
emptyLabel = "Nothing here yet",
|
|
53
|
+
isDroppable,
|
|
54
|
+
class: className,
|
|
55
|
+
}: Props<T> = $props();
|
|
56
|
+
|
|
57
|
+
// Rows past this index enter together, so a long list never waits on its stagger.
|
|
58
|
+
const STAGGER_CAP = 8;
|
|
59
|
+
|
|
60
|
+
// svelte-ignore state_referenced_locally
|
|
61
|
+
let folded = $state(new Set(collapsed));
|
|
62
|
+
|
|
63
|
+
function compare(a: T, b: T): number {
|
|
64
|
+
if (sortKey === "manual") {
|
|
65
|
+
if (!boardOrder) return 0;
|
|
66
|
+
const ao = boardOrder(a);
|
|
67
|
+
const bo = boardOrder(b);
|
|
68
|
+
if (ao == null && bo == null) return 0;
|
|
69
|
+
if (ao == null) return 1;
|
|
70
|
+
if (bo == null) return -1;
|
|
71
|
+
return ao < bo ? -1 : ao > bo ? 1 : 0;
|
|
72
|
+
}
|
|
73
|
+
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
74
|
+
return key ? key.compare(a, b) : 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sortedByGroup(groupId: string): T[] {
|
|
78
|
+
return items.filter((item) => getGroup(item) === groupId).sort(compare);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function canDrop(groupId: string): boolean {
|
|
82
|
+
return isDroppable?.(groupId) ?? (groups.find((g) => g.id === groupId)?.droppable ?? true);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function idAt(list: T[], index: number): string | null {
|
|
86
|
+
const item = list[index];
|
|
87
|
+
return item === undefined ? null : getId(item);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const dnd = createKanbanDnd<T>({
|
|
91
|
+
items: () => items,
|
|
92
|
+
getId: (item) => getId(item),
|
|
93
|
+
getColumn: (item) => getGroup(item),
|
|
94
|
+
getColumnItems: sortedByGroup,
|
|
95
|
+
getSortKey: () => sortKey,
|
|
96
|
+
boardOrder: (item) => boardOrder?.(item),
|
|
97
|
+
onMove: (id, move) => onMove(id, move),
|
|
98
|
+
isDroppable: canDrop,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function toggle(groupId: string): void {
|
|
102
|
+
const next = new Set(folded);
|
|
103
|
+
if (next.has(groupId)) next.delete(groupId);
|
|
104
|
+
else next.add(groupId);
|
|
105
|
+
folded = next;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Alt + ArrowUp/Down sends the focused row to the end of the group above or below. */
|
|
109
|
+
function onRowKeyDown(e: KeyboardEvent, id: string): void {
|
|
110
|
+
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
|
111
|
+
const item = items.find((entry) => getId(entry) === id);
|
|
112
|
+
if (!item) return;
|
|
113
|
+
const current = getGroup(item);
|
|
114
|
+
const at = groups.findIndex((g) => g.id === current);
|
|
115
|
+
if (at === -1) return;
|
|
116
|
+
const dir = e.key === "ArrowDown" ? 1 : -1;
|
|
117
|
+
for (let i = at + dir; i >= 0 && i < groups.length; i += dir) {
|
|
118
|
+
const target = groups[i];
|
|
119
|
+
if (!canDrop(target.id)) continue;
|
|
120
|
+
e.preventDefault();
|
|
121
|
+
if (sortKey === "manual" && boardOrder) {
|
|
122
|
+
const rows = sortedByGroup(target.id).filter((entry) => getId(entry) !== id);
|
|
123
|
+
void onMove(id, {
|
|
124
|
+
columnId: target.id,
|
|
125
|
+
boardOrder: boardOrderAt(rows, rows.length, (entry) => boardOrder(entry)),
|
|
126
|
+
});
|
|
127
|
+
} else {
|
|
128
|
+
void onMove(id, { columnId: target.id });
|
|
129
|
+
}
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let view = $derived(groups.map((group) => ({ group, rows: sortedByGroup(group.id) })));
|
|
135
|
+
</script>
|
|
136
|
+
|
|
137
|
+
<div
|
|
138
|
+
class={cn(
|
|
139
|
+
"task-list flex min-h-0 flex-1 flex-col overflow-y-auto rounded-xl border border-border bg-card text-card-foreground",
|
|
140
|
+
className,
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
143
|
+
{#if head}
|
|
144
|
+
<div
|
|
145
|
+
class="sticky top-0 z-20 flex h-9 shrink-0 items-center border-b border-border bg-card/90 text-xs font-medium text-muted-foreground backdrop-blur-sm"
|
|
146
|
+
>
|
|
147
|
+
{@render head()}
|
|
148
|
+
</div>
|
|
149
|
+
{/if}
|
|
150
|
+
|
|
151
|
+
{#each view as { group, rows } (group.id)}
|
|
152
|
+
{@const active = dnd.dragOver?.columnId === group.id}
|
|
153
|
+
{@const open = !folded.has(group.id)}
|
|
154
|
+
{@const hue = group.hue ?? "var(--color-primary)"}
|
|
155
|
+
<section
|
|
156
|
+
aria-label={group.ariaLabel ?? group.label}
|
|
157
|
+
class="tl-group border-b border-border last:border-b-0"
|
|
158
|
+
class:tl-group-active={active}
|
|
159
|
+
style:--tl-hue={hue}
|
|
160
|
+
ondragover={(e) => dnd.onColumnDragOver(e, group.id)}
|
|
161
|
+
ondragleave={dnd.onDragLeave}
|
|
162
|
+
ondrop={(e) => dnd.onDrop(e, group.id)}
|
|
163
|
+
>
|
|
164
|
+
<div class={cn("sticky z-10 flex h-10 items-center gap-2 bg-card/90 px-3 backdrop-blur-sm", head ? "top-9" : "top-0")}>
|
|
165
|
+
{#if collapsible}
|
|
166
|
+
<button
|
|
167
|
+
type="button"
|
|
168
|
+
aria-expanded={open}
|
|
169
|
+
aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
|
|
170
|
+
onclick={() => toggle(group.id)}
|
|
171
|
+
class="-ml-1 grid size-6 place-items-center rounded-md text-muted-foreground outline-none transition-colors duration-[var(--duration-quick,150ms)] hover:bg-foreground/5 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
172
|
+
>
|
|
173
|
+
<span class="tl-chevron size-3.5 [&>svg]:size-full" class:tl-chevron-open={open} aria-hidden="true">
|
|
174
|
+
{@html chevronIcon}
|
|
175
|
+
</span>
|
|
176
|
+
</button>
|
|
177
|
+
{/if}
|
|
178
|
+
{#if groupHeader}
|
|
179
|
+
{@render groupHeader(group, { count: rows.length, active, open })}
|
|
180
|
+
{:else}
|
|
181
|
+
<span class="size-2 shrink-0 rounded-full" style:background={hue}></span>
|
|
182
|
+
<span class="truncate text-sm font-medium text-foreground">{group.label}</span>
|
|
183
|
+
<span
|
|
184
|
+
class="grid h-5 min-w-5 place-items-center rounded-sm bg-foreground/5 px-1 text-xs font-medium text-muted-foreground tabular-nums"
|
|
185
|
+
>
|
|
186
|
+
{rows.length}
|
|
187
|
+
</span>
|
|
188
|
+
{/if}
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
<div class="tl-fold" class:tl-fold-open={open}>
|
|
192
|
+
<div class="tl-fold-inner">
|
|
193
|
+
<div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
|
|
194
|
+
{#each rows as item, index (getId(item))}
|
|
195
|
+
{@const id = getId(item)}
|
|
196
|
+
{@const dropBefore =
|
|
197
|
+
dnd.showIndicatorBefore(group.id, id) &&
|
|
198
|
+
!dnd.isDragSource(id) &&
|
|
199
|
+
idAt(rows, index - 1) !== dnd.draggedId}
|
|
200
|
+
<div
|
|
201
|
+
role="listitem"
|
|
202
|
+
tabindex="0"
|
|
203
|
+
aria-label="Row. Press Alt plus Up or Down arrow to move between groups."
|
|
204
|
+
draggable="true"
|
|
205
|
+
animate:flip={{ duration: 250, easing: cubicOut }}
|
|
206
|
+
ondragstart={(e) => {
|
|
207
|
+
liftDragImage(e, -1);
|
|
208
|
+
dnd.onDragStart(e, id);
|
|
209
|
+
}}
|
|
210
|
+
ondragend={() => dnd.resetDrag()}
|
|
211
|
+
ondragover={(e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))}
|
|
212
|
+
onkeydown={(e) => onRowKeyDown(e, id)}
|
|
213
|
+
style:--tl-i={Math.min(index, STAGGER_CAP)}
|
|
214
|
+
class={cn(
|
|
215
|
+
"tl-row relative mx-1.5 cursor-grab rounded-lg outline-none active:cursor-grabbing focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
216
|
+
dropBefore && "tl-drop-before",
|
|
217
|
+
dnd.isDragSource(id) && "opacity-40",
|
|
218
|
+
)}
|
|
219
|
+
>
|
|
220
|
+
{@render renderRow(item, { groupId: group.id, index })}
|
|
221
|
+
</div>
|
|
222
|
+
{/each}
|
|
223
|
+
|
|
224
|
+
{#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
|
|
225
|
+
<div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
|
|
226
|
+
{/if}
|
|
227
|
+
|
|
228
|
+
{#if rows.length === 0 && !active}
|
|
229
|
+
<p class="mx-1.5 rounded-lg border border-dashed border-border px-3 py-3 text-xs text-muted-foreground">
|
|
230
|
+
{emptyLabel}
|
|
231
|
+
</p>
|
|
232
|
+
{/if}
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
</section>
|
|
237
|
+
{/each}
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
<style>
|
|
241
|
+
.tl-group {
|
|
242
|
+
transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.tl-group-active {
|
|
246
|
+
background: color-mix(in oklab, var(--tl-hue) 6%, transparent);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.tl-chevron {
|
|
250
|
+
transition: rotate var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.tl-chevron-open {
|
|
254
|
+
rotate: 90deg;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* A 0fr to 1fr track folds to any height. Padding lives inside, or 0fr never fully closes. */
|
|
258
|
+
.tl-fold {
|
|
259
|
+
display: grid;
|
|
260
|
+
grid-template-rows: 0fr;
|
|
261
|
+
opacity: 0;
|
|
262
|
+
transition:
|
|
263
|
+
grid-template-rows var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
264
|
+
opacity var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.tl-fold-open {
|
|
268
|
+
grid-template-rows: 1fr;
|
|
269
|
+
opacity: 1;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.tl-fold-inner {
|
|
273
|
+
min-height: 0;
|
|
274
|
+
overflow: hidden;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.tl-row {
|
|
278
|
+
animation: tl-row-in var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
279
|
+
animation-delay: calc(var(--tl-i, 0) * var(--duration-stagger, 40ms));
|
|
280
|
+
transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.tl-row:hover {
|
|
284
|
+
background: color-mix(in oklab, var(--color-foreground, currentColor) 4%, transparent);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* The drop line: a hairline in the group's hue with a dot where it starts. */
|
|
288
|
+
.tl-drop-before::before,
|
|
289
|
+
.tl-drop-end::before {
|
|
290
|
+
content: "";
|
|
291
|
+
position: absolute;
|
|
292
|
+
left: 0.5rem;
|
|
293
|
+
right: 0.5rem;
|
|
294
|
+
height: 2px;
|
|
295
|
+
border-radius: 1px;
|
|
296
|
+
background: var(--tl-hue);
|
|
297
|
+
animation: tl-line-in var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) both;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* A ring where the line starts, so the drop point reads at a glance. */
|
|
301
|
+
.tl-drop-before::after,
|
|
302
|
+
.tl-drop-end::after {
|
|
303
|
+
content: "";
|
|
304
|
+
position: absolute;
|
|
305
|
+
left: 0.25rem;
|
|
306
|
+
width: 8px;
|
|
307
|
+
height: 8px;
|
|
308
|
+
border-radius: 999px;
|
|
309
|
+
border: 2px solid var(--tl-hue);
|
|
310
|
+
background: var(--color-card, white);
|
|
311
|
+
animation: tl-line-in var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) both;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
.tl-drop-before::before {
|
|
315
|
+
top: -1px;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
.tl-drop-before::after {
|
|
319
|
+
top: -4px;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
.tl-drop-end::before {
|
|
323
|
+
top: 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.tl-drop-end::after {
|
|
327
|
+
top: -3px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
@keyframes tl-row-in {
|
|
331
|
+
from {
|
|
332
|
+
opacity: 0;
|
|
333
|
+
transform: translateY(var(--distance-micro, 4px));
|
|
334
|
+
filter: blur(var(--blur-small, 2px));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
@keyframes tl-line-in {
|
|
339
|
+
from {
|
|
340
|
+
opacity: 0;
|
|
341
|
+
transform: scaleX(0.6);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
@media (prefers-reduced-motion: reduce) {
|
|
346
|
+
.tl-row,
|
|
347
|
+
.tl-drop-before::before,
|
|
348
|
+
.tl-drop-end::before,
|
|
349
|
+
.tl-drop-before::after,
|
|
350
|
+
.tl-drop-end::after {
|
|
351
|
+
animation: none;
|
|
352
|
+
}
|
|
353
|
+
.tl-fold,
|
|
354
|
+
.tl-chevron {
|
|
355
|
+
transition: none;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
|
|
3
|
+
/** Second argument handed to the list row snippet. */
|
|
4
|
+
export type TaskListRowContext = {
|
|
5
|
+
groupId: string;
|
|
6
|
+
index: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Second argument handed to the list group header snippet. */
|
|
10
|
+
export type TaskListGroupContext = {
|
|
11
|
+
count: number;
|
|
12
|
+
active: boolean;
|
|
13
|
+
open: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** What the gantt asks the caller to persist after a bar is moved or resized. */
|
|
17
|
+
export type GanttChange = {
|
|
18
|
+
/** ISO date, inclusive. */
|
|
19
|
+
start: string;
|
|
20
|
+
/** ISO date, inclusive. A one-day task has `end === start`. */
|
|
21
|
+
end: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** One option in the view switch. */
|
|
25
|
+
export type TaskView = {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
/** Any Svelte component that takes `size` and `class`, e.g. `BoardIcon`. */
|
|
29
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
30
|
+
};
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "./lib/cn.js";
|
|
3
|
+
import type { TaskView } from "./task-types.js";
|
|
4
|
+
|
|
5
|
+
type Props = {
|
|
6
|
+
views: TaskView[];
|
|
7
|
+
/** The active view id. Bindable. */
|
|
8
|
+
value: string;
|
|
9
|
+
onchange?: (id: string) => void;
|
|
10
|
+
label?: string;
|
|
11
|
+
class?: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
let {
|
|
15
|
+
views,
|
|
16
|
+
value = $bindable(),
|
|
17
|
+
onchange,
|
|
18
|
+
label = "View",
|
|
19
|
+
class: className,
|
|
20
|
+
}: Props = $props();
|
|
21
|
+
|
|
22
|
+
let track = $state<HTMLDivElement>();
|
|
23
|
+
let pill = $state<{ x: number; width: number } | null>(null);
|
|
24
|
+
// The pill's first position is written without a transition, or it slides in from 0.
|
|
25
|
+
let placed = $state(false);
|
|
26
|
+
|
|
27
|
+
function measure(): void {
|
|
28
|
+
const button = track?.querySelector<HTMLElement>(`[data-view="${CSS.escape(value)}"]`);
|
|
29
|
+
if (!button) return;
|
|
30
|
+
pill = { x: button.offsetLeft, width: button.offsetWidth };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
$effect(() => {
|
|
34
|
+
void value;
|
|
35
|
+
void views.length;
|
|
36
|
+
measure();
|
|
37
|
+
if (!placed) requestAnimationFrame(() => (placed = true));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
$effect(() => {
|
|
41
|
+
if (!track) return;
|
|
42
|
+
const observer = new ResizeObserver(() => measure());
|
|
43
|
+
observer.observe(track);
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function select(id: string): void {
|
|
48
|
+
if (id === value) return;
|
|
49
|
+
value = id;
|
|
50
|
+
onchange?.(id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onKeyDown(e: KeyboardEvent): void {
|
|
54
|
+
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
const at = views.findIndex((view) => view.id === value);
|
|
57
|
+
const next = views[(at + (e.key === "ArrowRight" ? 1 : -1) + views.length) % views.length];
|
|
58
|
+
select(next.id);
|
|
59
|
+
track?.querySelector<HTMLElement>(`[data-view="${CSS.escape(next.id)}"]`)?.focus();
|
|
60
|
+
}
|
|
61
|
+
</script>
|
|
62
|
+
|
|
63
|
+
<div
|
|
64
|
+
bind:this={track}
|
|
65
|
+
role="tablist"
|
|
66
|
+
aria-label={label}
|
|
67
|
+
tabindex="-1"
|
|
68
|
+
onkeydown={onKeyDown}
|
|
69
|
+
class={cn(
|
|
70
|
+
"task-view-switch relative inline-flex shrink-0 items-center gap-0.5 rounded-lg bg-muted/70 p-0.5 dark:bg-muted/40",
|
|
71
|
+
className,
|
|
72
|
+
)}
|
|
73
|
+
>
|
|
74
|
+
{#if pill}
|
|
75
|
+
<span
|
|
76
|
+
aria-hidden="true"
|
|
77
|
+
class="tvs-pill absolute inset-y-0.5 left-0 rounded-md bg-background shadow-sm ring-1 ring-foreground/5 dark:bg-foreground/10"
|
|
78
|
+
class:tvs-pill-placed={placed}
|
|
79
|
+
style:width="{pill.width}px"
|
|
80
|
+
style:transform="translateX({pill.x}px)"
|
|
81
|
+
></span>
|
|
82
|
+
{/if}
|
|
83
|
+
{#each views as view (view.id)}
|
|
84
|
+
{@const active = view.id === value}
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
role="tab"
|
|
88
|
+
data-view={view.id}
|
|
89
|
+
aria-selected={active}
|
|
90
|
+
tabindex={active ? 0 : -1}
|
|
91
|
+
onclick={() => select(view.id)}
|
|
92
|
+
class={cn(
|
|
93
|
+
"relative z-10 inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium outline-none transition-colors duration-[var(--duration-quick,150ms)] focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
94
|
+
active ? "text-foreground" : "text-muted-foreground hover:text-foreground",
|
|
95
|
+
)}
|
|
96
|
+
>
|
|
97
|
+
{#if view.icon}
|
|
98
|
+
{@const Icon = view.icon}
|
|
99
|
+
<Icon size={14} class="shrink-0" />
|
|
100
|
+
{/if}
|
|
101
|
+
{view.label}
|
|
102
|
+
</button>
|
|
103
|
+
{/each}
|
|
104
|
+
</div>
|
|
105
|
+
|
|
106
|
+
<style>
|
|
107
|
+
.tvs-pill-placed {
|
|
108
|
+
transition:
|
|
109
|
+
transform var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
110
|
+
width var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@media (prefers-reduced-motion: reduce) {
|
|
114
|
+
.tvs-pill-placed {
|
|
115
|
+
transition: none;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
</style>
|
package/src/tone.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Tone } from "./status-types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The severity scale, as Tailwind classes for a chip and as CSS colors for the
|
|
5
|
+
* dot. Tokens are plain CSS variables, so a host app can retheme them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const TONE_CLASS: Record<Tone, string> = {
|
|
9
|
+
neutral: "border-border bg-muted/40 text-muted-foreground",
|
|
10
|
+
accent: "border-primary/30 bg-primary/10 text-primary",
|
|
11
|
+
info: "border-info/30 bg-info/10 text-info-800 dark:text-info-400",
|
|
12
|
+
success: "border-success/30 bg-success/10 text-success-800 dark:text-success-400",
|
|
13
|
+
warning: "border-warning/30 bg-warning/10 text-warning-800 dark:text-warning-400",
|
|
14
|
+
destructive: "border-destructive/30 bg-destructive/10 text-destructive",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** The same scale as text, for a verdict inside a line of muted facts. */
|
|
18
|
+
export const TONE_TEXT: Record<Tone, string> = {
|
|
19
|
+
neutral: "text-muted-foreground",
|
|
20
|
+
accent: "text-primary",
|
|
21
|
+
info: "text-info-800 dark:text-info-400",
|
|
22
|
+
success: "text-success-800 dark:text-success-400",
|
|
23
|
+
warning: "text-warning-800 dark:text-warning-400",
|
|
24
|
+
destructive: "text-destructive",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The same scale as icon color. */
|
|
28
|
+
export const TONE_ICON: Record<Tone, string> = {
|
|
29
|
+
neutral: "text-muted-foreground",
|
|
30
|
+
accent: "text-primary",
|
|
31
|
+
info: "text-info",
|
|
32
|
+
success: "text-success",
|
|
33
|
+
warning: "text-warning",
|
|
34
|
+
destructive: "text-destructive",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The scale as CSS colors, for the SVG dot. */
|
|
38
|
+
export const TONE_COLOR: Record<Tone, string> = {
|
|
39
|
+
neutral: "var(--color-muted-foreground)",
|
|
40
|
+
accent: "var(--color-primary)",
|
|
41
|
+
info: "var(--color-info)",
|
|
42
|
+
success: "var(--color-success)",
|
|
43
|
+
warning: "var(--color-warning)",
|
|
44
|
+
destructive: "var(--color-destructive)",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The inset well for a control block inside a panel. A visible step
|
|
49
|
+
* at the same depth, for filter rows and grouped controls.
|
|
50
|
+
*/
|
|
51
|
+
export const insetWellClass = "rounded-lg border border-border bg-muted/40 p-2";
|