@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,845 @@
|
|
|
1
|
+
<script lang="ts" generics="T">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import { cn } from "./lib/cn.js";
|
|
4
|
+
import { dayNumber, isoFromDay, todayIso, weekday } from "./dates.js";
|
|
5
|
+
import type { KanbanColumn as KanbanColumnDef } from "./kanban-types.js";
|
|
6
|
+
import type { GanttChange, GanttLabelContext } from "./task-types.js";
|
|
7
|
+
import chevronIcon from "./icons/chevron-right.svg?raw";
|
|
8
|
+
import flagIcon from "./icons/milestone-flag.svg?raw";
|
|
9
|
+
|
|
10
|
+
type Props<T> = {
|
|
11
|
+
items: T[];
|
|
12
|
+
getId: (item: T) => string;
|
|
13
|
+
getTitle: (item: T) => string;
|
|
14
|
+
/**
|
|
15
|
+
* ISO date, inclusive. Null or empty means undated: with an end date alone the task
|
|
16
|
+
* starts the same day, and with neither it has no bar of its own.
|
|
17
|
+
*/
|
|
18
|
+
getStart: (item: T) => string | null | undefined;
|
|
19
|
+
/** ISO date, inclusive. See `getStart`. */
|
|
20
|
+
getEnd: (item: T) => string | null | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The id of the task this one sits under. Children nest under their parent, indented
|
|
23
|
+
* and foldable. A parent with no dates of its own draws the span of what is below it.
|
|
24
|
+
*/
|
|
25
|
+
getParent?: (item: T) => string | null | undefined;
|
|
26
|
+
/** Draws a flag at the end date instead of a bar. Milestones are not draggable. */
|
|
27
|
+
isMilestone?: (item: T) => boolean;
|
|
28
|
+
/** Parent ids that start folded. */
|
|
29
|
+
collapsed?: string[];
|
|
30
|
+
/** Shown in place of a bar for an undated task. */
|
|
31
|
+
noDatesLabel?: string;
|
|
32
|
+
/** Any CSS color for the bar. Falls back to the group hue, then the primary color. */
|
|
33
|
+
getHue?: (item: T) => string | undefined;
|
|
34
|
+
/** 0 to 1. Fills the bar from the left. */
|
|
35
|
+
getProgress?: (item: T) => number | undefined;
|
|
36
|
+
/** Ids this task waits on. Draws an arrow from each one's end to this one's start. */
|
|
37
|
+
getDependsOn?: (item: T) => string[] | undefined;
|
|
38
|
+
/** Group rows under a header, in the order given. Same shape as board columns. */
|
|
39
|
+
groups?: KanbanColumnDef[];
|
|
40
|
+
getGroup?: (item: T) => string;
|
|
41
|
+
/** Called after a bar is dragged, resized or nudged. Leave it out for a read-only chart. */
|
|
42
|
+
onChange?: (id: string, change: GanttChange) => void | Promise<void>;
|
|
43
|
+
/** Called on a click or Enter that did not move the bar. */
|
|
44
|
+
onSelect?: (id: string) => void;
|
|
45
|
+
/** The left column for a row. Defaults to the title. */
|
|
46
|
+
renderLabel?: Snippet<[T, GanttLabelContext]>;
|
|
47
|
+
/** First and last day shown. Default: the tasks' span, padded, starting on a Monday. */
|
|
48
|
+
from?: string;
|
|
49
|
+
to?: string;
|
|
50
|
+
/** The day the today line marks. Defaults to the viewer's today. */
|
|
51
|
+
today?: string;
|
|
52
|
+
dayWidth?: number;
|
|
53
|
+
rowHeight?: number;
|
|
54
|
+
/** Width of the task column in px. It never takes more than 40% of a narrow screen. */
|
|
55
|
+
labelWidth?: number;
|
|
56
|
+
labelHeading?: string;
|
|
57
|
+
locale?: string;
|
|
58
|
+
/** Drops the card chrome (border, radius, fill) so the chart sits flat on the page. */
|
|
59
|
+
flat?: boolean;
|
|
60
|
+
class?: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let {
|
|
64
|
+
items,
|
|
65
|
+
getId,
|
|
66
|
+
getTitle,
|
|
67
|
+
getStart,
|
|
68
|
+
getEnd,
|
|
69
|
+
getParent,
|
|
70
|
+
isMilestone,
|
|
71
|
+
collapsed = [],
|
|
72
|
+
noDatesLabel = "No dates",
|
|
73
|
+
getHue,
|
|
74
|
+
getProgress,
|
|
75
|
+
getDependsOn,
|
|
76
|
+
groups,
|
|
77
|
+
getGroup,
|
|
78
|
+
onChange,
|
|
79
|
+
onSelect,
|
|
80
|
+
renderLabel,
|
|
81
|
+
from,
|
|
82
|
+
to,
|
|
83
|
+
today = todayIso(),
|
|
84
|
+
dayWidth = 32,
|
|
85
|
+
rowHeight = 40,
|
|
86
|
+
labelWidth = 240,
|
|
87
|
+
labelHeading = "Task",
|
|
88
|
+
locale = "en-US",
|
|
89
|
+
flat = false,
|
|
90
|
+
class: className,
|
|
91
|
+
}: Props<T> = $props();
|
|
92
|
+
|
|
93
|
+
type Span = { start: number; end: number };
|
|
94
|
+
type Drag = { id: string; mode: "move" | "start" | "end"; originX: number; dx: number };
|
|
95
|
+
type Row =
|
|
96
|
+
| { kind: "group"; group: KanbanColumnDef; count: number; span: Span | null }
|
|
97
|
+
| {
|
|
98
|
+
kind: "task";
|
|
99
|
+
item: T;
|
|
100
|
+
id: string;
|
|
101
|
+
order: number;
|
|
102
|
+
depth: number;
|
|
103
|
+
hasChildren: boolean;
|
|
104
|
+
open: boolean;
|
|
105
|
+
/** The task's own dates, with a live drag applied. Null when undated. */
|
|
106
|
+
own: Span | null;
|
|
107
|
+
/** What the row draws: its own span, else the span of what is below it. */
|
|
108
|
+
span: Span | null;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Bars past this row enter together, so a long chart never waits on its stagger.
|
|
112
|
+
const STAGGER_CAP = 10;
|
|
113
|
+
// A bar narrower than this shows its title outside, to the right.
|
|
114
|
+
const INLINE_TITLE_MIN = 72;
|
|
115
|
+
const INDENT = 14;
|
|
116
|
+
|
|
117
|
+
let scroller = $state<HTMLDivElement>();
|
|
118
|
+
let corner = $state<HTMLDivElement>();
|
|
119
|
+
let drag = $state<Drag | null>(null);
|
|
120
|
+
let hoveredId = $state<string | null>(null);
|
|
121
|
+
let scrolledToToday = false;
|
|
122
|
+
|
|
123
|
+
let todayDay = $derived(dayNumber(today));
|
|
124
|
+
let editable = $derived(!!onChange);
|
|
125
|
+
// Under 18px a day is too narrow for a gap either side of a bar.
|
|
126
|
+
let inset = $derived(dayWidth >= 18 ? 3 : 0);
|
|
127
|
+
// svelte-ignore state_referenced_locally
|
|
128
|
+
let folded = $state(new Set(collapsed));
|
|
129
|
+
|
|
130
|
+
function toggle(id: string): void {
|
|
131
|
+
const next = new Set(folded);
|
|
132
|
+
if (next.has(id)) next.delete(id);
|
|
133
|
+
else next.add(id);
|
|
134
|
+
folded = next;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let range = $derived.by(() => {
|
|
138
|
+
let lo = todayDay;
|
|
139
|
+
let hi = todayDay;
|
|
140
|
+
for (const item of items) {
|
|
141
|
+
const span = spanOf(item);
|
|
142
|
+
if (!span) continue;
|
|
143
|
+
lo = Math.min(lo, span.start);
|
|
144
|
+
hi = Math.max(hi, span.end);
|
|
145
|
+
}
|
|
146
|
+
let first = from ? dayNumber(from) : lo - 7;
|
|
147
|
+
// Day 0 was a Thursday. Step back to the Monday on or before `first`.
|
|
148
|
+
if (!from) first -= (weekday(first) + 6) % 7;
|
|
149
|
+
const last = to ? dayNumber(to) : hi + 21;
|
|
150
|
+
return { first, last: Math.max(last, first) };
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
let dayCount = $derived(range.last - range.first + 1);
|
|
154
|
+
let timelineWidth = $derived(dayCount * dayWidth);
|
|
155
|
+
|
|
156
|
+
let days = $derived(
|
|
157
|
+
Array.from({ length: dayCount }, (_, i) => {
|
|
158
|
+
const day = range.first + i;
|
|
159
|
+
const wd = weekday(day);
|
|
160
|
+
return { day, i, date: Number(isoFromDay(day).slice(8, 10)), wd, weekend: wd === 0 || wd === 6 };
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
let monthFormat = $derived(new Intl.DateTimeFormat(locale, { month: "long", year: "numeric", timeZone: "UTC" }));
|
|
165
|
+
let dayFormat = $derived(new Intl.DateTimeFormat(locale, { month: "short", day: "numeric", timeZone: "UTC" }));
|
|
166
|
+
let weekdayFormat = $derived(new Intl.DateTimeFormat(locale, { weekday: "narrow", timeZone: "UTC" }));
|
|
167
|
+
|
|
168
|
+
let months = $derived.by(() => {
|
|
169
|
+
const out: { key: string; label: string; i: number; length: number }[] = [];
|
|
170
|
+
for (const d of days) {
|
|
171
|
+
const key = isoFromDay(d.day).slice(0, 7);
|
|
172
|
+
const last = out.at(-1);
|
|
173
|
+
if (last?.key === key) last.length++;
|
|
174
|
+
else out.push({ key, label: monthFormat.format(new Date(d.day * 86_400_000)), i: d.i, length: 1 });
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
/** The task's own dates. A lone date stands for a one-day task. */
|
|
180
|
+
function spanOf(item: T): Span | null {
|
|
181
|
+
const first = getStart(item) || getEnd(item);
|
|
182
|
+
const last = getEnd(item) || getStart(item);
|
|
183
|
+
if (!first || !last) return null;
|
|
184
|
+
const start = dayNumber(first);
|
|
185
|
+
return { start, end: Math.max(start, dayNumber(last)) };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** A bar's span, with the live drag applied. Resizes never cross the other edge. */
|
|
189
|
+
function liveSpan(item: T, id: string): Span | null {
|
|
190
|
+
const span = spanOf(item);
|
|
191
|
+
if (!span || drag?.id !== id || drag.dx === 0) return span;
|
|
192
|
+
if (drag.mode === "move") return { start: span.start + drag.dx, end: span.end + drag.dx };
|
|
193
|
+
if (drag.mode === "start") return { start: Math.min(span.start + drag.dx, span.end), end: span.end };
|
|
194
|
+
return { start: span.start, end: Math.max(span.end + drag.dx, span.start) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let rows = $derived.by(() => {
|
|
198
|
+
const out: Row[] = [];
|
|
199
|
+
let order = 0;
|
|
200
|
+
const join = (a: Span | null, b: Span | null): Span | null =>
|
|
201
|
+
!a ? b : !b ? a : { start: Math.min(a.start, b.start), end: Math.max(a.end, b.end) };
|
|
202
|
+
|
|
203
|
+
// Depth first, so a parent's row comes before its children. A parent's span is known
|
|
204
|
+
// only after its children are, so the row is pushed first and filled in after.
|
|
205
|
+
const emit = (members: T[]) => {
|
|
206
|
+
if (!getParent) {
|
|
207
|
+
for (const item of members) {
|
|
208
|
+
const id = getId(item);
|
|
209
|
+
const own = liveSpan(item, id);
|
|
210
|
+
out.push({ kind: "task", item, id, order: order++, depth: 0, hasChildren: false, open: true, own, span: own });
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
const ids = new Set(members.map((item) => getId(item)));
|
|
215
|
+
const kids = new Map<string | null, T[]>();
|
|
216
|
+
for (const item of members) {
|
|
217
|
+
const parent = getParent(item);
|
|
218
|
+
const key = parent && ids.has(parent) ? parent : null;
|
|
219
|
+
kids.set(key, [...(kids.get(key) ?? []), item]);
|
|
220
|
+
}
|
|
221
|
+
const walk = (item: T, depth: number, visible: boolean): Span | null => {
|
|
222
|
+
const id = getId(item);
|
|
223
|
+
const children = kids.get(id) ?? [];
|
|
224
|
+
const open = !folded.has(id);
|
|
225
|
+
const own = liveSpan(item, id);
|
|
226
|
+
const row: Row = { kind: "task", item, id, order: 0, depth, hasChildren: children.length > 0, open, own, span: own };
|
|
227
|
+
if (visible) {
|
|
228
|
+
row.order = order++;
|
|
229
|
+
out.push(row);
|
|
230
|
+
}
|
|
231
|
+
let below: Span | null = null;
|
|
232
|
+
for (const child of children) below = join(below, walk(child, depth + 1, visible && open));
|
|
233
|
+
if (!own) row.span = below;
|
|
234
|
+
return join(own, below);
|
|
235
|
+
};
|
|
236
|
+
for (const item of kids.get(null) ?? []) walk(item, 0, true);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
if (!groups || !getGroup) {
|
|
240
|
+
emit(items);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
for (const group of groups) {
|
|
244
|
+
const members = items.filter((item) => getGroup(item) === group.id);
|
|
245
|
+
let span: Span | null = null;
|
|
246
|
+
for (const item of members) span = join(span, liveSpan(item, getId(item)));
|
|
247
|
+
out.push({ kind: "group", group, count: members.length, span });
|
|
248
|
+
emit(members);
|
|
249
|
+
}
|
|
250
|
+
return out;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
let groupHue = $derived(new Map((groups ?? []).map((g) => [g.id, g.hue])));
|
|
254
|
+
|
|
255
|
+
function hueOf(item: T): string {
|
|
256
|
+
return getHue?.(item) ?? (getGroup ? groupHue.get(getGroup(item)) : undefined) ?? "var(--color-primary)";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function x(day: number): number {
|
|
260
|
+
return (day - range.first) * dayWidth;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** A right-angled path through `points`, each corner rounded by up to `radius`. */
|
|
264
|
+
function roundedPath(points: [number, number][], radius: number): string {
|
|
265
|
+
let d = `M${points[0][0]},${points[0][1]}`;
|
|
266
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
267
|
+
const [px, py] = points[i - 1];
|
|
268
|
+
const [cx, cy] = points[i];
|
|
269
|
+
const [nx, ny] = points[i + 1];
|
|
270
|
+
const r = Math.min(radius, Math.hypot(cx - px, cy - py) / 2, Math.hypot(nx - cx, ny - cy) / 2);
|
|
271
|
+
const inX = cx - Math.sign(cx - px) * r;
|
|
272
|
+
const inY = cy - Math.sign(cy - py) * r;
|
|
273
|
+
const outX = cx + Math.sign(nx - cx) * r;
|
|
274
|
+
const outY = cy + Math.sign(ny - cy) * r;
|
|
275
|
+
d += ` L${inX},${inY} Q${cx},${cy} ${outX},${outY}`;
|
|
276
|
+
}
|
|
277
|
+
const [lx, ly] = points[points.length - 1];
|
|
278
|
+
return `${d} L${lx},${ly}`;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Each arrow runs from the end of the task waited on to the start of the one waiting.
|
|
282
|
+
let arrows = $derived.by(() => {
|
|
283
|
+
if (!getDependsOn) return [];
|
|
284
|
+
const at = new Map<string, { row: number; span: Span }>();
|
|
285
|
+
rows.forEach((row, index) => {
|
|
286
|
+
if (row.kind === "task" && row.span) at.set(row.id, { row: index, span: row.span });
|
|
287
|
+
});
|
|
288
|
+
const out: { key: string; d: string; head: string; from: string; to: string }[] = [];
|
|
289
|
+
for (const row of rows) {
|
|
290
|
+
if (row.kind !== "task") continue;
|
|
291
|
+
const target = at.get(row.id);
|
|
292
|
+
if (!target) continue;
|
|
293
|
+
for (const dep of getDependsOn(row.item) ?? []) {
|
|
294
|
+
const source = at.get(dep);
|
|
295
|
+
if (!source) continue;
|
|
296
|
+
const x1 = x(source.span.end + 1) - inset;
|
|
297
|
+
const y1 = source.row * rowHeight + rowHeight / 2;
|
|
298
|
+
const x2 = x(target.span.start) + inset;
|
|
299
|
+
const y2 = target.row * rowHeight + rowHeight / 2;
|
|
300
|
+
// Room between the bars: one bend in the gap. No room: out, round the row gap, and back in.
|
|
301
|
+
const points: [number, number][] =
|
|
302
|
+
x2 - x1 >= 24
|
|
303
|
+
? [[x1, y1], [x1 + 12, y1], [x1 + 12, y2], [x2, y2]]
|
|
304
|
+
: [
|
|
305
|
+
[x1, y1],
|
|
306
|
+
[x1 + 10, y1],
|
|
307
|
+
[x1 + 10, y1 + (y2 > y1 ? rowHeight / 2 : -rowHeight / 2)],
|
|
308
|
+
[x2 - 12, y1 + (y2 > y1 ? rowHeight / 2 : -rowHeight / 2)],
|
|
309
|
+
[x2 - 12, y2],
|
|
310
|
+
[x2, y2],
|
|
311
|
+
];
|
|
312
|
+
out.push({
|
|
313
|
+
key: `${dep}->${row.id}`,
|
|
314
|
+
d: roundedPath(points, 6),
|
|
315
|
+
head: `M${x2 - 5},${y2 - 3.5} L${x2},${y2} L${x2 - 5},${y2 + 3.5}`,
|
|
316
|
+
from: dep,
|
|
317
|
+
to: row.id,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// Open on today, a third of the way in, so past work and what's next both show.
|
|
325
|
+
$effect(() => {
|
|
326
|
+
if (!scroller || scrolledToToday) return;
|
|
327
|
+
scrolledToToday = true;
|
|
328
|
+
const label = corner?.offsetWidth ?? 0;
|
|
329
|
+
scroller.scrollLeft = Math.max(0, x(todayDay) - (scroller.clientWidth - label) / 3);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
/** Scroll the today line into view. */
|
|
333
|
+
export function scrollToToday(): void {
|
|
334
|
+
if (!scroller) return;
|
|
335
|
+
const label = corner?.offsetWidth ?? 0;
|
|
336
|
+
scroller.scrollTo({ left: Math.max(0, x(todayDay) - (scroller.clientWidth - label) / 3), behavior: "smooth" });
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function onPointerDown(e: PointerEvent, id: string, mode: Drag["mode"]): void {
|
|
340
|
+
if (!editable || e.button !== 0) return;
|
|
341
|
+
e.stopPropagation();
|
|
342
|
+
(e.currentTarget as HTMLElement).closest<HTMLElement>("[data-bar]")?.setPointerCapture(e.pointerId);
|
|
343
|
+
drag = { id, mode, originX: e.clientX, dx: 0 };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function onPointerMove(e: PointerEvent): void {
|
|
347
|
+
if (!drag) return;
|
|
348
|
+
const dx = Math.round((e.clientX - drag.originX) / dayWidth);
|
|
349
|
+
if (dx !== drag.dx) drag = { ...drag, dx };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function onPointerUp(item: T, id: string): void {
|
|
353
|
+
if (!drag || drag.id !== id) return;
|
|
354
|
+
const moved = drag.dx !== 0;
|
|
355
|
+
const span = liveSpan(item, id);
|
|
356
|
+
drag = null;
|
|
357
|
+
if (moved && span) void onChange?.(id, { start: isoFromDay(span.start), end: isoFromDay(span.end) });
|
|
358
|
+
else onSelect?.(id);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Arrow keys move a bar a day. Shift plus an arrow moves its end, so the task grows or shrinks. */
|
|
362
|
+
function onBarKeyDown(e: KeyboardEvent, item: T, id: string): void {
|
|
363
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
364
|
+
e.preventDefault();
|
|
365
|
+
onSelect?.(id);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (!editable || (e.key !== "ArrowLeft" && e.key !== "ArrowRight")) return;
|
|
369
|
+
e.preventDefault();
|
|
370
|
+
const step = e.key === "ArrowRight" ? 1 : -1;
|
|
371
|
+
const span = spanOf(item);
|
|
372
|
+
if (!span) return;
|
|
373
|
+
const next = e.shiftKey
|
|
374
|
+
? { start: span.start, end: Math.max(span.start, span.end + step) }
|
|
375
|
+
: { start: span.start + step, end: span.end + step };
|
|
376
|
+
if (next.start === span.start && next.end === span.end) return;
|
|
377
|
+
void onChange?.(id, { start: isoFromDay(next.start), end: isoFromDay(next.end) });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function rangeLabel(span: Span): string {
|
|
381
|
+
const length = span.end - span.start + 1;
|
|
382
|
+
return `${dayFormat.format(new Date(span.start * 86_400_000))} to ${dayFormat.format(new Date(span.end * 86_400_000))}, ${length} ${length === 1 ? "day" : "days"}`;
|
|
383
|
+
}
|
|
384
|
+
</script>
|
|
385
|
+
|
|
386
|
+
<div
|
|
387
|
+
bind:this={scroller}
|
|
388
|
+
class={cn(
|
|
389
|
+
"task-gantt relative min-h-0 flex-1 overflow-auto overscroll-x-contain",
|
|
390
|
+
flat ? "bg-background" : "rounded-xl border border-border bg-card text-card-foreground",
|
|
391
|
+
drag && "select-none",
|
|
392
|
+
className,
|
|
393
|
+
)}
|
|
394
|
+
style:--g-label="min({labelWidth}px, 40vw)"
|
|
395
|
+
style:--g-day="{dayWidth}px"
|
|
396
|
+
style:--g-row="{rowHeight}px"
|
|
397
|
+
>
|
|
398
|
+
<div class="relative" style:width="calc(var(--g-label) + {timelineWidth}px)">
|
|
399
|
+
<!-- Header: months over days, pinned to the top. -->
|
|
400
|
+
<div class="sticky top-0 z-30 flex border-b border-border bg-card/90 backdrop-blur-sm">
|
|
401
|
+
<div
|
|
402
|
+
bind:this={corner}
|
|
403
|
+
class="sticky left-0 z-10 flex w-(--g-label) shrink-0 items-end border-r border-border bg-card px-3 pb-2 text-xs font-medium text-muted-foreground"
|
|
404
|
+
>
|
|
405
|
+
{labelHeading}
|
|
406
|
+
</div>
|
|
407
|
+
<div class="relative shrink-0" style:width="{timelineWidth}px">
|
|
408
|
+
<div class="relative h-7 border-b border-border/60">
|
|
409
|
+
{#each months as month (month.key)}
|
|
410
|
+
<div
|
|
411
|
+
class="absolute inset-y-0 border-l border-border/60 first:border-l-0"
|
|
412
|
+
style:left="{month.i * dayWidth}px"
|
|
413
|
+
style:width="{month.length * dayWidth}px"
|
|
414
|
+
>
|
|
415
|
+
<span
|
|
416
|
+
class="sticky left-(--g-label) inline-block px-2 pt-1.5 text-xs font-medium whitespace-nowrap text-foreground"
|
|
417
|
+
>
|
|
418
|
+
{month.label}
|
|
419
|
+
</span>
|
|
420
|
+
</div>
|
|
421
|
+
{/each}
|
|
422
|
+
</div>
|
|
423
|
+
<div class="flex h-8">
|
|
424
|
+
{#each days as d (d.day)}
|
|
425
|
+
{@const isToday = d.day === todayDay}
|
|
426
|
+
{@const showNumber = dayWidth >= 18 || d.wd === 1}
|
|
427
|
+
<div
|
|
428
|
+
class={cn(
|
|
429
|
+
"relative flex shrink-0 flex-col items-center justify-center text-[10px] leading-none tabular-nums",
|
|
430
|
+
d.weekend ? "text-muted-foreground/50" : "text-muted-foreground",
|
|
431
|
+
dayWidth < 18 && d.wd === 1 && "border-l border-border/60",
|
|
432
|
+
)}
|
|
433
|
+
style:width="{dayWidth}px"
|
|
434
|
+
>
|
|
435
|
+
{#if dayWidth >= 26}
|
|
436
|
+
<span class="mb-0.5 opacity-70">{weekdayFormat.format(new Date(d.day * 86_400_000))}</span>
|
|
437
|
+
{/if}
|
|
438
|
+
{#if showNumber}
|
|
439
|
+
<span
|
|
440
|
+
class={cn(
|
|
441
|
+
"grid h-4 min-w-4 place-items-center rounded-full px-1 font-medium",
|
|
442
|
+
dayWidth < 18 && "absolute left-1 whitespace-nowrap",
|
|
443
|
+
isToday && "bg-primary text-primary-foreground",
|
|
444
|
+
)}
|
|
445
|
+
>
|
|
446
|
+
{d.date}
|
|
447
|
+
</span>
|
|
448
|
+
{/if}
|
|
449
|
+
</div>
|
|
450
|
+
{/each}
|
|
451
|
+
</div>
|
|
452
|
+
</div>
|
|
453
|
+
</div>
|
|
454
|
+
|
|
455
|
+
<!-- Body. -->
|
|
456
|
+
<div class="relative">
|
|
457
|
+
<!-- The grid layer behind every row: weekends, week lines, the today line and arrows. -->
|
|
458
|
+
<div class="pointer-events-none absolute inset-y-0 left-(--g-label)" style:width="{timelineWidth}px" aria-hidden="true">
|
|
459
|
+
{#each days as d (d.day)}
|
|
460
|
+
{#if d.weekend}
|
|
461
|
+
<div class="absolute inset-y-0 bg-foreground/[0.025]" style:left="{d.i * dayWidth}px" style:width="{dayWidth}px"></div>
|
|
462
|
+
{/if}
|
|
463
|
+
{#if d.wd === 1}
|
|
464
|
+
<div class="absolute inset-y-0 w-px bg-border/70" style:left="{d.i * dayWidth}px"></div>
|
|
465
|
+
{/if}
|
|
466
|
+
{/each}
|
|
467
|
+
{#if todayDay >= range.first && todayDay <= range.last}
|
|
468
|
+
<div class="g-today absolute inset-y-0 w-px bg-primary" style:left="{x(todayDay) + dayWidth / 2}px">
|
|
469
|
+
<span class="absolute -top-1 -left-[3px] size-[7px] rounded-full bg-primary"></span>
|
|
470
|
+
</div>
|
|
471
|
+
{/if}
|
|
472
|
+
<svg class="absolute inset-0 overflow-visible" width={timelineWidth} height={rows.length * rowHeight}>
|
|
473
|
+
{#each arrows as arrow (arrow.key)}
|
|
474
|
+
{@const lit = hoveredId === arrow.from || hoveredId === arrow.to || drag?.id === arrow.from || drag?.id === arrow.to}
|
|
475
|
+
<g class="g-arrow" class:g-arrow-lit={lit}>
|
|
476
|
+
<path d={arrow.d} pathLength="1" fill="none" stroke-width="1.5" class="g-arrow-line"></path>
|
|
477
|
+
<path d={arrow.head} fill="none" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="g-arrow-head"></path>
|
|
478
|
+
</g>
|
|
479
|
+
{/each}
|
|
480
|
+
</svg>
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
<div role="list" aria-label="Timeline">
|
|
484
|
+
{#each rows as row, index (row.kind === "group" ? `g:${row.group.id}` : row.id)}
|
|
485
|
+
{#if row.kind === "group"}
|
|
486
|
+
{@const hue = row.group.hue ?? "var(--color-primary)"}
|
|
487
|
+
<div role="listitem" class="relative flex border-b border-border/60 bg-muted/30" style:height="{rowHeight}px">
|
|
488
|
+
<!-- Opaque card under the row's muted tint, so bars never show through the pinned column. -->
|
|
489
|
+
<div class="sticky left-0 z-10 w-(--g-label) shrink-0 border-r border-border bg-card">
|
|
490
|
+
<div class="flex h-full items-center gap-2 bg-muted/30 px-3">
|
|
491
|
+
<span class="size-2 shrink-0 rounded-full" style:background={hue}></span>
|
|
492
|
+
<span class="truncate text-sm font-medium text-foreground">{row.group.label}</span>
|
|
493
|
+
<span 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">
|
|
494
|
+
{row.count}
|
|
495
|
+
</span>
|
|
496
|
+
</div>
|
|
497
|
+
</div>
|
|
498
|
+
<div class="relative shrink-0" style:width="{timelineWidth}px">
|
|
499
|
+
{#if row.span}
|
|
500
|
+
<div
|
|
501
|
+
class="g-summary absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full"
|
|
502
|
+
style:--g-hue={hue}
|
|
503
|
+
style:--g-i={Math.min(index, STAGGER_CAP)}
|
|
504
|
+
style:left="{x(row.span.start) + inset}px"
|
|
505
|
+
style:width="{Math.max((row.span.end - row.span.start + 1) * dayWidth - inset * 2, 8)}px"
|
|
506
|
+
></div>
|
|
507
|
+
{/if}
|
|
508
|
+
</div>
|
|
509
|
+
</div>
|
|
510
|
+
{:else}
|
|
511
|
+
{@const span = row.span}
|
|
512
|
+
{@const width = span ? Math.max((span.end - span.start + 1) * dayWidth - inset * 2, 8) : 0}
|
|
513
|
+
{@const ctx = { depth: row.depth, hasChildren: row.hasChildren, open: row.open }}
|
|
514
|
+
{@const progress = Math.max(0, Math.min(1, getProgress?.(row.item) ?? 0))}
|
|
515
|
+
{@const title = getTitle(row.item)}
|
|
516
|
+
{@const dragging = drag?.id === row.id}
|
|
517
|
+
<div
|
|
518
|
+
role="listitem"
|
|
519
|
+
class="g-row relative flex border-b border-border/40 last:border-b-0"
|
|
520
|
+
class:g-row-hot={hoveredId === row.id || dragging}
|
|
521
|
+
style:height="{rowHeight}px"
|
|
522
|
+
>
|
|
523
|
+
<div
|
|
524
|
+
class="sticky left-0 z-10 flex w-(--g-label) shrink-0 items-center gap-2 border-r border-border bg-card px-3 text-sm"
|
|
525
|
+
style:padding-inline-start={getParent ? `${row.depth * INDENT + 12}px` : undefined}
|
|
526
|
+
>
|
|
527
|
+
{#if getParent}
|
|
528
|
+
{#if row.hasChildren}
|
|
529
|
+
<button
|
|
530
|
+
type="button"
|
|
531
|
+
aria-expanded={row.open}
|
|
532
|
+
aria-label="{row.open ? 'Fold' : 'Unfold'} {title}"
|
|
533
|
+
onclick={() => toggle(row.id)}
|
|
534
|
+
class="-ml-1 grid size-5 shrink-0 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"
|
|
535
|
+
>
|
|
536
|
+
<span class="g-chevron size-3.5 [&>svg]:size-full" class:g-chevron-open={row.open} aria-hidden="true">
|
|
537
|
+
{@html chevronIcon}
|
|
538
|
+
</span>
|
|
539
|
+
</button>
|
|
540
|
+
{:else}
|
|
541
|
+
<span class="-ml-1 size-5 shrink-0" aria-hidden="true"></span>
|
|
542
|
+
{/if}
|
|
543
|
+
{/if}
|
|
544
|
+
{#if renderLabel}
|
|
545
|
+
{@render renderLabel(row.item, ctx)}
|
|
546
|
+
{:else}
|
|
547
|
+
<span class="truncate text-foreground">{title}</span>
|
|
548
|
+
{/if}
|
|
549
|
+
</div>
|
|
550
|
+
<div class="relative shrink-0" style:width="{timelineWidth}px">
|
|
551
|
+
{#if !span}
|
|
552
|
+
<span
|
|
553
|
+
class="pointer-events-none absolute top-1/2 -translate-y-1/2 text-xs whitespace-nowrap text-muted-foreground/70"
|
|
554
|
+
style:left="{Math.max(x(todayDay) - 40, 0) + 8}px"
|
|
555
|
+
>
|
|
556
|
+
{noDatesLabel}
|
|
557
|
+
</span>
|
|
558
|
+
{:else if isMilestone?.(row.item) && row.own}
|
|
559
|
+
<button
|
|
560
|
+
type="button"
|
|
561
|
+
aria-label="{title}, {rangeLabel(span)}"
|
|
562
|
+
title="{title}, {rangeLabel(span)}"
|
|
563
|
+
class="g-flag absolute top-1/2 grid size-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
564
|
+
style:--g-hue={hueOf(row.item)}
|
|
565
|
+
style:left="{x(span.end) + dayWidth / 2}px"
|
|
566
|
+
onclick={() => onSelect?.(row.id)}
|
|
567
|
+
onpointerenter={() => (hoveredId = row.id)}
|
|
568
|
+
onpointerleave={() => (hoveredId = hoveredId === row.id ? null : hoveredId)}
|
|
569
|
+
>
|
|
570
|
+
<span class="size-5 [&>svg]:size-full" aria-hidden="true">{@html flagIcon}</span>
|
|
571
|
+
</button>
|
|
572
|
+
{:else if !row.own}
|
|
573
|
+
<!-- A parent with no dates of its own: the span of what is below it. Not draggable. -->
|
|
574
|
+
<div
|
|
575
|
+
role="button"
|
|
576
|
+
tabindex="0"
|
|
577
|
+
aria-label="{title}, {rangeLabel(span)}"
|
|
578
|
+
title="{title}, {rangeLabel(span)}"
|
|
579
|
+
class="g-derived absolute top-1/2 -translate-y-1/2 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
|
580
|
+
style:--g-hue={hueOf(row.item)}
|
|
581
|
+
style:--g-i={Math.min(row.order, STAGGER_CAP)}
|
|
582
|
+
style:left="{x(span.start) + inset}px"
|
|
583
|
+
style:width="{width}px"
|
|
584
|
+
style:height="{Math.max(rowHeight - 24, 10)}px"
|
|
585
|
+
onclick={() => onSelect?.(row.id)}
|
|
586
|
+
onkeydown={(e) => (e.key === "Enter" || e.key === " ") && (e.preventDefault(), onSelect?.(row.id))}
|
|
587
|
+
onpointerenter={() => (hoveredId = row.id)}
|
|
588
|
+
onpointerleave={() => (hoveredId = hoveredId === row.id ? null : hoveredId)}
|
|
589
|
+
></div>
|
|
590
|
+
{:else}
|
|
591
|
+
<div
|
|
592
|
+
data-bar
|
|
593
|
+
role="button"
|
|
594
|
+
tabindex="0"
|
|
595
|
+
aria-roledescription="task bar"
|
|
596
|
+
aria-label="{title}, {rangeLabel(span)}.{editable ? ' Arrow keys move it a day, Shift plus arrow keys change its end.' : ''}"
|
|
597
|
+
class={cn(
|
|
598
|
+
"g-bar absolute top-1/2 flex -translate-y-1/2 items-center rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-ring/60",
|
|
599
|
+
editable && "cursor-grab",
|
|
600
|
+
dragging && "g-bar-dragging cursor-grabbing",
|
|
601
|
+
)}
|
|
602
|
+
style:--g-hue={hueOf(row.item)}
|
|
603
|
+
style:--g-i={Math.min(row.order, STAGGER_CAP)}
|
|
604
|
+
style:left="{x(span.start) + inset}px"
|
|
605
|
+
style:width="{width}px"
|
|
606
|
+
style:height="{rowHeight - 14}px"
|
|
607
|
+
onpointerdown={(e) => onPointerDown(e, row.id, "move")}
|
|
608
|
+
onpointermove={onPointerMove}
|
|
609
|
+
onpointerup={() => onPointerUp(row.item, row.id)}
|
|
610
|
+
onpointercancel={() => (drag = null)}
|
|
611
|
+
onpointerenter={() => (hoveredId = row.id)}
|
|
612
|
+
onpointerleave={() => (hoveredId = hoveredId === row.id ? null : hoveredId)}
|
|
613
|
+
onkeydown={(e) => onBarKeyDown(e, row.item, row.id)}
|
|
614
|
+
>
|
|
615
|
+
{#if progress > 0}
|
|
616
|
+
<!-- The clip keeps the bar's rounded corners while the fill scales. -->
|
|
617
|
+
<span class="absolute inset-0 overflow-hidden rounded-lg" aria-hidden="true">
|
|
618
|
+
<span class="g-progress absolute inset-0 origin-left" style:transform="scaleX({progress})"></span>
|
|
619
|
+
</span>
|
|
620
|
+
{/if}
|
|
621
|
+
{#if width >= INLINE_TITLE_MIN}
|
|
622
|
+
<span class="relative truncate px-2.5 text-xs font-medium text-foreground">{title}</span>
|
|
623
|
+
{/if}
|
|
624
|
+
{#if editable}
|
|
625
|
+
<span
|
|
626
|
+
class="g-handle left-0"
|
|
627
|
+
aria-hidden="true"
|
|
628
|
+
onpointerdown={(e) => onPointerDown(e, row.id, "start")}
|
|
629
|
+
></span>
|
|
630
|
+
<span
|
|
631
|
+
class="g-handle right-0"
|
|
632
|
+
aria-hidden="true"
|
|
633
|
+
onpointerdown={(e) => onPointerDown(e, row.id, "end")}
|
|
634
|
+
></span>
|
|
635
|
+
{/if}
|
|
636
|
+
{#if dragging && drag && drag.dx !== 0}
|
|
637
|
+
<span
|
|
638
|
+
class={cn(
|
|
639
|
+
"g-tip pointer-events-none absolute left-0 z-20 rounded-md bg-foreground px-2 py-1 text-[11px] font-medium whitespace-nowrap text-background tabular-nums shadow-md",
|
|
640
|
+
row.order === 0 && !groups ? "top-full mt-1.5" : "bottom-full mb-1.5",
|
|
641
|
+
)}
|
|
642
|
+
>
|
|
643
|
+
{rangeLabel(span)}
|
|
644
|
+
</span>
|
|
645
|
+
{/if}
|
|
646
|
+
</div>
|
|
647
|
+
{#if width < INLINE_TITLE_MIN}
|
|
648
|
+
<span
|
|
649
|
+
class="pointer-events-none absolute top-1/2 -translate-y-1/2 truncate rounded-sm bg-card/90 px-1 text-xs text-muted-foreground"
|
|
650
|
+
style:left="{x(span.end + 1) + 16}px"
|
|
651
|
+
style:max-width="200px"
|
|
652
|
+
>
|
|
653
|
+
{title}
|
|
654
|
+
</span>
|
|
655
|
+
{/if}
|
|
656
|
+
{/if}
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
659
|
+
{/if}
|
|
660
|
+
{/each}
|
|
661
|
+
</div>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
|
|
666
|
+
<style>
|
|
667
|
+
.g-row {
|
|
668
|
+
transition: background-color var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
.g-row-hot {
|
|
672
|
+
background: color-mix(in oklab, var(--color-foreground, currentColor) 3%, transparent);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
.g-bar {
|
|
676
|
+
touch-action: pan-y;
|
|
677
|
+
background: color-mix(in oklab, var(--g-hue) 16%, var(--color-card, white));
|
|
678
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--g-hue) 34%, transparent);
|
|
679
|
+
animation: g-bar-in var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
680
|
+
animation-delay: calc(var(--g-i, 0) * var(--duration-stagger, 40ms));
|
|
681
|
+
/* Snapping to whole days reads as a glide, not a jump. */
|
|
682
|
+
transition:
|
|
683
|
+
left var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
684
|
+
width var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
685
|
+
box-shadow var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
.g-bar:hover,
|
|
689
|
+
.g-bar-dragging {
|
|
690
|
+
box-shadow:
|
|
691
|
+
inset 0 0 0 1px color-mix(in oklab, var(--g-hue) 55%, transparent),
|
|
692
|
+
0 1px 2px rgb(0 0 0 / 0.06),
|
|
693
|
+
0 4px 12px color-mix(in oklab, var(--g-hue) 18%, transparent);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.g-bar-dragging {
|
|
697
|
+
z-index: 15;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
.g-progress {
|
|
701
|
+
background: color-mix(in oklab, var(--g-hue) 32%, var(--color-card, white));
|
|
702
|
+
transition: transform var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/* Resize grips: a wide invisible hit area, a slim pill that shows on hover. */
|
|
706
|
+
.g-handle {
|
|
707
|
+
position: absolute;
|
|
708
|
+
top: 0;
|
|
709
|
+
bottom: 0;
|
|
710
|
+
width: 12px;
|
|
711
|
+
cursor: ew-resize;
|
|
712
|
+
touch-action: none;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
.g-handle::after {
|
|
716
|
+
content: "";
|
|
717
|
+
position: absolute;
|
|
718
|
+
top: 25%;
|
|
719
|
+
bottom: 25%;
|
|
720
|
+
left: 50%;
|
|
721
|
+
width: 3px;
|
|
722
|
+
translate: -50% 0;
|
|
723
|
+
border-radius: 999px;
|
|
724
|
+
background: color-mix(in oklab, var(--g-hue) 70%, var(--color-foreground, black));
|
|
725
|
+
opacity: 0;
|
|
726
|
+
transition: opacity var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
.g-bar:hover .g-handle::after,
|
|
730
|
+
.g-bar:focus-visible .g-handle::after,
|
|
731
|
+
.g-bar-dragging .g-handle::after {
|
|
732
|
+
opacity: 0.6;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.g-chevron {
|
|
736
|
+
transition: rotate var(--duration-fast, 250ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
.g-chevron-open {
|
|
740
|
+
rotate: 90deg;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/* A summary of what is below: an outline, so it never reads as a task you can drag. */
|
|
744
|
+
.g-derived {
|
|
745
|
+
background: color-mix(in oklab, var(--g-hue) 10%, transparent);
|
|
746
|
+
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--g-hue) 45%, transparent);
|
|
747
|
+
animation: g-bar-in var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
748
|
+
animation-delay: calc(var(--g-i, 0) * var(--duration-stagger, 40ms));
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
.g-flag {
|
|
752
|
+
color: var(--g-hue);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
.g-summary {
|
|
756
|
+
background: color-mix(in oklab, var(--g-hue) 45%, transparent);
|
|
757
|
+
animation: g-bar-in var(--duration-slow, 400ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
758
|
+
animation-delay: calc(var(--g-i, 0) * var(--duration-stagger, 40ms));
|
|
759
|
+
transition:
|
|
760
|
+
left var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)),
|
|
761
|
+
width var(--duration-quick, 150ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1));
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
.g-tip {
|
|
765
|
+
animation: g-tip-in var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) both;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
.g-today {
|
|
769
|
+
transform-origin: top;
|
|
770
|
+
animation: g-today-in var(--duration-very-slow, 500ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) backwards;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
.g-arrow {
|
|
774
|
+
stroke: color-mix(in oklab, var(--color-muted-foreground, gray) 55%, transparent);
|
|
775
|
+
transition: stroke var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
.g-arrow-lit {
|
|
779
|
+
stroke: var(--color-foreground, currentColor);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
.g-arrow-line {
|
|
783
|
+
stroke-dasharray: 1;
|
|
784
|
+
stroke-dashoffset: 0;
|
|
785
|
+
animation: g-draw var(--duration-very-slow, 500ms) var(--ease-smooth-out, cubic-bezier(0.22, 1, 0.36, 1)) 300ms backwards;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
.g-arrow-head {
|
|
789
|
+
animation: g-fade var(--duration-quick, 150ms) var(--ease-smooth-out, ease-out) 700ms backwards;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
@keyframes g-bar-in {
|
|
793
|
+
from {
|
|
794
|
+
opacity: 0;
|
|
795
|
+
clip-path: inset(0 100% 0 0 round 8px);
|
|
796
|
+
}
|
|
797
|
+
to {
|
|
798
|
+
opacity: 1;
|
|
799
|
+
clip-path: inset(0 0 0 0 round 8px);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
@keyframes g-tip-in {
|
|
804
|
+
from {
|
|
805
|
+
opacity: 0;
|
|
806
|
+
translate: 0 var(--distance-micro, 4px);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
@keyframes g-today-in {
|
|
811
|
+
from {
|
|
812
|
+
transform: scaleY(0);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
@keyframes g-draw {
|
|
817
|
+
from {
|
|
818
|
+
stroke-dashoffset: 1;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
@keyframes g-fade {
|
|
823
|
+
from {
|
|
824
|
+
opacity: 0;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
@media (prefers-reduced-motion: reduce) {
|
|
829
|
+
.g-bar,
|
|
830
|
+
.g-summary,
|
|
831
|
+
.g-derived,
|
|
832
|
+
.g-tip,
|
|
833
|
+
.g-today,
|
|
834
|
+
.g-arrow-line,
|
|
835
|
+
.g-arrow-head {
|
|
836
|
+
animation: none;
|
|
837
|
+
}
|
|
838
|
+
.g-bar,
|
|
839
|
+
.g-summary,
|
|
840
|
+
.g-progress,
|
|
841
|
+
.g-chevron {
|
|
842
|
+
transition: none;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
</style>
|