@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,463 @@
|
|
|
1
|
+
<script lang="ts" generics="T, C extends KanbanColumnDef = KanbanColumnDef">
|
|
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, C extends KanbanColumnDef> = {
|
|
14
|
+
/** The same shape as board columns, so one config feeds both views. */
|
|
15
|
+
groups: C[];
|
|
16
|
+
items: T[];
|
|
17
|
+
getId: (item: T) => string;
|
|
18
|
+
/** The group id a row belongs to. */
|
|
19
|
+
getGroup: (item: T) => string;
|
|
20
|
+
/**
|
|
21
|
+
* Nests a group under another by its id, e.g. a project under its client. A child draws
|
|
22
|
+
* inside its parent's section, indented, and folds with it. Return nothing for a top-level group.
|
|
23
|
+
*/
|
|
24
|
+
getParent?: (group: C) => string | null | undefined;
|
|
25
|
+
/** Called on drop with what to persist. Same contract as the board. */
|
|
26
|
+
onMove: (id: string, move: KanbanMove) => void | Promise<void>;
|
|
27
|
+
renderRow: Snippet<[T, TaskListRowContext]>;
|
|
28
|
+
/** Optional sticky header row above every group, e.g. column labels. */
|
|
29
|
+
head?: Snippet;
|
|
30
|
+
groupHeader?: Snippet<[C, TaskListGroupContext]>;
|
|
31
|
+
/** Controls at the end of a group header, shown on hover, focus or a coarse pointer. */
|
|
32
|
+
actions?: Snippet<[C, TaskListGroupContext]>;
|
|
33
|
+
/** Rendered under a group's rows, e.g. an add-task row. Folds with the group. */
|
|
34
|
+
groupFooter?: Snippet<[C, TaskListGroupContext]>;
|
|
35
|
+
boardOrder?: (item: T) => string | null | undefined;
|
|
36
|
+
/** Breaks a tie in manual order: two rows with the same key, or both without one. */
|
|
37
|
+
manualTiebreak?: (a: T, b: T) => number;
|
|
38
|
+
/**
|
|
39
|
+
* Rows are draggable and take Alt plus Up or Down. Turn it off for a list that only groups,
|
|
40
|
+
* where rows hold their own links and buttons and a drop makes no sense.
|
|
41
|
+
*/
|
|
42
|
+
draggable?: boolean;
|
|
43
|
+
/** The screen-reader name of a row. Defaults to a hint about the keyboard move. */
|
|
44
|
+
getLabel?: (item: T) => string;
|
|
45
|
+
/** Fires when a group is folded or unfolded, with every folded id. */
|
|
46
|
+
onCollapsedChange?: (ids: string[]) => void;
|
|
47
|
+
sortKeys?: KanbanSortKey<T>[];
|
|
48
|
+
sortKey?: string;
|
|
49
|
+
/** Groups can be folded from their header. Defaults to true. */
|
|
50
|
+
collapsible?: boolean;
|
|
51
|
+
/** Group ids that start folded. */
|
|
52
|
+
collapsed?: string[];
|
|
53
|
+
emptyLabel?: string;
|
|
54
|
+
isDroppable?: (groupId: string) => boolean;
|
|
55
|
+
/** Drops the card chrome (border, radius, fill) so the list sits flat on the page. Group dividers stay. */
|
|
56
|
+
flat?: boolean;
|
|
57
|
+
class?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
let {
|
|
61
|
+
groups,
|
|
62
|
+
items,
|
|
63
|
+
getId,
|
|
64
|
+
getGroup,
|
|
65
|
+
getParent,
|
|
66
|
+
onMove,
|
|
67
|
+
renderRow,
|
|
68
|
+
head,
|
|
69
|
+
groupHeader,
|
|
70
|
+
actions,
|
|
71
|
+
groupFooter,
|
|
72
|
+
boardOrder,
|
|
73
|
+
manualTiebreak,
|
|
74
|
+
draggable = true,
|
|
75
|
+
getLabel,
|
|
76
|
+
onCollapsedChange,
|
|
77
|
+
sortKeys = [],
|
|
78
|
+
sortKey = "manual",
|
|
79
|
+
collapsible = true,
|
|
80
|
+
collapsed = [],
|
|
81
|
+
emptyLabel = "Nothing here yet",
|
|
82
|
+
isDroppable,
|
|
83
|
+
flat = false,
|
|
84
|
+
class: className,
|
|
85
|
+
}: Props<T, C> = $props();
|
|
86
|
+
|
|
87
|
+
// Rows past this index enter together, so a long list never waits on its stagger.
|
|
88
|
+
const STAGGER_CAP = 8;
|
|
89
|
+
|
|
90
|
+
// svelte-ignore state_referenced_locally
|
|
91
|
+
let folded = $state(new Set(collapsed));
|
|
92
|
+
|
|
93
|
+
function compare(a: T, b: T): number {
|
|
94
|
+
if (sortKey === "manual") {
|
|
95
|
+
const tie = manualTiebreak?.(a, b) ?? 0;
|
|
96
|
+
if (!boardOrder) return tie;
|
|
97
|
+
const ao = boardOrder(a);
|
|
98
|
+
const bo = boardOrder(b);
|
|
99
|
+
if (ao == null && bo == null) return tie;
|
|
100
|
+
if (ao == null) return 1;
|
|
101
|
+
if (bo == null) return -1;
|
|
102
|
+
return ao < bo ? -1 : ao > bo ? 1 : tie;
|
|
103
|
+
}
|
|
104
|
+
const key = sortKeys.find((entry) => entry.id === sortKey);
|
|
105
|
+
return key ? key.compare(a, b) : 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function sortedByGroup(groupId: string): T[] {
|
|
109
|
+
return items.filter((item) => getGroup(item) === groupId).sort(compare);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// A group nested under a missing parent is top-level, so no row is ever dropped from view.
|
|
113
|
+
let groupIds = $derived(new Set(groups.map((g) => g.id)));
|
|
114
|
+
let roots = $derived(
|
|
115
|
+
groups.filter((g) => {
|
|
116
|
+
const parent = getParent?.(g);
|
|
117
|
+
return !parent || !groupIds.has(parent);
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
let childrenOf = $derived.by(() => {
|
|
121
|
+
const map = new Map<string, C[]>();
|
|
122
|
+
for (const g of groups) {
|
|
123
|
+
const parent = getParent?.(g);
|
|
124
|
+
if (!parent || !groupIds.has(parent)) continue;
|
|
125
|
+
map.set(parent, [...(map.get(parent) ?? []), g]);
|
|
126
|
+
}
|
|
127
|
+
return map;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/** Rows in a group and in every group nested under it. */
|
|
131
|
+
function countWithin(groupId: string): number {
|
|
132
|
+
let n = sortedByGroup(groupId).length;
|
|
133
|
+
for (const child of childrenOf.get(groupId) ?? []) n += countWithin(child.id);
|
|
134
|
+
return n;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function canDrop(groupId: string): boolean {
|
|
138
|
+
// A group that only holds other groups has no rows of its own to drop into.
|
|
139
|
+
if (childrenOf.has(groupId)) return false;
|
|
140
|
+
return isDroppable?.(groupId) ?? (groups.find((g) => g.id === groupId)?.droppable ?? true);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function idAt(list: T[], index: number): string | null {
|
|
144
|
+
const item = list[index];
|
|
145
|
+
return item === undefined ? null : getId(item);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dnd = createKanbanDnd<T>({
|
|
149
|
+
items: () => items,
|
|
150
|
+
getId: (item) => getId(item),
|
|
151
|
+
getColumn: (item) => getGroup(item),
|
|
152
|
+
getColumnItems: sortedByGroup,
|
|
153
|
+
getSortKey: () => sortKey,
|
|
154
|
+
boardOrder: (item) => boardOrder?.(item),
|
|
155
|
+
onMove: (id, move) => onMove(id, move),
|
|
156
|
+
isDroppable: canDrop,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
function toggle(groupId: string): void {
|
|
160
|
+
const next = new Set(folded);
|
|
161
|
+
if (next.has(groupId)) next.delete(groupId);
|
|
162
|
+
else next.add(groupId);
|
|
163
|
+
folded = next;
|
|
164
|
+
onCollapsedChange?.([...next]);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Alt + ArrowUp/Down sends the focused row to the end of the group above or below. */
|
|
168
|
+
function onRowKeyDown(e: KeyboardEvent, id: string): void {
|
|
169
|
+
if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
|
|
170
|
+
const item = items.find((entry) => getId(entry) === id);
|
|
171
|
+
if (!item) return;
|
|
172
|
+
const current = getGroup(item);
|
|
173
|
+
const at = groups.findIndex((g) => g.id === current);
|
|
174
|
+
if (at === -1) return;
|
|
175
|
+
const dir = e.key === "ArrowDown" ? 1 : -1;
|
|
176
|
+
for (let i = at + dir; i >= 0 && i < groups.length; i += dir) {
|
|
177
|
+
const target = groups[i];
|
|
178
|
+
if (!canDrop(target.id)) continue;
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
if (sortKey === "manual") {
|
|
181
|
+
const rows = sortedByGroup(target.id).filter((entry) => getId(entry) !== id);
|
|
182
|
+
const last = rows.at(-1);
|
|
183
|
+
void onMove(id, {
|
|
184
|
+
columnId: target.id,
|
|
185
|
+
...(boardOrder
|
|
186
|
+
? { boardOrder: boardOrderAt(rows, rows.length, (entry) => boardOrder(entry)) }
|
|
187
|
+
: {}),
|
|
188
|
+
beforeId: last === undefined ? null : getId(last),
|
|
189
|
+
afterId: null,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
void onMove(id, { columnId: target.id });
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
</script>
|
|
199
|
+
|
|
200
|
+
{#snippet section(group: C, depth: number)}
|
|
201
|
+
{@const rows = sortedByGroup(group.id)}
|
|
202
|
+
{@const children = childrenOf.get(group.id) ?? []}
|
|
203
|
+
{@const count = countWithin(group.id)}
|
|
204
|
+
{@const active = dnd.dragOver?.columnId === group.id}
|
|
205
|
+
{@const open = !folded.has(group.id)}
|
|
206
|
+
{@const hue = group.hue ?? "var(--color-primary)"}
|
|
207
|
+
{@const context = { count, active, open, depth, hasChildren: children.length > 0 }}
|
|
208
|
+
<section
|
|
209
|
+
aria-label={group.ariaLabel ?? group.label}
|
|
210
|
+
class={cn("tl-group group/tl", depth === 0 ? "border-b border-border last:border-b-0" : "border-t border-border/60")}
|
|
211
|
+
class:tl-group-active={active}
|
|
212
|
+
style:--tl-hue={hue}
|
|
213
|
+
ondragover={children.length ? undefined : (e) => dnd.onColumnDragOver(e, group.id)}
|
|
214
|
+
ondragleave={children.length ? undefined : dnd.onDragLeave}
|
|
215
|
+
ondrop={children.length ? undefined : (e) => dnd.onDrop(e, group.id)}
|
|
216
|
+
>
|
|
217
|
+
<div
|
|
218
|
+
class={cn("sticky z-10 flex h-10 items-center gap-2 px-3 backdrop-blur-sm", flat ? "bg-background/90" : "bg-card/90", depth === 0 && (head ? "top-9" : "top-0"))}
|
|
219
|
+
style:top={depth > 0 ? `${(head ? 2.25 : 0) + depth * 2.5}rem` : undefined}
|
|
220
|
+
style:padding-inline-start={depth > 0 ? `${0.75 + depth * 1.25}rem` : undefined}
|
|
221
|
+
>
|
|
222
|
+
{#if collapsible}
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
aria-expanded={open}
|
|
226
|
+
aria-label="{open ? 'Fold' : 'Unfold'} {group.label}"
|
|
227
|
+
onclick={() => toggle(group.id)}
|
|
228
|
+
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"
|
|
229
|
+
>
|
|
230
|
+
<span class="tl-chevron size-3.5 [&>svg]:size-full" class:tl-chevron-open={open} aria-hidden="true">
|
|
231
|
+
{@html chevronIcon}
|
|
232
|
+
</span>
|
|
233
|
+
</button>
|
|
234
|
+
{/if}
|
|
235
|
+
{#if groupHeader}
|
|
236
|
+
{@render groupHeader(group, context)}
|
|
237
|
+
{:else}
|
|
238
|
+
<span class="size-2 shrink-0 rounded-full" style:background={hue}></span>
|
|
239
|
+
<span class="truncate text-sm font-medium text-foreground">{group.label}</span>
|
|
240
|
+
<span
|
|
241
|
+
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"
|
|
242
|
+
>
|
|
243
|
+
{count}
|
|
244
|
+
</span>
|
|
245
|
+
{/if}
|
|
246
|
+
{#if actions}
|
|
247
|
+
<div
|
|
248
|
+
class="tl-actions ms-auto flex shrink-0 items-center gap-1 opacity-0 transition-opacity duration-[var(--duration-quick,150ms)] focus-within:opacity-100 any-pointer-coarse:opacity-100"
|
|
249
|
+
>
|
|
250
|
+
{@render actions(group, context)}
|
|
251
|
+
</div>
|
|
252
|
+
{/if}
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div class="tl-fold" class:tl-fold-open={open}>
|
|
256
|
+
<div class="tl-fold-inner">
|
|
257
|
+
<div role="list" aria-label={group.label} class="flex flex-col pb-1.5">
|
|
258
|
+
{#each rows as item, index (getId(item))}
|
|
259
|
+
{@const id = getId(item)}
|
|
260
|
+
{@const dropBefore =
|
|
261
|
+
dnd.showIndicatorBefore(group.id, id) &&
|
|
262
|
+
!dnd.isDragSource(id) &&
|
|
263
|
+
idAt(rows, index - 1) !== dnd.draggedId}
|
|
264
|
+
<!-- WHY: a row is a list item that takes focus for the Alt+arrow move; making it a button would hide the list from screen readers. -->
|
|
265
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_noninteractive_element_interactions -->
|
|
266
|
+
<div
|
|
267
|
+
role="listitem"
|
|
268
|
+
tabindex={draggable ? 0 : undefined}
|
|
269
|
+
aria-label={getLabel?.(item) ??
|
|
270
|
+
(draggable ? "Row. Press Alt plus Up or Down arrow to move between groups." : undefined)}
|
|
271
|
+
aria-keyshortcuts={draggable ? "Alt+ArrowUp Alt+ArrowDown" : undefined}
|
|
272
|
+
draggable={draggable ? "true" : undefined}
|
|
273
|
+
animate:flip={{ duration: 250, easing: cubicOut }}
|
|
274
|
+
ondragstart={draggable
|
|
275
|
+
? (e) => {
|
|
276
|
+
liftDragImage(e, -1);
|
|
277
|
+
dnd.onDragStart(e, id);
|
|
278
|
+
}
|
|
279
|
+
: undefined}
|
|
280
|
+
ondragend={draggable ? () => dnd.resetDrag() : undefined}
|
|
281
|
+
ondragover={draggable
|
|
282
|
+
? (e) => dnd.onItemDragOver(e, group.id, id, idAt(rows, index + 1))
|
|
283
|
+
: undefined}
|
|
284
|
+
onkeydown={draggable ? (e) => onRowKeyDown(e, id) : undefined}
|
|
285
|
+
style:--tl-i={Math.min(index, STAGGER_CAP)}
|
|
286
|
+
class={cn(
|
|
287
|
+
"tl-row relative mx-1.5 rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
288
|
+
draggable && "cursor-grab active:cursor-grabbing",
|
|
289
|
+
dropBefore && "tl-drop-before",
|
|
290
|
+
dnd.isDragSource(id) && "opacity-40",
|
|
291
|
+
)}
|
|
292
|
+
>
|
|
293
|
+
{@render renderRow(item, { groupId: group.id, index })}
|
|
294
|
+
</div>
|
|
295
|
+
{/each}
|
|
296
|
+
|
|
297
|
+
{#if dnd.showIndicatorEnd(group.id) && idAt(rows, rows.length - 1) !== dnd.draggedId}
|
|
298
|
+
<div class="tl-drop-end relative mx-1.5 h-2" aria-hidden="true"></div>
|
|
299
|
+
{/if}
|
|
300
|
+
|
|
301
|
+
{#if groupFooter}
|
|
302
|
+
{@render groupFooter(group, context)}
|
|
303
|
+
{/if}
|
|
304
|
+
|
|
305
|
+
{#if rows.length === 0 && children.length === 0 && !active}
|
|
306
|
+
<p class="mx-1.5 rounded-lg border border-dashed border-border px-3 py-3 text-xs text-muted-foreground">
|
|
307
|
+
{emptyLabel}
|
|
308
|
+
</p>
|
|
309
|
+
{/if}
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{#each children as child (child.id)}
|
|
313
|
+
{@render section(child, depth + 1)}
|
|
314
|
+
{/each}
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</section>
|
|
318
|
+
{/snippet}
|
|
319
|
+
|
|
320
|
+
<div
|
|
321
|
+
class={cn(
|
|
322
|
+
"task-list flex min-h-0 flex-1 flex-col overflow-y-auto",
|
|
323
|
+
flat ? "bg-background" : "rounded-xl border border-border bg-card text-card-foreground",
|
|
324
|
+
className,
|
|
325
|
+
)}
|
|
326
|
+
>
|
|
327
|
+
{#if head}
|
|
328
|
+
<div
|
|
329
|
+
class={cn("sticky top-0 z-20 flex h-9 shrink-0 items-center border-b border-border text-xs font-medium text-muted-foreground backdrop-blur-sm", flat ? "bg-background/90" : "bg-card/90")}
|
|
330
|
+
>
|
|
331
|
+
{@render head()}
|
|
332
|
+
</div>
|
|
333
|
+
{/if}
|
|
334
|
+
|
|
335
|
+
{#each roots as group (group.id)}
|
|
336
|
+
{@render section(group, 0)}
|
|
337
|
+
{/each}
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<style>
|
|
341
|
+
.tl-group {
|
|
342
|
+
transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
.tl-group:hover :global(.tl-actions),
|
|
346
|
+
.tl-group :global(.tl-actions:focus-within) {
|
|
347
|
+
opacity: 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.tl-group-active {
|
|
351
|
+
background: color-mix(in oklab, var(--tl-hue) 6%, transparent);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
.tl-chevron {
|
|
355
|
+
transition: rotate var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
.tl-chevron-open {
|
|
359
|
+
rotate: 90deg;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/* A 0fr to 1fr track folds to any height. Padding lives inside, or 0fr never fully closes. */
|
|
363
|
+
.tl-fold {
|
|
364
|
+
display: grid;
|
|
365
|
+
grid-template-rows: 0fr;
|
|
366
|
+
opacity: 0;
|
|
367
|
+
transition:
|
|
368
|
+
grid-template-rows var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
369
|
+
opacity var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.tl-fold-open {
|
|
373
|
+
grid-template-rows: 1fr;
|
|
374
|
+
opacity: 1;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.tl-fold-inner {
|
|
378
|
+
min-height: 0;
|
|
379
|
+
overflow: hidden;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.tl-row {
|
|
383
|
+
animation: tl-row-in var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
384
|
+
animation-delay: calc(var(--tl-i, 0) * var(--duration-stagger, 40ms));
|
|
385
|
+
transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.tl-row:hover {
|
|
389
|
+
background: color-mix(in oklab, var(--color-foreground, currentColor) 4%, transparent);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/* The drop line: a hairline in the group's hue with a dot where it starts. */
|
|
393
|
+
.tl-drop-before::before,
|
|
394
|
+
.tl-drop-end::before {
|
|
395
|
+
content: "";
|
|
396
|
+
position: absolute;
|
|
397
|
+
left: 0.5rem;
|
|
398
|
+
right: 0.5rem;
|
|
399
|
+
height: 2px;
|
|
400
|
+
border-radius: 1px;
|
|
401
|
+
background: var(--tl-hue);
|
|
402
|
+
animation: tl-line-in var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) both;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/* A ring where the line starts, so the drop point reads at a glance. */
|
|
406
|
+
.tl-drop-before::after,
|
|
407
|
+
.tl-drop-end::after {
|
|
408
|
+
content: "";
|
|
409
|
+
position: absolute;
|
|
410
|
+
left: 0.25rem;
|
|
411
|
+
width: 8px;
|
|
412
|
+
height: 8px;
|
|
413
|
+
border-radius: 999px;
|
|
414
|
+
border: 2px solid var(--tl-hue);
|
|
415
|
+
background: var(--color-card, white);
|
|
416
|
+
animation: tl-line-in var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) both;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.tl-drop-before::before {
|
|
420
|
+
top: -1px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.tl-drop-before::after {
|
|
424
|
+
top: -4px;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
.tl-drop-end::before {
|
|
428
|
+
top: 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
.tl-drop-end::after {
|
|
432
|
+
top: -3px;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
@keyframes tl-row-in {
|
|
436
|
+
from {
|
|
437
|
+
opacity: 0;
|
|
438
|
+
transform: translateY(var(--distance-micro, 4px));
|
|
439
|
+
filter: blur(var(--blur-small, 2px));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@keyframes tl-line-in {
|
|
444
|
+
from {
|
|
445
|
+
opacity: 0;
|
|
446
|
+
transform: scaleX(0.6);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
@media (prefers-reduced-motion: reduce) {
|
|
451
|
+
.tl-row,
|
|
452
|
+
.tl-drop-before::before,
|
|
453
|
+
.tl-drop-end::before,
|
|
454
|
+
.tl-drop-before::after,
|
|
455
|
+
.tl-drop-end::after {
|
|
456
|
+
animation: none;
|
|
457
|
+
}
|
|
458
|
+
.tl-fold,
|
|
459
|
+
.tl-chevron {
|
|
460
|
+
transition: none;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
</style>
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
/** Rows in the group, and in every group nested under it. */
|
|
12
|
+
count: number;
|
|
13
|
+
active: boolean;
|
|
14
|
+
open: boolean;
|
|
15
|
+
/** 0 for a top-level group. Only grows when `getParent` is set. */
|
|
16
|
+
depth: number;
|
|
17
|
+
hasChildren: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** What the gantt asks the caller to persist after a bar is moved or resized. */
|
|
21
|
+
export type GanttChange = {
|
|
22
|
+
/** ISO date, inclusive. */
|
|
23
|
+
start: string;
|
|
24
|
+
/** ISO date, inclusive. A one-day task has `end === start`. */
|
|
25
|
+
end: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** One option in the view switch. */
|
|
29
|
+
export type TaskView = {
|
|
30
|
+
id: string;
|
|
31
|
+
label: string;
|
|
32
|
+
/** Any Svelte component that takes `size` and `class`, e.g. `BoardIcon`. */
|
|
33
|
+
icon?: Component<{ size?: number; class?: string }>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Second argument handed to the gantt label snippet. */
|
|
37
|
+
export type GanttLabelContext = {
|
|
38
|
+
/** 0 for a top-level task. Only grows when `getParent` is set. */
|
|
39
|
+
depth: number;
|
|
40
|
+
hasChildren: boolean;
|
|
41
|
+
/** False when the task's children are folded away. */
|
|
42
|
+
open: boolean;
|
|
43
|
+
};
|
|
@@ -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>
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { Tone } from "../status-types.js";
|
|
2
|
+
|
|
3
|
+
/** "90", "45m", "1h", "1.5h", "1h30", "1h 30m" and "1:30" to whole minutes. Null when it isn't a duration. */
|
|
4
|
+
export function parseMinutes(text: string): number | null {
|
|
5
|
+
const value = text.trim().toLowerCase();
|
|
6
|
+
if (!value) return null;
|
|
7
|
+
if (/^\d+$/.test(value)) return Number(value);
|
|
8
|
+
const clock = /^(\d+):([0-5]?\d)$/.exec(value);
|
|
9
|
+
if (clock) return Number(clock[1]) * 60 + Number(clock[2]);
|
|
10
|
+
const decimalHours = /^(\d+(?:[.,]\d+)?)\s*h$/.exec(value);
|
|
11
|
+
if (decimalHours?.[1]) return Math.round(Number(decimalHours[1].replace(",", ".")) * 60);
|
|
12
|
+
const parts = /^(?:(\d+)\s*h)?\s*(?:(\d+)\s*m?)?$/.exec(value);
|
|
13
|
+
if (!parts || (!parts[1] && !parts[2])) return null;
|
|
14
|
+
return Number(parts[1] ?? 0) * 60 + Number(parts[2] ?? 0);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 150 to "2h 30m". */
|
|
18
|
+
export function formatMinutes(minutes: number): string {
|
|
19
|
+
const whole = Math.max(0, Math.round(Number.isFinite(minutes) ? minutes : 0));
|
|
20
|
+
const hours = Math.floor(whole / 60);
|
|
21
|
+
const rest = whole % 60;
|
|
22
|
+
if (hours === 0) return `${rest}m`;
|
|
23
|
+
return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Milliseconds to the timer readout, "1:05:09". */
|
|
27
|
+
export function formatClock(ms: number): { hours: string; minutes: string; seconds: string } {
|
|
28
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
29
|
+
return {
|
|
30
|
+
hours: String(Math.floor(total / 3600)),
|
|
31
|
+
minutes: String(Math.floor((total % 3600) / 60)).padStart(2, "0"),
|
|
32
|
+
seconds: String(total % 60).padStart(2, "0"),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The reader's own calendar day as `YYYY-MM-DD`. */
|
|
37
|
+
export function localDay(date: Date = new Date()): string {
|
|
38
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
39
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
40
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** "Today", "Yesterday", then "Mon, Oct 12", in the given locale. */
|
|
44
|
+
export function dayLabel(day: string, locale?: string): string {
|
|
45
|
+
const date = new Date(`${day}T12:00:00`);
|
|
46
|
+
const now = new Date();
|
|
47
|
+
now.setHours(12, 0, 0, 0);
|
|
48
|
+
const gap = Math.round((date.getTime() - now.getTime()) / 86_400_000);
|
|
49
|
+
if (gap === 0 || gap === -1) {
|
|
50
|
+
const word = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(gap, "day");
|
|
51
|
+
return word.charAt(0).toLocaleUpperCase(locale) + word.slice(1);
|
|
52
|
+
}
|
|
53
|
+
return date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The tone of a budget: quiet until 90%, warning to 100%, destructive past it. */
|
|
57
|
+
export function budgetTone(logged: number, estimate: number | null | undefined): Tone {
|
|
58
|
+
if (!estimate || estimate <= 0) return "neutral";
|
|
59
|
+
const ratio = logged / estimate;
|
|
60
|
+
if (ratio > 1) return "destructive";
|
|
61
|
+
return ratio >= 0.9 ? "warning" : "neutral";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A stable color for a person, from their name or id. */
|
|
65
|
+
export function personColor(key: string): string {
|
|
66
|
+
let hash = 0;
|
|
67
|
+
for (const char of key) hash = (hash * 31 + char.charCodeAt(0)) % 360;
|
|
68
|
+
return `oklch(0.72 0.12 ${hash})`;
|
|
69
|
+
}
|