@heyramzi/tasks 0.0.0-stage → 0.4.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 +285 -2
- package/package.json +98 -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 +394 -0
- package/src/icons/board-icon.svelte +11 -0
- package/src/icons/chevron-down.svg +1 -0
- package/src/icons/chevron-right.svg +1 -0
- package/src/icons/clock.svg +1 -0
- package/src/icons/close.svg +1 -0
- package/src/icons/gantt-icon.svelte +11 -0
- package/src/icons/list-icon.svelte +16 -0
- package/src/icons/milestone-flag.svg +1 -0
- package/src/icons/play.svg +1 -0
- package/src/icons/spinner.svg +1 -0
- package/src/inbox/icons/call.svg +1 -0
- package/src/inbox/icons/email.svg +1 -0
- package/src/inbox/icons/form.svg +1 -0
- package/src/inbox/icons/slack.svg +1 -0
- package/src/inbox/inbox-card.svelte +279 -0
- package/src/inbox/inbox-draft.svelte +84 -0
- package/src/inbox/inbox-logic.ts +56 -0
- package/src/inbox/inbox-statuses.ts +50 -0
- package/src/inbox/inbox-trace.svelte +44 -0
- package/src/inbox/inbox-types.ts +108 -0
- package/src/inbox/inbox.svelte +203 -0
- package/src/inbox/index.ts +23 -0
- package/src/index.ts +78 -0
- package/src/kanban-board.svelte +560 -0
- package/src/kanban-column.svelte +117 -0
- package/src/kanban-drag.ts +38 -0
- package/src/kanban-types.ts +65 -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 +57 -0
- package/src/status-dot.svelte +228 -0
- package/src/status-icon.svelte +30 -0
- package/src/status-types.ts +42 -0
- package/src/task-gantt.svelte +845 -0
- package/src/task-list.svelte +463 -0
- package/src/task-types.ts +43 -0
- package/src/task-view-switch.svelte +118 -0
- package/src/time/duration.ts +69 -0
- package/src/time/time-chip.svelte +62 -0
- package/src/time/time-dot.svelte +58 -0
- package/src/time/time-ring.svelte +74 -0
- package/src/time/time-tracker.svelte +567 -0
- package/src/time/time-types.ts +74 -0
- package/src/tone.ts +54 -0
- package/src/use-kanban-dnd.svelte.ts +173 -0
|
@@ -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,65 @@
|
|
|
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
|
+
* The cards that sit above and below the drop slot once the dragged card is
|
|
28
|
+
* set aside. Set on manual drops only, null at an end. For a backend that
|
|
29
|
+
* orders by neighbour instead of by key.
|
|
30
|
+
*/
|
|
31
|
+
beforeId?: string | null;
|
|
32
|
+
afterId?: string | null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** A named sort the board can apply instead of manual (`boardOrder`) ordering. */
|
|
36
|
+
export type KanbanSortKey<T> = {
|
|
37
|
+
id: string;
|
|
38
|
+
label: string;
|
|
39
|
+
compare: (a: T, b: T) => number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Second argument handed to the card snippet. */
|
|
43
|
+
export type KanbanCardContext = {
|
|
44
|
+
columnId: string;
|
|
45
|
+
index: number;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Second argument handed to the column header and footer snippets. */
|
|
49
|
+
export type KanbanColumnContext = {
|
|
50
|
+
/** Every card in the column, including the ones a `limit` keeps out of view. */
|
|
51
|
+
count: number;
|
|
52
|
+
/** A card is held over the column. */
|
|
53
|
+
active: boolean;
|
|
54
|
+
/** How many cards render. Fewer than `count` while the column is capped. */
|
|
55
|
+
shown: number;
|
|
56
|
+
/** The column's `limit` is hiding its tail. */
|
|
57
|
+
capped: boolean;
|
|
58
|
+
/** Fold the column into the hidden rail. Needs `hidden` or `onHiddenChange` on the board. */
|
|
59
|
+
hide: () => void;
|
|
60
|
+
/** Lift the `limit` so every card shows. */
|
|
61
|
+
expand: () => void;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** The default header renderer when no `columnHeader` snippet is given. */
|
|
65
|
+
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,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { cn } from "./lib/cn.js";
|
|
4
|
+
import { TONE_CLASS } from "./tone.js";
|
|
5
|
+
import type { Tone, Statuses } from "./status-types.js";
|
|
6
|
+
import spinnerIcon from "./icons/spinner.svg?raw";
|
|
7
|
+
|
|
8
|
+
type Props = {
|
|
9
|
+
/** A registry from `defineStatuses`. Supplies the label and tone. */
|
|
10
|
+
statuses?: Statuses;
|
|
11
|
+
/** The status id to look up in `statuses`. Leave it out when you pass `children`. */
|
|
12
|
+
status?: string;
|
|
13
|
+
/** Override the registry label. */
|
|
14
|
+
label?: string;
|
|
15
|
+
/** Override the registry tone. */
|
|
16
|
+
tone?: Tone;
|
|
17
|
+
/** Shows a spinner: a background job is still working on the record. */
|
|
18
|
+
busy?: boolean;
|
|
19
|
+
/** Capitalizes the first letter of each word. Turn off for a name or a number. */
|
|
20
|
+
capitalize?: boolean;
|
|
21
|
+
/** The chip's text, for a caller that already has the word. Wins over `label` and the registry. */
|
|
22
|
+
children?: Snippet;
|
|
23
|
+
class?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let {
|
|
27
|
+
statuses,
|
|
28
|
+
status,
|
|
29
|
+
label,
|
|
30
|
+
tone,
|
|
31
|
+
busy = false,
|
|
32
|
+
capitalize = true,
|
|
33
|
+
children,
|
|
34
|
+
class: className,
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
let resolvedTone = $derived(tone ?? (statuses && status ? statuses.tone(status) : "neutral"));
|
|
38
|
+
let resolvedLabel = $derived(label ?? (statuses && status ? statuses.label(status) : (status ?? "")));
|
|
39
|
+
</script>
|
|
40
|
+
|
|
41
|
+
<span
|
|
42
|
+
class={cn(
|
|
43
|
+
"inline-flex h-5 shrink-0 items-center justify-center gap-1 rounded-full border px-2 text-xs font-medium whitespace-nowrap",
|
|
44
|
+
capitalize && "capitalize",
|
|
45
|
+
TONE_CLASS[resolvedTone],
|
|
46
|
+
className,
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{#if busy}
|
|
50
|
+
<span class="size-3 animate-spin [&>svg]:size-full" aria-hidden="true">{@html spinnerIcon}</span>
|
|
51
|
+
{/if}
|
|
52
|
+
{#if children}
|
|
53
|
+
{@render children()}
|
|
54
|
+
{:else}
|
|
55
|
+
{resolvedLabel}
|
|
56
|
+
{/if}
|
|
57
|
+
</span>
|
|
@@ -0,0 +1,228 @@
|
|
|
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
|
+
interface 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("sd", 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
|
+
class={cn("sd-mark", animate && "sd-check")}
|
|
76
|
+
stroke-width="1.5"
|
|
77
|
+
stroke-linecap="round"
|
|
78
|
+
stroke-linejoin="round"
|
|
79
|
+
></polyline>
|
|
80
|
+
</svg>
|
|
81
|
+
{:else if resolvedVariant === "lost"}
|
|
82
|
+
<svg
|
|
83
|
+
width={size}
|
|
84
|
+
height={size}
|
|
85
|
+
viewBox="0 0 14 14"
|
|
86
|
+
fill="none"
|
|
87
|
+
aria-hidden="true"
|
|
88
|
+
class={cn("sd", className)}
|
|
89
|
+
>
|
|
90
|
+
<circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
|
|
91
|
+
<g class="sd-mark" stroke-width="1.5" stroke-linecap="round">
|
|
92
|
+
<line x1="5" y1="5" x2="9" y2="9" class={animate ? "sd-cross" : ""}></line>
|
|
93
|
+
<line x1="9" y1="5" x2="5" y2="9" class={animate ? "sd-cross sd-cross-second" : ""}></line>
|
|
94
|
+
</g>
|
|
95
|
+
</svg>
|
|
96
|
+
{:else if resolvedVariant === "filled"}
|
|
97
|
+
<svg
|
|
98
|
+
width={size}
|
|
99
|
+
height={size}
|
|
100
|
+
viewBox="0 0 14 14"
|
|
101
|
+
fill="none"
|
|
102
|
+
aria-hidden="true"
|
|
103
|
+
class={cn("sd", className)}
|
|
104
|
+
>
|
|
105
|
+
<circle cx="7" cy="7" r="6" fill={resolvedColor} class={animate ? "sd-pop" : ""}></circle>
|
|
106
|
+
</svg>
|
|
107
|
+
{:else}
|
|
108
|
+
<svg
|
|
109
|
+
width={size}
|
|
110
|
+
height={size}
|
|
111
|
+
viewBox="0 0 14 14"
|
|
112
|
+
fill="none"
|
|
113
|
+
aria-hidden="true"
|
|
114
|
+
class={cn("sd", className)}
|
|
115
|
+
>
|
|
116
|
+
<circle
|
|
117
|
+
cx="7"
|
|
118
|
+
cy="7"
|
|
119
|
+
r="5.5"
|
|
120
|
+
stroke={resolvedColor}
|
|
121
|
+
stroke-width="1.5"
|
|
122
|
+
class={resolvedVariant === "dashed" ? "sd-dashed" : ""}
|
|
123
|
+
></circle>
|
|
124
|
+
{#if dasharray}
|
|
125
|
+
<circle
|
|
126
|
+
cx="7"
|
|
127
|
+
cy="7"
|
|
128
|
+
r={INNER_R}
|
|
129
|
+
fill="none"
|
|
130
|
+
stroke={resolvedColor}
|
|
131
|
+
stroke-width="4"
|
|
132
|
+
stroke-dasharray={dasharray}
|
|
133
|
+
stroke-dashoffset={dashoffset}
|
|
134
|
+
transform="rotate(-90 7 7)"
|
|
135
|
+
class={animate ? "sd-fill" : ""}
|
|
136
|
+
></circle>
|
|
137
|
+
{/if}
|
|
138
|
+
</svg>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
.sd {
|
|
143
|
+
flex-shrink: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* The tick and cross read as cut out of the disc. A host sets the mark to its own ground. */
|
|
147
|
+
.sd-mark {
|
|
148
|
+
stroke: var(--status-dot-mark, white);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/* 12 dashes that close evenly: 2 * PI * 5.5 = 34.56, cut into 12 periods of 2.88. */
|
|
152
|
+
.sd-dashed {
|
|
153
|
+
stroke-dasharray: 1.3 1.58;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.sd-fill {
|
|
157
|
+
transform-origin: 7px 7px;
|
|
158
|
+
animation: sd-fill-in var(--duration-slow, 320ms) var(--ease-smooth-out, ease-out) forwards;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@keyframes sd-fill-in {
|
|
162
|
+
from {
|
|
163
|
+
transform: rotate(-90deg) scale(0.4);
|
|
164
|
+
opacity: 0;
|
|
165
|
+
}
|
|
166
|
+
to {
|
|
167
|
+
transform: rotate(-90deg) scale(1);
|
|
168
|
+
opacity: 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.sd-pop {
|
|
173
|
+
transform-origin: 7px 7px;
|
|
174
|
+
animation: sd-pop-in var(--duration-medium, 240ms) var(--ease-smooth-out, ease-out) forwards;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@keyframes sd-pop-in {
|
|
178
|
+
from {
|
|
179
|
+
transform: scale(0.4);
|
|
180
|
+
opacity: 0;
|
|
181
|
+
}
|
|
182
|
+
60% {
|
|
183
|
+
transform: scale(1.08);
|
|
184
|
+
opacity: 1;
|
|
185
|
+
}
|
|
186
|
+
to {
|
|
187
|
+
transform: scale(1);
|
|
188
|
+
opacity: 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.sd-check {
|
|
193
|
+
stroke-dasharray: 12;
|
|
194
|
+
stroke-dashoffset: 12;
|
|
195
|
+
animation: sd-trace var(--duration-medium, 240ms) var(--ease-smooth-out, ease-out)
|
|
196
|
+
var(--duration-quick, 120ms) forwards;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.sd-cross {
|
|
200
|
+
stroke-dasharray: 6;
|
|
201
|
+
stroke-dashoffset: 6;
|
|
202
|
+
animation: sd-trace calc(var(--duration-fast, 160ms) / 2) var(--ease-smooth-out, ease-out)
|
|
203
|
+
var(--duration-quick, 120ms) forwards;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.sd-cross-second {
|
|
207
|
+
animation-delay: calc(var(--duration-quick, 120ms) + var(--duration-fast, 160ms) / 2);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
@keyframes sd-trace {
|
|
211
|
+
to {
|
|
212
|
+
stroke-dashoffset: 0;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@media (prefers-reduced-motion: reduce) {
|
|
217
|
+
.sd-fill,
|
|
218
|
+
.sd-pop,
|
|
219
|
+
.sd-check,
|
|
220
|
+
.sd-cross {
|
|
221
|
+
animation: none;
|
|
222
|
+
}
|
|
223
|
+
.sd-check,
|
|
224
|
+
.sd-cross {
|
|
225
|
+
stroke-dashoffset: 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
</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,42 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* The shape the dot can take. `progress` needs a `progress` value between 0 and 1.
|
|
8
|
+
* `dashed` is an open ring cut into dashes, for an aggregate such as "All".
|
|
9
|
+
*/
|
|
10
|
+
export type StatusDotVariant = "open" | "dashed" | "progress" | "filled" | "completed" | "lost";
|
|
11
|
+
|
|
12
|
+
export type StatusDef = {
|
|
13
|
+
label: string;
|
|
14
|
+
tone?: Tone;
|
|
15
|
+
/** Any Svelte component, for example a Phosphor icon. Renders inside StatusIcon. */
|
|
16
|
+
icon?: Component;
|
|
17
|
+
variant?: StatusDotVariant;
|
|
18
|
+
/** 0 to 1. Only used by the `progress` variant. */
|
|
19
|
+
progress?: number;
|
|
20
|
+
/** Optional CSS color. Overrides the tone color for the dot and icon. */
|
|
21
|
+
color?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type StatusMap = Record<string, StatusDef>;
|
|
26
|
+
|
|
27
|
+
export type StatusId<M extends StatusMap> = keyof M & string;
|
|
28
|
+
|
|
29
|
+
/** What the components accept. `defineStatuses` returns this plus a typed `ids`. */
|
|
30
|
+
export type Statuses = {
|
|
31
|
+
/** The raw map. Kept loose so a registry with specific keys is assignable here. */
|
|
32
|
+
map: StatusMap;
|
|
33
|
+
ids: string[];
|
|
34
|
+
has(id: string): boolean;
|
|
35
|
+
get(id: string): StatusDef | undefined;
|
|
36
|
+
label(id: string): string;
|
|
37
|
+
tone(id: string): Tone;
|
|
38
|
+
icon(id: string): Component | undefined;
|
|
39
|
+
variant(id: string): StatusDotVariant;
|
|
40
|
+
progress(id: string): number | undefined;
|
|
41
|
+
color(id: string): string | undefined;
|
|
42
|
+
};
|