@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,117 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import type { HTMLAttributes } from "svelte/elements";
|
|
4
|
+
import { cn } from "./lib/cn.js";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
/** Any CSS color. Only shows while a card is held over the column. */
|
|
8
|
+
hue?: string;
|
|
9
|
+
active?: boolean;
|
|
10
|
+
collapsing?: boolean;
|
|
11
|
+
expanding?: boolean;
|
|
12
|
+
ariaLabel?: string;
|
|
13
|
+
header: Snippet;
|
|
14
|
+
children: Snippet;
|
|
15
|
+
ondragover?: (e: DragEvent) => void;
|
|
16
|
+
ondragleave?: (e: DragEvent) => void;
|
|
17
|
+
ondrop?: (e: DragEvent) => void;
|
|
18
|
+
class?: string;
|
|
19
|
+
bodyClass?: string;
|
|
20
|
+
} & HTMLAttributes<HTMLDivElement>;
|
|
21
|
+
|
|
22
|
+
let {
|
|
23
|
+
hue,
|
|
24
|
+
active = false,
|
|
25
|
+
collapsing = false,
|
|
26
|
+
expanding = false,
|
|
27
|
+
ariaLabel,
|
|
28
|
+
header,
|
|
29
|
+
children,
|
|
30
|
+
ondragover,
|
|
31
|
+
ondragleave,
|
|
32
|
+
ondrop,
|
|
33
|
+
class: className,
|
|
34
|
+
bodyClass,
|
|
35
|
+
...restProps
|
|
36
|
+
}: Props = $props();
|
|
37
|
+
|
|
38
|
+
// A column rests neutral. Its hue shows only while a card is held over it.
|
|
39
|
+
let style = $derived(
|
|
40
|
+
hue && active
|
|
41
|
+
? `background: color-mix(in oklch, ${hue} 8%, var(--color-muted, transparent)); outline: 1px solid color-mix(in oklch, ${hue} 30%, transparent);`
|
|
42
|
+
: undefined,
|
|
43
|
+
);
|
|
44
|
+
</script>
|
|
45
|
+
|
|
46
|
+
<div
|
|
47
|
+
{...restProps}
|
|
48
|
+
class={cn(
|
|
49
|
+
"kanban-column flex flex-col gap-2",
|
|
50
|
+
collapsing && "kanban-column-collapsing",
|
|
51
|
+
expanding && "kanban-column-expanding",
|
|
52
|
+
!collapsing && !expanding && "w-[280px] shrink-0 min-h-0 max-sm:w-[82vw] max-sm:snap-start",
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
>
|
|
56
|
+
<div
|
|
57
|
+
role="region"
|
|
58
|
+
aria-label={ariaLabel}
|
|
59
|
+
class={cn(
|
|
60
|
+
"flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto overflow-x-hidden rounded-xl bg-muted/50 p-1.5 outline-offset-[-1px] transition-[background-color,outline-color] duration-[var(--duration-quick,120ms)] ease-[var(--ease-smooth-out,ease-out)] dark:bg-muted/30",
|
|
61
|
+
bodyClass,
|
|
62
|
+
)}
|
|
63
|
+
{style}
|
|
64
|
+
{ondragover}
|
|
65
|
+
{ondragleave}
|
|
66
|
+
{ondrop}
|
|
67
|
+
>
|
|
68
|
+
<div class="flex h-8 min-w-0 shrink-0 items-center gap-2 px-1.5">
|
|
69
|
+
{@render header()}
|
|
70
|
+
</div>
|
|
71
|
+
{@render children()}
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<style>
|
|
76
|
+
.kanban-column {
|
|
77
|
+
transition: opacity var(--duration-quick, 120ms) var(--ease-smooth-out, ease-out);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.kanban-column-collapsing,
|
|
81
|
+
.kanban-column-expanding {
|
|
82
|
+
will-change: opacity;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
.kanban-column-collapsing {
|
|
86
|
+
flex: 0 0 0px;
|
|
87
|
+
width: 0;
|
|
88
|
+
opacity: 0;
|
|
89
|
+
overflow: hidden;
|
|
90
|
+
pointer-events: none;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.kanban-column-expanding {
|
|
94
|
+
flex: 0 0 280px;
|
|
95
|
+
width: 280px;
|
|
96
|
+
animation: column-expand var(--duration-slow, 320ms) var(--ease-smooth-out, ease-out) both;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
@keyframes column-expand {
|
|
100
|
+
from {
|
|
101
|
+
opacity: 0;
|
|
102
|
+
flex: 0 0 0px;
|
|
103
|
+
width: 0;
|
|
104
|
+
}
|
|
105
|
+
to {
|
|
106
|
+
opacity: 1;
|
|
107
|
+
flex: 0 0 280px;
|
|
108
|
+
width: 280px;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
@media (prefers-reduced-motion: reduce) {
|
|
113
|
+
.kanban-column-expanding {
|
|
114
|
+
animation: none;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
</style>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const FRAME_PAD = 16;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Native drag shows a flat, faded snapshot. This hands the browser a tilted
|
|
5
|
+
* copy with a lifted shadow instead. Call it from `ondragstart`.
|
|
6
|
+
*/
|
|
7
|
+
export function liftDragImage(e: DragEvent, tilt = -3): void {
|
|
8
|
+
const source = e.currentTarget as HTMLElement | null;
|
|
9
|
+
if (!source || !e.dataTransfer) return;
|
|
10
|
+
const rect = source.getBoundingClientRect();
|
|
11
|
+
|
|
12
|
+
// The padded frame leaves room for the tilt and shadow, or the snapshot crops them.
|
|
13
|
+
const frame = document.createElement("div");
|
|
14
|
+
Object.assign(frame.style, {
|
|
15
|
+
position: "fixed",
|
|
16
|
+
top: "-10000px",
|
|
17
|
+
left: "-10000px",
|
|
18
|
+
padding: `${FRAME_PAD}px`,
|
|
19
|
+
pointerEvents: "none",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const ghost = source.cloneNode(true) as HTMLElement;
|
|
23
|
+
Object.assign(ghost.style, {
|
|
24
|
+
width: `${rect.width}px`,
|
|
25
|
+
opacity: "1",
|
|
26
|
+
rotate: `${tilt}deg`,
|
|
27
|
+
filter:
|
|
28
|
+
"drop-shadow(0 2px 4px rgb(35 35 35 / 0.08)) drop-shadow(0 10px 18px rgb(35 35 35 / 0.14))",
|
|
29
|
+
});
|
|
30
|
+
frame.appendChild(ghost);
|
|
31
|
+
document.body.appendChild(frame);
|
|
32
|
+
e.dataTransfer.setDragImage(
|
|
33
|
+
frame,
|
|
34
|
+
e.clientX - rect.left + FRAME_PAD,
|
|
35
|
+
e.clientY - rect.top + FRAME_PAD,
|
|
36
|
+
);
|
|
37
|
+
setTimeout(() => frame.remove());
|
|
38
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
|
|
3
|
+
/** A column the board renders. `id` is the value `getColumn` returns for a card. */
|
|
4
|
+
export type KanbanColumn = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: string;
|
|
7
|
+
/** Any CSS color. Shown as a tint on the column while a card is held over it. */
|
|
8
|
+
hue?: string;
|
|
9
|
+
/** Screen-reader label for the column region. Defaults to `label`. */
|
|
10
|
+
ariaLabel?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Set false to make the column a display-only lane: cards there cannot be
|
|
13
|
+
* dragged over or dropped onto (e.g. an Overdue lane). Defaults to true.
|
|
14
|
+
*/
|
|
15
|
+
droppable?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** What the board asks the caller to persist after a drop. */
|
|
19
|
+
export type KanbanMove = {
|
|
20
|
+
columnId: string;
|
|
21
|
+
/**
|
|
22
|
+
* Only set when the active sort is manual: a fractional key that places the
|
|
23
|
+
* card in its new slot. Ignore it when you persist order some other way.
|
|
24
|
+
*/
|
|
25
|
+
boardOrder?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** A named sort the board can apply instead of manual (`boardOrder`) ordering. */
|
|
29
|
+
export type KanbanSortKey<T> = {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
compare: (a: T, b: T) => number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Second argument handed to the card snippet. */
|
|
36
|
+
export type KanbanCardContext = {
|
|
37
|
+
columnId: string;
|
|
38
|
+
index: number;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Second argument handed to the column header snippet. */
|
|
42
|
+
export type KanbanColumnContext = {
|
|
43
|
+
count: number;
|
|
44
|
+
active: boolean;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The default header renderer when no `columnHeader` snippet is given. */
|
|
48
|
+
export type KanbanCardSnippet<T> = Snippet<[T, KanbanCardContext]>;
|
package/src/lib/cn.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ClassValue = string | false | null | undefined;
|
|
2
|
+
|
|
3
|
+
/** Join class values, dropping empties. Deliberately dependency-free. */
|
|
4
|
+
export function cn(...values: ClassValue[]): string {
|
|
5
|
+
let out = "";
|
|
6
|
+
for (const value of values) {
|
|
7
|
+
if (!value) continue;
|
|
8
|
+
out = out ? out + " " + value : value;
|
|
9
|
+
}
|
|
10
|
+
return out;
|
|
11
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineStatuses } from "../define-statuses.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* An example status map for a sales pipeline. Names are neutral on purpose:
|
|
5
|
+
* swap the keys and labels for your own domain.
|
|
6
|
+
*/
|
|
7
|
+
export const salesStatuses = defineStatuses({
|
|
8
|
+
new: { label: "New", tone: "accent", variant: "open" },
|
|
9
|
+
contacted: { label: "Contacted", tone: "info", variant: "open" },
|
|
10
|
+
proposal: { label: "Proposal", tone: "accent", variant: "progress", progress: 0.25 },
|
|
11
|
+
negotiation: { label: "Negotiation", tone: "warning", variant: "progress", progress: 0.75 },
|
|
12
|
+
won: { label: "Won", tone: "success", variant: "completed" },
|
|
13
|
+
lost: { label: "Lost", tone: "destructive", variant: "lost" },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export type SalesStatus = (typeof salesStatuses.ids)[number];
|
package/src/reorder.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
type Identifiable = { id: string };
|
|
2
|
+
|
|
3
|
+
/** Move `activeId` to `overId`'s index. Returns a copy; unknown ids pass through. */
|
|
4
|
+
export function reorderByIds<Item extends Identifiable>(
|
|
5
|
+
items: Item[],
|
|
6
|
+
activeId: Item["id"],
|
|
7
|
+
overId: Item["id"],
|
|
8
|
+
): Item[] {
|
|
9
|
+
const activeIndex = items.findIndex((item) => item.id === activeId);
|
|
10
|
+
const overIndex = items.findIndex((item) => item.id === overId);
|
|
11
|
+
|
|
12
|
+
if (activeIndex === -1 || overIndex === -1 || activeIndex === overIndex) {
|
|
13
|
+
return items.slice();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const next = items.slice();
|
|
17
|
+
const [moved] = next.splice(activeIndex, 1);
|
|
18
|
+
next.splice(overIndex, 0, moved);
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "./lib/cn.js";
|
|
3
|
+
import { TONE_CLASS } from "./tone.js";
|
|
4
|
+
import type { Tone, Statuses } from "./status-types.js";
|
|
5
|
+
import spinnerIcon from "./icons/spinner.svg?raw";
|
|
6
|
+
|
|
7
|
+
type Props = {
|
|
8
|
+
/** A registry from `defineStatuses`. Supplies the label and tone. */
|
|
9
|
+
statuses?: Statuses;
|
|
10
|
+
status: string;
|
|
11
|
+
/** Override the registry label. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** Override the registry tone. */
|
|
14
|
+
tone?: Tone;
|
|
15
|
+
/** Shows a spinner: a background job is still working on the record. */
|
|
16
|
+
busy?: boolean;
|
|
17
|
+
class?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
statuses,
|
|
22
|
+
status,
|
|
23
|
+
label,
|
|
24
|
+
tone,
|
|
25
|
+
busy = false,
|
|
26
|
+
class: className,
|
|
27
|
+
}: Props = $props();
|
|
28
|
+
|
|
29
|
+
let resolvedTone = $derived(tone ?? (statuses ? statuses.tone(status) : "neutral"));
|
|
30
|
+
let resolvedLabel = $derived(label ?? (statuses ? statuses.label(status) : status));
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<span
|
|
34
|
+
class={cn(
|
|
35
|
+
"inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize",
|
|
36
|
+
TONE_CLASS[resolvedTone],
|
|
37
|
+
className,
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
{#if busy}
|
|
41
|
+
<span class="size-3 animate-spin [&>svg]:size-full" aria-hidden="true">{@html spinnerIcon}</span>
|
|
42
|
+
{/if}
|
|
43
|
+
{resolvedLabel}
|
|
44
|
+
</span>
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { cn } from "./lib/cn.js";
|
|
3
|
+
import { TONE_COLOR } from "./tone.js";
|
|
4
|
+
import type { StatusDotVariant, Statuses } from "./status-types.js";
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
/** A registry from `defineStatuses`. Resolves color, variant and progress. */
|
|
8
|
+
statuses?: Statuses;
|
|
9
|
+
status?: string;
|
|
10
|
+
/** Explicit color, overrides the registry and tone. */
|
|
11
|
+
color?: string;
|
|
12
|
+
variant?: StatusDotVariant;
|
|
13
|
+
progress?: number;
|
|
14
|
+
size?: number;
|
|
15
|
+
animate?: boolean;
|
|
16
|
+
class?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
statuses,
|
|
21
|
+
status,
|
|
22
|
+
color,
|
|
23
|
+
variant,
|
|
24
|
+
progress,
|
|
25
|
+
size = 14,
|
|
26
|
+
animate = true,
|
|
27
|
+
class: className,
|
|
28
|
+
}: Props = $props();
|
|
29
|
+
|
|
30
|
+
const INNER_R = 2;
|
|
31
|
+
const INNER_CIRC = 2 * Math.PI * INNER_R;
|
|
32
|
+
|
|
33
|
+
function clamp01(n: number): number {
|
|
34
|
+
return Math.max(0, Math.min(1, n));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let resolvedColor = $derived(
|
|
38
|
+
color ??
|
|
39
|
+
(statuses && status ? statuses.color(status) : undefined) ??
|
|
40
|
+
(statuses && status ? TONE_COLOR[statuses.tone(status)] : undefined) ??
|
|
41
|
+
TONE_COLOR.neutral,
|
|
42
|
+
);
|
|
43
|
+
let resolvedVariant = $derived(
|
|
44
|
+
variant ?? (statuses && status ? statuses.variant(status) : "open"),
|
|
45
|
+
);
|
|
46
|
+
let resolvedProgress = $derived(
|
|
47
|
+
progress ?? (statuses && status ? statuses.progress(status) : undefined),
|
|
48
|
+
);
|
|
49
|
+
let fraction = $derived(
|
|
50
|
+
resolvedVariant === "progress" && typeof resolvedProgress === "number"
|
|
51
|
+
? clamp01(resolvedProgress)
|
|
52
|
+
: undefined,
|
|
53
|
+
);
|
|
54
|
+
let dasharray = $derived(
|
|
55
|
+
fraction != null
|
|
56
|
+
? `${INNER_CIRC * fraction} ${INNER_CIRC * (1 - fraction)}`
|
|
57
|
+
: undefined,
|
|
58
|
+
);
|
|
59
|
+
let dashoffset = $derived(fraction != null ? INNER_CIRC * 0.25 : undefined);
|
|
60
|
+
</script>
|
|
61
|
+
|
|
62
|
+
{#if resolvedVariant === "completed"}
|
|
63
|
+
<svg
|
|
64
|
+
width={size}
|
|
65
|
+
height={size}
|
|
66
|
+
viewBox="0 0 14 14"
|
|
67
|
+
fill="none"
|
|
68
|
+
aria-hidden="true"
|
|
69
|
+
class={cn("shrink-0", className)}
|
|
70
|
+
>
|
|
71
|
+
<circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
|
|
72
|
+
<polyline
|
|
73
|
+
points="4.5,7 6.5,9 9.5,5"
|
|
74
|
+
fill="none"
|
|
75
|
+
stroke="white"
|
|
76
|
+
stroke-width="1.5"
|
|
77
|
+
stroke-linecap="round"
|
|
78
|
+
stroke-linejoin="round"
|
|
79
|
+
class={animate ? "sd-check" : ""}
|
|
80
|
+
></polyline>
|
|
81
|
+
</svg>
|
|
82
|
+
{:else if resolvedVariant === "lost"}
|
|
83
|
+
<svg
|
|
84
|
+
width={size}
|
|
85
|
+
height={size}
|
|
86
|
+
viewBox="0 0 14 14"
|
|
87
|
+
fill="none"
|
|
88
|
+
aria-hidden="true"
|
|
89
|
+
class={cn("shrink-0", className)}
|
|
90
|
+
>
|
|
91
|
+
<circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
|
|
92
|
+
<g stroke="white" stroke-width="1.5" stroke-linecap="round">
|
|
93
|
+
<line x1="5" y1="5" x2="9" y2="9" class={animate ? "sd-cross" : ""}></line>
|
|
94
|
+
<line x1="9" y1="5" x2="5" y2="9" class={animate ? "sd-cross sd-cross-second" : ""}></line>
|
|
95
|
+
</g>
|
|
96
|
+
</svg>
|
|
97
|
+
{:else if resolvedVariant === "filled"}
|
|
98
|
+
<svg
|
|
99
|
+
width={size}
|
|
100
|
+
height={size}
|
|
101
|
+
viewBox="0 0 14 14"
|
|
102
|
+
fill="none"
|
|
103
|
+
aria-hidden="true"
|
|
104
|
+
class={cn("shrink-0", className)}
|
|
105
|
+
>
|
|
106
|
+
<circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
|
|
107
|
+
</svg>
|
|
108
|
+
{:else}
|
|
109
|
+
<svg
|
|
110
|
+
width={size}
|
|
111
|
+
height={size}
|
|
112
|
+
viewBox="0 0 14 14"
|
|
113
|
+
fill="none"
|
|
114
|
+
aria-hidden="true"
|
|
115
|
+
class={cn("shrink-0", className)}
|
|
116
|
+
>
|
|
117
|
+
<circle cx="7" cy="7" r="5.5" stroke={resolvedColor} stroke-width="1.5"></circle>
|
|
118
|
+
{#if dasharray}
|
|
119
|
+
<circle
|
|
120
|
+
cx="7"
|
|
121
|
+
cy="7"
|
|
122
|
+
r={INNER_R}
|
|
123
|
+
fill="none"
|
|
124
|
+
stroke={resolvedColor}
|
|
125
|
+
stroke-width="4"
|
|
126
|
+
stroke-dasharray={dasharray}
|
|
127
|
+
stroke-dashoffset={dashoffset}
|
|
128
|
+
transform="rotate(-90 7 7)"
|
|
129
|
+
class={animate ? "sd-fill" : ""}
|
|
130
|
+
></circle>
|
|
131
|
+
{/if}
|
|
132
|
+
</svg>
|
|
133
|
+
{/if}
|
|
134
|
+
|
|
135
|
+
<style>
|
|
136
|
+
.sd-fill {
|
|
137
|
+
transform-origin: 7px 7px;
|
|
138
|
+
animation: sd-fill-in var(--duration-slow, 320ms) var(--ease-smooth-out, ease-out) forwards;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@keyframes sd-fill-in {
|
|
142
|
+
from {
|
|
143
|
+
transform: rotate(-90deg) scale(0.4);
|
|
144
|
+
opacity: 0;
|
|
145
|
+
}
|
|
146
|
+
to {
|
|
147
|
+
transform: rotate(-90deg) scale(1);
|
|
148
|
+
opacity: 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.sd-pop {
|
|
153
|
+
transform-origin: 7px 7px;
|
|
154
|
+
animation: sd-pop-in var(--duration-medium, 240ms) var(--ease-smooth-out, ease-out) forwards;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@keyframes sd-pop-in {
|
|
158
|
+
from {
|
|
159
|
+
transform: scale(0.4);
|
|
160
|
+
opacity: 0;
|
|
161
|
+
}
|
|
162
|
+
60% {
|
|
163
|
+
transform: scale(1.08);
|
|
164
|
+
opacity: 1;
|
|
165
|
+
}
|
|
166
|
+
to {
|
|
167
|
+
transform: scale(1);
|
|
168
|
+
opacity: 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.sd-check {
|
|
173
|
+
stroke-dasharray: 12;
|
|
174
|
+
stroke-dashoffset: 12;
|
|
175
|
+
animation: sd-trace var(--duration-medium, 240ms) var(--ease-smooth-out, ease-out)
|
|
176
|
+
var(--duration-quick, 120ms) forwards;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.sd-cross {
|
|
180
|
+
stroke-dasharray: 6;
|
|
181
|
+
stroke-dashoffset: 6;
|
|
182
|
+
animation: sd-trace calc(var(--duration-fast, 160ms) / 2) var(--ease-smooth-out, ease-out)
|
|
183
|
+
var(--duration-quick, 120ms) forwards;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.sd-cross-second {
|
|
187
|
+
animation-delay: calc(var(--duration-quick, 120ms) + var(--duration-fast, 160ms) / 2);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@keyframes sd-trace {
|
|
191
|
+
to {
|
|
192
|
+
stroke-dashoffset: 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
@media (prefers-reduced-motion: reduce) {
|
|
197
|
+
.sd-fill,
|
|
198
|
+
.sd-pop,
|
|
199
|
+
.sd-check,
|
|
200
|
+
.sd-cross {
|
|
201
|
+
animation: none;
|
|
202
|
+
}
|
|
203
|
+
.sd-check,
|
|
204
|
+
.sd-cross {
|
|
205
|
+
stroke-dashoffset: 0;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
</style>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Component } from "svelte";
|
|
3
|
+
import StatusDot from "./status-dot.svelte";
|
|
4
|
+
import { cn } from "./lib/cn.js";
|
|
5
|
+
import { TONE_ICON } from "./tone.js";
|
|
6
|
+
import type { Statuses } from "./status-types.js";
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
statuses: Statuses;
|
|
10
|
+
status: string;
|
|
11
|
+
size?: number;
|
|
12
|
+
animate?: boolean;
|
|
13
|
+
class?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
let { statuses, status, size = 14, animate = true, class: className }: Props = $props();
|
|
17
|
+
|
|
18
|
+
// An icon in the map wins; otherwise the status falls back to its dot.
|
|
19
|
+
let icon = $derived(
|
|
20
|
+
statuses.icon(status) as Component<{ size?: number; class?: string }> | undefined,
|
|
21
|
+
);
|
|
22
|
+
let tone = $derived(statuses.tone(status));
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
{#if icon}
|
|
26
|
+
{@const Icon = icon}
|
|
27
|
+
<Icon {size} class={cn("shrink-0", TONE_ICON[tone], className)} />
|
|
28
|
+
{:else}
|
|
29
|
+
<StatusDot {statuses} {status} {size} {animate} class={className} />
|
|
30
|
+
{/if}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Component } from "svelte";
|
|
2
|
+
|
|
3
|
+
/** The one severity scale the kit ships. */
|
|
4
|
+
export type Tone = "neutral" | "accent" | "info" | "success" | "warning" | "destructive";
|
|
5
|
+
|
|
6
|
+
/** The shape the dot can take. `progress` needs a `progress` value between 0 and 1. */
|
|
7
|
+
export type StatusDotVariant = "open" | "progress" | "filled" | "completed" | "lost";
|
|
8
|
+
|
|
9
|
+
export type StatusDef = {
|
|
10
|
+
label: string;
|
|
11
|
+
tone?: Tone;
|
|
12
|
+
/** Any Svelte component, for example a Phosphor icon. Renders inside StatusIcon. */
|
|
13
|
+
icon?: Component;
|
|
14
|
+
variant?: StatusDotVariant;
|
|
15
|
+
/** 0 to 1. Only used by the `progress` variant. */
|
|
16
|
+
progress?: number;
|
|
17
|
+
/** Optional CSS color. Overrides the tone color for the dot and icon. */
|
|
18
|
+
color?: string;
|
|
19
|
+
description?: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type StatusMap = Record<string, StatusDef>;
|
|
23
|
+
|
|
24
|
+
export type StatusId<M extends StatusMap> = keyof M & string;
|
|
25
|
+
|
|
26
|
+
/** What the components accept. `defineStatuses` returns this plus a typed `ids`. */
|
|
27
|
+
export type Statuses = {
|
|
28
|
+
/** The raw map. Kept loose so a registry with specific keys is assignable here. */
|
|
29
|
+
map: StatusMap;
|
|
30
|
+
ids: string[];
|
|
31
|
+
has(id: string): boolean;
|
|
32
|
+
get(id: string): StatusDef | undefined;
|
|
33
|
+
label(id: string): string;
|
|
34
|
+
tone(id: string): Tone;
|
|
35
|
+
icon(id: string): Component | undefined;
|
|
36
|
+
variant(id: string): StatusDotVariant;
|
|
37
|
+
progress(id: string): number | undefined;
|
|
38
|
+
color(id: string): string | undefined;
|
|
39
|
+
};
|