@marianmeres/stuic 3.187.0 → 3.189.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/API.md +47 -0
- package/README.md +2 -2
- package/dist/README.md +1 -0
- package/dist/components/ColorPicker/ColorPicker.svelte +26 -1
- package/dist/components/ColorPicker/ColorPicker.svelte.d.ts +9 -0
- package/dist/components/ColorPicker/README.md +19 -7
- package/dist/components/Gantt/Gantt.svelte +451 -0
- package/dist/components/Gantt/Gantt.svelte.d.ts +134 -0
- package/dist/components/Gantt/README.md +298 -0
- package/dist/components/Gantt/gantt-geometry.d.ts +131 -0
- package/dist/components/Gantt/gantt-geometry.js +214 -0
- package/dist/components/Gantt/index.css +496 -0
- package/dist/components/Gantt/index.d.ts +2 -0
- package/dist/components/Gantt/index.js +2 -0
- package/dist/components/Input/FieldColorPicker.svelte +201 -0
- package/dist/components/Input/FieldColorPicker.svelte.d.ts +59 -0
- package/dist/components/Input/README.md +45 -0
- package/dist/components/Input/index.css +21 -9
- package/dist/components/Input/index.d.ts +1 -0
- package/dist/components/Input/index.js +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/utils/validate-fields.d.ts +1 -1
- package/docs/domains/components.md +64 -4
- package/package.json +1 -1
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Gantt's geometry: a date window in, columns and 0..1 track fractions out.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of the component (and out of the browser) on purpose — everything
|
|
5
|
+
* that could be wrong by a day is decided here, in plain functions a node test
|
|
6
|
+
* can pin. The component only multiplies fractions by a width.
|
|
7
|
+
*
|
|
8
|
+
* Whole-day semantics throughout (`YYYY-MM-DD`, both ends inclusive), so a bar
|
|
9
|
+
* never shifts by a day because the visitor is in Auckland. The day arithmetic
|
|
10
|
+
* reuses `Calendar/iso-date.js`, which is DST-safe via `@marianmeres/calendar-utils`.
|
|
11
|
+
*/
|
|
12
|
+
import { addDaysIso, addMonths, compareIso, daysBetweenIso, daysInMonth, firstOfMonth, formatYearMonth, getMonthNames, normalizeIsoDate, toIsoDate, weekdayColumn, yearMonthKey, yearMonthOf, } from "../Calendar/iso-date.js";
|
|
13
|
+
/** Cap on generated columns — a mistyped year would otherwise build 700k of them. */
|
|
14
|
+
export const GANTT_MAX_COLUMNS = 2000;
|
|
15
|
+
/** The 7 localized weekday short names, Monday first. 2024-01-01 was a Monday. */
|
|
16
|
+
function getWeekdayNames(locale, format = "short") {
|
|
17
|
+
const f = new Intl.DateTimeFormat(locale, { weekday: format });
|
|
18
|
+
return Array.from({ length: 7 }, (_, i) =>
|
|
19
|
+
// noon, so no zone can pull the formatter back into the previous day
|
|
20
|
+
f.format(new Date(2024, 0, 1 + i, 12)));
|
|
21
|
+
}
|
|
22
|
+
/** ISO-8601 week number of `iso` (weeks start Monday; week 1 holds the first Thursday). */
|
|
23
|
+
export function isoWeekNumber(iso) {
|
|
24
|
+
// Thursday of this ISO week decides which year (and therefore which week 1) the
|
|
25
|
+
// week belongs to — the standard trick, done on plain day arithmetic.
|
|
26
|
+
const thursday = addDaysIso(iso, 3 - weekdayColumn(iso, 1));
|
|
27
|
+
const year = Number(thursday.slice(0, 4));
|
|
28
|
+
const jan4 = toIsoDate(year, 1, 4);
|
|
29
|
+
const week1Monday = addDaysIso(jan4, -weekdayColumn(jan4, 1));
|
|
30
|
+
return Math.floor(daysBetweenIso(week1Monday, thursday) / 7) + 1;
|
|
31
|
+
}
|
|
32
|
+
/** First day of the unit `iso` falls in. */
|
|
33
|
+
function unitStart(iso, unit, weekStartsOn) {
|
|
34
|
+
if (unit === "week")
|
|
35
|
+
return addDaysIso(iso, -weekdayColumn(iso, weekStartsOn));
|
|
36
|
+
if (unit === "month")
|
|
37
|
+
return firstOfMonth(yearMonthOf(iso));
|
|
38
|
+
return iso;
|
|
39
|
+
}
|
|
40
|
+
/** Last day of the unit `iso` falls in. */
|
|
41
|
+
function unitEnd(iso, unit, weekStartsOn) {
|
|
42
|
+
if (unit === "week")
|
|
43
|
+
return addDaysIso(unitStart(iso, unit, weekStartsOn), 6);
|
|
44
|
+
if (unit === "month") {
|
|
45
|
+
const ym = yearMonthOf(iso);
|
|
46
|
+
return toIsoDate(ym.year, ym.month, daysInMonth(ym.year, ym.month));
|
|
47
|
+
}
|
|
48
|
+
return iso;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The columns and the two header rows for a `[from, to]` window (both inclusive).
|
|
52
|
+
*
|
|
53
|
+
* Throws on an unparseable window, and on one so wide it would exceed
|
|
54
|
+
* {@link GANTT_MAX_COLUMNS} columns — at that zoom nothing is readable anyway,
|
|
55
|
+
* and silently drawing 100k nodes is worse than saying so.
|
|
56
|
+
*/
|
|
57
|
+
export function buildGanttAxis(from, to, options = {}) {
|
|
58
|
+
const { unit = "day", weekStartsOn = 1, locale, today = null } = options;
|
|
59
|
+
const isoFrom = normalizeIsoDate(from);
|
|
60
|
+
const isoTo = normalizeIsoDate(to);
|
|
61
|
+
if (!isoFrom)
|
|
62
|
+
throw new Error(`Gantt: invalid "from" date: ${String(from)}`);
|
|
63
|
+
if (!isoTo)
|
|
64
|
+
throw new Error(`Gantt: invalid "to" date: ${String(to)}`);
|
|
65
|
+
// A backwards window is a caller bug, not a reason to render nothing silently.
|
|
66
|
+
if (compareIso(isoFrom, isoTo) > 0) {
|
|
67
|
+
throw new Error(`Gantt: "from" (${isoFrom}) is after "to" (${isoTo})`);
|
|
68
|
+
}
|
|
69
|
+
const start = unitStart(isoFrom, unit, weekStartsOn);
|
|
70
|
+
const end = unitEnd(isoTo, unit, weekStartsOn);
|
|
71
|
+
const totalDays = daysBetweenIso(start, end) + 1;
|
|
72
|
+
const months = getMonthNames(locale, "short");
|
|
73
|
+
const weekdays = unit === "day" ? getWeekdayNames(locale) : [];
|
|
74
|
+
const columns = [];
|
|
75
|
+
const groups = [];
|
|
76
|
+
const pushColumn = (date, days, groupKey, label, subLabel) => {
|
|
77
|
+
const last = groups[groups.length - 1];
|
|
78
|
+
if (last?.key === groupKey)
|
|
79
|
+
last.span += 1;
|
|
80
|
+
else
|
|
81
|
+
groups.push({ key: groupKey, label: "", span: 1, from: columns.length });
|
|
82
|
+
columns.push({
|
|
83
|
+
date,
|
|
84
|
+
days,
|
|
85
|
+
offset: daysBetweenIso(start, date),
|
|
86
|
+
index: columns.length,
|
|
87
|
+
label,
|
|
88
|
+
subLabel,
|
|
89
|
+
groupIndex: groups.length - 1,
|
|
90
|
+
isWeekend: unit === "day" && weekdayColumn(date, 1) >= 5,
|
|
91
|
+
isToday: !!today && compareIso(today, date) >= 0 && daysBetweenIso(date, today) < days,
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
const estimated = unit === "day" ? totalDays : unit === "week" ? totalDays / 7 : totalDays / 28;
|
|
95
|
+
if (estimated > GANTT_MAX_COLUMNS) {
|
|
96
|
+
throw new Error(`Gantt: ${Math.round(estimated)} "${unit}" columns for ${isoFrom}…${isoTo} ` +
|
|
97
|
+
`exceeds the ${GANTT_MAX_COLUMNS} limit — narrow the window or use a coarser unit.`);
|
|
98
|
+
}
|
|
99
|
+
if (unit === "month") {
|
|
100
|
+
let ym = yearMonthOf(start);
|
|
101
|
+
const endYm = yearMonthOf(end);
|
|
102
|
+
while (ym.year * 12 + ym.month <= endYm.year * 12 + endYm.month) {
|
|
103
|
+
pushColumn(firstOfMonth(ym), daysInMonth(ym.year, ym.month), `${ym.year}`, months[ym.month - 1]);
|
|
104
|
+
ym = addMonths(ym, 1);
|
|
105
|
+
}
|
|
106
|
+
for (const g of groups)
|
|
107
|
+
g.label = g.key;
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const step = unit === "week" ? 7 : 1;
|
|
111
|
+
for (let date = start; compareIso(date, end) <= 0; date = addDaysIso(date, step)) {
|
|
112
|
+
const ym = yearMonthOf(date);
|
|
113
|
+
pushColumn(date, step, yearMonthKey(ym), `${Number(date.slice(8, 10))}`, unit === "day" ? weekdays[weekdayColumn(date, 1)] : `W${isoWeekNumber(date)}`);
|
|
114
|
+
}
|
|
115
|
+
for (const g of groups) {
|
|
116
|
+
const [y, m] = g.key.split("-").map(Number);
|
|
117
|
+
g.label = formatYearMonth({ year: y, month: m }, locale, {
|
|
118
|
+
month: "short",
|
|
119
|
+
year: "numeric",
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return { unit, from: isoFrom, to: isoTo, start, end, columns, groups, totalDays };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Where a day offset sits on the track, 0..1.
|
|
127
|
+
*
|
|
128
|
+
* Columns all render at the same width but do not all cover the same number of
|
|
129
|
+
* days (February against March), so this maps through the column the offset
|
|
130
|
+
* falls in rather than dividing by `totalDays`. Fractional offsets are allowed
|
|
131
|
+
* — `offset + 0.5` is the middle of that day.
|
|
132
|
+
*/
|
|
133
|
+
export function dayToFraction(offset, axis) {
|
|
134
|
+
const n = axis.columns.length;
|
|
135
|
+
if (!n)
|
|
136
|
+
return 0;
|
|
137
|
+
if (offset <= 0)
|
|
138
|
+
return 0;
|
|
139
|
+
if (offset >= axis.totalDays)
|
|
140
|
+
return 1;
|
|
141
|
+
// binary search for the column containing `offset`
|
|
142
|
+
let lo = 0;
|
|
143
|
+
let hi = n - 1;
|
|
144
|
+
while (lo < hi) {
|
|
145
|
+
const mid = (lo + hi + 1) >> 1;
|
|
146
|
+
if (axis.columns[mid].offset <= offset)
|
|
147
|
+
lo = mid;
|
|
148
|
+
else
|
|
149
|
+
hi = mid - 1;
|
|
150
|
+
}
|
|
151
|
+
const col = axis.columns[lo];
|
|
152
|
+
return (lo + (offset - col.offset) / col.days) / n;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A `[from, to]` inclusive range as a track placement, or `null` when it misses
|
|
156
|
+
* the axis entirely.
|
|
157
|
+
*
|
|
158
|
+
* A miss is `null` rather than a zero-width bar on purpose: a hairline pinned to
|
|
159
|
+
* the window's edge reads as "starts today", which is exactly the wrong thing to
|
|
160
|
+
* tell a planner.
|
|
161
|
+
*/
|
|
162
|
+
export function placeRange(from, to, axis) {
|
|
163
|
+
const a = normalizeIsoDate(from);
|
|
164
|
+
const b = normalizeIsoDate(to);
|
|
165
|
+
if (!a || !b)
|
|
166
|
+
return null;
|
|
167
|
+
const [lo, hi] = compareIso(a, b) <= 0 ? [a, b] : [b, a];
|
|
168
|
+
if (compareIso(hi, axis.start) < 0 || compareIso(lo, axis.end) > 0)
|
|
169
|
+
return null;
|
|
170
|
+
const clippedStart = compareIso(lo, axis.start) < 0;
|
|
171
|
+
const clippedEnd = compareIso(hi, axis.end) > 0;
|
|
172
|
+
const visFrom = clippedStart ? axis.start : lo;
|
|
173
|
+
const visTo = clippedEnd ? axis.end : hi;
|
|
174
|
+
const startDay = daysBetweenIso(axis.start, visFrom);
|
|
175
|
+
const endDay = daysBetweenIso(axis.start, visTo) + 1; // exclusive
|
|
176
|
+
return {
|
|
177
|
+
start: dayToFraction(startDay, axis),
|
|
178
|
+
end: dayToFraction(endDay, axis),
|
|
179
|
+
clippedStart,
|
|
180
|
+
clippedEnd,
|
|
181
|
+
days: endDay - startDay,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The middle of a single day as a 0..1 fraction (milestones, the today line), or
|
|
186
|
+
* `null` when the day is outside the axis.
|
|
187
|
+
*/
|
|
188
|
+
export function placePoint(date, axis) {
|
|
189
|
+
const iso = normalizeIsoDate(date);
|
|
190
|
+
if (!iso)
|
|
191
|
+
return null;
|
|
192
|
+
if (compareIso(iso, axis.start) < 0 || compareIso(iso, axis.end) > 0)
|
|
193
|
+
return null;
|
|
194
|
+
return dayToFraction(daysBetweenIso(axis.start, iso) + 0.5, axis);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* The `[min, max]` of every date in `ranges` — what the axis defaults to when
|
|
198
|
+
* the consumer gives no explicit window. `null` when nothing is datable.
|
|
199
|
+
*/
|
|
200
|
+
export function boundsOf(ranges) {
|
|
201
|
+
let min = null;
|
|
202
|
+
let max = null;
|
|
203
|
+
for (const r of ranges) {
|
|
204
|
+
for (const v of [normalizeIsoDate(r.from), normalizeIsoDate(r.to ?? r.from)]) {
|
|
205
|
+
if (!v)
|
|
206
|
+
continue;
|
|
207
|
+
if (!min || compareIso(v, min) < 0)
|
|
208
|
+
min = v;
|
|
209
|
+
if (!max || compareIso(v, max) > 0)
|
|
210
|
+
max = v;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return min && max ? { from: min, to: max } : null;
|
|
214
|
+
}
|
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
/* ============================================================================
|
|
2
|
+
GANTT COMPONENT TOKENS
|
|
3
|
+
Override globally: :root { --stuic-gantt-unit-width: 3rem; }
|
|
4
|
+
Override locally: <Gantt style="--stuic-gantt-row-height: 3rem;" />
|
|
5
|
+
|
|
6
|
+
Structural tokens (radius, border-width, transition) are NOT declared here —
|
|
7
|
+
they are resolved as var() fallbacks at the usage sites below, so a scoped
|
|
8
|
+
--stuic-radius override still reaches them.
|
|
9
|
+
============================================================================ */
|
|
10
|
+
|
|
11
|
+
/* prettier-ignore */
|
|
12
|
+
:root {
|
|
13
|
+
/* Layout */
|
|
14
|
+
--stuic-gantt-unit-width: 2.5rem; /* one column — the horizontal zoom */
|
|
15
|
+
--stuic-gantt-label-width: 12rem; /* the sticky left column */
|
|
16
|
+
--stuic-gantt-row-height: 2.5rem;
|
|
17
|
+
--stuic-gantt-max-height: none; /* set it to make the header sticky */
|
|
18
|
+
|
|
19
|
+
/* Frame */
|
|
20
|
+
--stuic-gantt-bg: var(--stuic-color-background);
|
|
21
|
+
--stuic-gantt-border-color: var(--stuic-color-border);
|
|
22
|
+
--stuic-gantt-font-size: var(--text-base);
|
|
23
|
+
|
|
24
|
+
/* Header (both tiers) */
|
|
25
|
+
--stuic-gantt-header-bg: var(--stuic-color-surface);
|
|
26
|
+
--stuic-gantt-header-text: var(--stuic-color-foreground);
|
|
27
|
+
--stuic-gantt-header-font-size: var(--text-xs);
|
|
28
|
+
--stuic-gantt-header-padding: 0.25rem 0.375rem;
|
|
29
|
+
--stuic-gantt-header-sub-text: var(--stuic-color-muted-foreground);
|
|
30
|
+
|
|
31
|
+
/* Grid */
|
|
32
|
+
--stuic-gantt-grid-line-color: var(--stuic-color-border);
|
|
33
|
+
--stuic-gantt-weekend-bg: var(--stuic-color-muted);
|
|
34
|
+
--stuic-gantt-today-color: var(--stuic-color-destructive);
|
|
35
|
+
--stuic-gantt-today-width: 2px;
|
|
36
|
+
|
|
37
|
+
/* Row + left label column */
|
|
38
|
+
--stuic-gantt-row-bg-hover: color-mix(in srgb, var(--stuic-color-muted) 45%, transparent);
|
|
39
|
+
--stuic-gantt-label-bg: var(--stuic-color-background);
|
|
40
|
+
--stuic-gantt-label-padding: 0.375rem 0.75rem;
|
|
41
|
+
--stuic-gantt-label-font-size: var(--text-base);
|
|
42
|
+
--stuic-gantt-label-text: var(--stuic-color-foreground);
|
|
43
|
+
--stuic-gantt-description-font-size: var(--text-xs);
|
|
44
|
+
--stuic-gantt-description-text: var(--stuic-color-muted-foreground);
|
|
45
|
+
|
|
46
|
+
/* Bars */
|
|
47
|
+
--stuic-gantt-bar-height: 1.25rem;
|
|
48
|
+
--stuic-gantt-bar-height-inset: 0.625rem; /* data-inset — the emphasis range */
|
|
49
|
+
--stuic-gantt-bar-bg: var(--stuic-color-muted-foreground);
|
|
50
|
+
--stuic-gantt-bar-text: var(--stuic-color-background);
|
|
51
|
+
--stuic-gantt-bar-font-size: var(--text-xs);
|
|
52
|
+
--stuic-gantt-bar-padding-inline: 0.375rem;
|
|
53
|
+
--stuic-gantt-bar-opacity-disabled: 0.45;
|
|
54
|
+
--stuic-gantt-milestone-size: 0.875rem;
|
|
55
|
+
|
|
56
|
+
/* Empty state area */
|
|
57
|
+
--stuic-gantt-empty-padding: 2rem 1rem;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@layer components {
|
|
61
|
+
/* ============================================================================
|
|
62
|
+
FRAME
|
|
63
|
+
One scroll container; everything inside is laid out in two flex columns —
|
|
64
|
+
the sticky label column (width 0 when there are no labels) and the track.
|
|
65
|
+
============================================================================ */
|
|
66
|
+
|
|
67
|
+
.stuic-gantt {
|
|
68
|
+
--_label-w: 0px;
|
|
69
|
+
font-size: var(--stuic-gantt-font-size);
|
|
70
|
+
color: var(--stuic-color-foreground);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.stuic-gantt[data-labels] {
|
|
74
|
+
--_label-w: var(--stuic-gantt-label-width);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Deliberately NOT `width: max-content`: besides leaving the frame narrower
|
|
78
|
+
than its container, a max-content scroll box that gains a vertical scrollbar
|
|
79
|
+
loses that width from its content area and starts scrolling horizontally for
|
|
80
|
+
no reason. The columns absorb the spare width instead (see below). */
|
|
81
|
+
.stuic-gantt-viewport {
|
|
82
|
+
overflow: auto;
|
|
83
|
+
max-height: var(--stuic-gantt-max-height);
|
|
84
|
+
background: var(--stuic-gantt-bg);
|
|
85
|
+
border: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
86
|
+
var(--stuic-gantt-border-color);
|
|
87
|
+
border-radius: var(--stuic-gantt-radius, var(--stuic-radius-container));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/* `max-content` so the px-sized track can overflow into the scroll; `min-width`
|
|
91
|
+
so a short axis still fills the frame (and the sticky cells have a box). */
|
|
92
|
+
.stuic-gantt-inner {
|
|
93
|
+
width: max-content;
|
|
94
|
+
min-width: 100%;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.stuic-gantt[data-fit] .stuic-gantt-inner {
|
|
98
|
+
width: 100%;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ============================================================================
|
|
102
|
+
HEADER — two tiers: groups (month / year) over units (day / week / month)
|
|
103
|
+
============================================================================ */
|
|
104
|
+
|
|
105
|
+
.stuic-gantt-head {
|
|
106
|
+
display: flex;
|
|
107
|
+
position: sticky;
|
|
108
|
+
top: 0;
|
|
109
|
+
z-index: 3;
|
|
110
|
+
background: var(--stuic-gantt-header-bg);
|
|
111
|
+
color: var(--stuic-gantt-header-text);
|
|
112
|
+
font-size: var(--stuic-gantt-header-font-size);
|
|
113
|
+
border-bottom: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
114
|
+
var(--stuic-gantt-border-color);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
.stuic-gantt-corner {
|
|
118
|
+
flex: 0 0 var(--_label-w);
|
|
119
|
+
box-sizing: border-box;
|
|
120
|
+
position: sticky;
|
|
121
|
+
left: 0;
|
|
122
|
+
z-index: 1;
|
|
123
|
+
display: flex;
|
|
124
|
+
align-items: flex-end;
|
|
125
|
+
padding: var(--stuic-gantt-header-padding);
|
|
126
|
+
background: var(--stuic-gantt-header-bg);
|
|
127
|
+
border-right: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
128
|
+
var(--stuic-gantt-border-color);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* `1 0 <basis>` everywhere below: the column width is a MINIMUM. When the axis
|
|
132
|
+
is narrower than the frame the columns share out what is left (no dead gutter,
|
|
133
|
+
and the grid layer still matches the track because both grow by the same
|
|
134
|
+
rule); when it is wider they keep their basis and the frame scrolls. */
|
|
135
|
+
.stuic-gantt-axis,
|
|
136
|
+
.stuic-gantt-track {
|
|
137
|
+
flex: 1 0 calc(var(--stuic-gantt-unit-width) * var(--_gantt-columns));
|
|
138
|
+
min-width: 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.stuic-gantt[data-fit] .stuic-gantt-axis,
|
|
142
|
+
.stuic-gantt[data-fit] .stuic-gantt-track {
|
|
143
|
+
flex: 1 1 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.stuic-gantt-groups,
|
|
147
|
+
.stuic-gantt-units {
|
|
148
|
+
display: flex;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.stuic-gantt-group {
|
|
152
|
+
flex-grow: var(--_gantt-span);
|
|
153
|
+
flex-shrink: 0;
|
|
154
|
+
flex-basis: calc(var(--stuic-gantt-unit-width) * var(--_gantt-span));
|
|
155
|
+
min-width: 0;
|
|
156
|
+
box-sizing: border-box;
|
|
157
|
+
padding: var(--stuic-gantt-header-padding);
|
|
158
|
+
border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
159
|
+
var(--stuic-gantt-grid-line-color);
|
|
160
|
+
font-weight: var(--font-weight-medium);
|
|
161
|
+
white-space: nowrap;
|
|
162
|
+
overflow: hidden;
|
|
163
|
+
text-overflow: ellipsis;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.stuic-gantt[data-fit] .stuic-gantt-group {
|
|
167
|
+
flex-shrink: 1;
|
|
168
|
+
flex-basis: 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.stuic-gantt-unit {
|
|
172
|
+
flex: 1 0 var(--stuic-gantt-unit-width);
|
|
173
|
+
min-width: 0;
|
|
174
|
+
box-sizing: border-box;
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: column;
|
|
177
|
+
align-items: center;
|
|
178
|
+
justify-content: flex-end;
|
|
179
|
+
padding: var(--stuic-gantt-header-padding);
|
|
180
|
+
border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
181
|
+
var(--stuic-gantt-grid-line-color);
|
|
182
|
+
line-height: 1.2;
|
|
183
|
+
overflow: hidden;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.stuic-gantt[data-fit] .stuic-gantt-unit {
|
|
187
|
+
flex: 1 1 0;
|
|
188
|
+
/* border-box padding does not shrink, so it is a hard floor on how narrow a
|
|
189
|
+
column can get — which would break `fit`'s "never scrolls" promise at high
|
|
190
|
+
column counts. The label is centered, so dropping it costs nothing. */
|
|
191
|
+
padding-inline: 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.stuic-gantt-group:first-child,
|
|
195
|
+
.stuic-gantt-unit:first-child {
|
|
196
|
+
border-left-color: transparent;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.stuic-gantt-unit[data-weekend] {
|
|
200
|
+
background: var(--stuic-gantt-weekend-bg);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.stuic-gantt-unit[data-today] {
|
|
204
|
+
color: var(--stuic-gantt-today-color);
|
|
205
|
+
font-weight: var(--font-weight-bold);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.stuic-gantt-unit-sub {
|
|
209
|
+
color: var(--stuic-gantt-header-sub-text);
|
|
210
|
+
font-size: 0.85em;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.stuic-gantt-unit[data-today] .stuic-gantt-unit-sub {
|
|
214
|
+
color: inherit;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/* ============================================================================
|
|
218
|
+
BODY + the single grid layer
|
|
219
|
+
Weekend shading and the gridlines are drawn once for the whole body rather
|
|
220
|
+
than per row — otherwise it is rows × columns nodes for the same pixels.
|
|
221
|
+
The rows come later in the DOM and are positioned, so they paint on top.
|
|
222
|
+
============================================================================ */
|
|
223
|
+
|
|
224
|
+
.stuic-gantt-body {
|
|
225
|
+
position: relative;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.stuic-gantt-grid {
|
|
229
|
+
position: absolute;
|
|
230
|
+
inset: 0 0 0 var(--_label-w);
|
|
231
|
+
display: flex;
|
|
232
|
+
pointer-events: none;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.stuic-gantt-gridline {
|
|
236
|
+
flex: 1 0 var(--stuic-gantt-unit-width);
|
|
237
|
+
border-left: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
238
|
+
var(--stuic-gantt-grid-line-color);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.stuic-gantt[data-fit] .stuic-gantt-gridline {
|
|
242
|
+
flex: 1 1 0;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.stuic-gantt-gridline:first-child {
|
|
246
|
+
border-left-color: transparent;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.stuic-gantt-gridline[data-weekend] {
|
|
250
|
+
background: var(--stuic-gantt-weekend-bg);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.stuic-gantt-today {
|
|
254
|
+
position: absolute;
|
|
255
|
+
top: 0;
|
|
256
|
+
bottom: 0;
|
|
257
|
+
width: var(--stuic-gantt-today-width);
|
|
258
|
+
translate: -50% 0;
|
|
259
|
+
background: var(--stuic-gantt-today-color);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/* ============================================================================
|
|
263
|
+
ROWS
|
|
264
|
+
============================================================================ */
|
|
265
|
+
|
|
266
|
+
.stuic-gantt-row {
|
|
267
|
+
position: relative;
|
|
268
|
+
display: flex;
|
|
269
|
+
min-height: var(--stuic-gantt-row-height);
|
|
270
|
+
border-bottom: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
271
|
+
var(--stuic-gantt-border-color);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.stuic-gantt-row:last-child {
|
|
275
|
+
border-bottom-color: transparent;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/* translucent on purpose — the weekend shading must stay visible under it */
|
|
279
|
+
.stuic-gantt-row:hover {
|
|
280
|
+
background: var(--stuic-gantt-row-bg-hover);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.stuic-gantt-row-label {
|
|
284
|
+
flex: 0 0 var(--_label-w);
|
|
285
|
+
box-sizing: border-box;
|
|
286
|
+
position: sticky;
|
|
287
|
+
left: 0;
|
|
288
|
+
z-index: 2;
|
|
289
|
+
display: flex;
|
|
290
|
+
flex-direction: column;
|
|
291
|
+
justify-content: center;
|
|
292
|
+
gap: 0.0625rem;
|
|
293
|
+
padding: var(--stuic-gantt-label-padding);
|
|
294
|
+
background: var(--stuic-gantt-label-bg);
|
|
295
|
+
border-right: var(--stuic-gantt-border-width, var(--stuic-border-width)) solid
|
|
296
|
+
var(--stuic-gantt-border-color);
|
|
297
|
+
overflow: hidden;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.stuic-gantt-row-title {
|
|
301
|
+
font-size: var(--stuic-gantt-label-font-size);
|
|
302
|
+
color: var(--stuic-gantt-label-text);
|
|
303
|
+
white-space: nowrap;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
text-overflow: ellipsis;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.stuic-gantt-row-link {
|
|
309
|
+
color: inherit;
|
|
310
|
+
text-decoration: none;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.stuic-gantt-row-link:hover {
|
|
314
|
+
text-decoration: underline;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.stuic-gantt-row-description {
|
|
318
|
+
font-size: var(--stuic-gantt-description-font-size);
|
|
319
|
+
color: var(--stuic-gantt-description-text);
|
|
320
|
+
white-space: nowrap;
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
text-overflow: ellipsis;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/* `clip` rather than `hidden`: a bar label positioned past the bar (barLabels
|
|
326
|
+
"after", and every milestone label) would otherwise be scrollable overflow and
|
|
327
|
+
put a horizontal scrollbar on a chart that fits. No `overflow-clip-margin` —
|
|
328
|
+
content in that margin still counts toward an ancestor's scrollable overflow,
|
|
329
|
+
which is the very scrollbar this is here to prevent. */
|
|
330
|
+
.stuic-gantt-track {
|
|
331
|
+
position: relative;
|
|
332
|
+
overflow: clip;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/* ============================================================================
|
|
336
|
+
BARS
|
|
337
|
+
============================================================================ */
|
|
338
|
+
|
|
339
|
+
.stuic-gantt-bar {
|
|
340
|
+
position: absolute;
|
|
341
|
+
top: 50%;
|
|
342
|
+
translate: 0 -50%;
|
|
343
|
+
height: var(--stuic-gantt-bar-height);
|
|
344
|
+
box-sizing: border-box;
|
|
345
|
+
display: flex;
|
|
346
|
+
align-items: center;
|
|
347
|
+
margin: 0;
|
|
348
|
+
padding: 0;
|
|
349
|
+
border: 0;
|
|
350
|
+
font: inherit;
|
|
351
|
+
font-size: var(--stuic-gantt-bar-font-size);
|
|
352
|
+
text-align: start;
|
|
353
|
+
text-decoration: none;
|
|
354
|
+
background: var(--stuic-gantt-bar-bg);
|
|
355
|
+
color: var(--stuic-gantt-bar-text);
|
|
356
|
+
border-radius: var(--stuic-gantt-bar-radius, var(--stuic-radius));
|
|
357
|
+
overflow: hidden;
|
|
358
|
+
transition: box-shadow var(--stuic-gantt-transition, var(--stuic-transition)) ease;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/* the emphasis range drawn inside a wider bar of the same row */
|
|
362
|
+
.stuic-gantt-bar[data-inset] {
|
|
363
|
+
height: var(--stuic-gantt-bar-height-inset);
|
|
364
|
+
z-index: 1;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
.stuic-gantt-bar[data-clipped-start] {
|
|
368
|
+
border-start-start-radius: 0;
|
|
369
|
+
border-end-start-radius: 0;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
.stuic-gantt-bar[data-clipped-end] {
|
|
373
|
+
border-start-end-radius: 0;
|
|
374
|
+
border-end-end-radius: 0;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
.stuic-gantt-bar:is(a, button) {
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.stuic-gantt-bar:is(a, button):hover {
|
|
382
|
+
box-shadow: 0 0 0 2px color-mix(in srgb, var(--stuic-gantt-bar-bg) 40%, transparent);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.stuic-gantt-bar:focus-visible {
|
|
386
|
+
outline: 2px solid var(--stuic-color-ring);
|
|
387
|
+
outline-offset: 1px;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.stuic-gantt-bar[data-disabled] {
|
|
391
|
+
opacity: var(--stuic-gantt-bar-opacity-disabled);
|
|
392
|
+
cursor: default;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.stuic-gantt-bar-progress {
|
|
396
|
+
position: absolute;
|
|
397
|
+
inset: 0 auto 0 0;
|
|
398
|
+
border-radius: inherit;
|
|
399
|
+
/* darken, rather than mix toward the foreground: mixing follows the color
|
|
400
|
+
scheme, so in dark mode the fill turned LIGHTER than the bar and the
|
|
401
|
+
label (sized for the bar's own foreground) lost its contrast. */
|
|
402
|
+
background: var(
|
|
403
|
+
--stuic-gantt-bar-progress-bg,
|
|
404
|
+
color-mix(in oklab, var(--stuic-gantt-bar-bg) 80%, black)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.stuic-gantt-bar-label {
|
|
409
|
+
position: relative; /* above the progress fill */
|
|
410
|
+
min-width: 0;
|
|
411
|
+
padding-inline: var(--stuic-gantt-bar-padding-inline);
|
|
412
|
+
white-space: nowrap;
|
|
413
|
+
overflow: hidden;
|
|
414
|
+
text-overflow: ellipsis;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/* `after`: the label escapes the bar to its right, in body (not bar) color */
|
|
418
|
+
.stuic-gantt[data-bar-labels="after"] .stuic-gantt-bar {
|
|
419
|
+
overflow: visible;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.stuic-gantt[data-bar-labels="after"] .stuic-gantt-bar-label,
|
|
423
|
+
.stuic-gantt-bar[data-kind="milestone"] .stuic-gantt-bar-label {
|
|
424
|
+
position: absolute;
|
|
425
|
+
left: 100%;
|
|
426
|
+
top: 50%;
|
|
427
|
+
translate: 0 -50%;
|
|
428
|
+
margin-left: 0.375rem;
|
|
429
|
+
padding-inline: 0;
|
|
430
|
+
overflow: visible;
|
|
431
|
+
color: var(--stuic-gantt-label-text);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ============================================================================
|
|
435
|
+
MILESTONE — a fixed box centered on its day; the diamond is the pseudo
|
|
436
|
+
element, so the label (and any renderBar content) is never rotated with it.
|
|
437
|
+
============================================================================ */
|
|
438
|
+
|
|
439
|
+
.stuic-gantt-bar[data-kind="milestone"] {
|
|
440
|
+
width: var(--stuic-gantt-milestone-size);
|
|
441
|
+
height: var(--stuic-gantt-milestone-size);
|
|
442
|
+
translate: -50% -50%;
|
|
443
|
+
background: transparent;
|
|
444
|
+
border-radius: 0;
|
|
445
|
+
overflow: visible;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.stuic-gantt-bar[data-kind="milestone"]::before {
|
|
449
|
+
content: "";
|
|
450
|
+
position: absolute;
|
|
451
|
+
inset: 0;
|
|
452
|
+
rotate: 45deg;
|
|
453
|
+
background: var(--stuic-gantt-bar-bg);
|
|
454
|
+
border-radius: var(--stuic-gantt-milestone-radius, 2px);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ============================================================================
|
|
458
|
+
INTENT — the bar's own two tokens, so progress and the milestone follow
|
|
459
|
+
============================================================================ */
|
|
460
|
+
|
|
461
|
+
.stuic-gantt-bar[data-intent="primary"] {
|
|
462
|
+
--stuic-gantt-bar-bg: var(--stuic-color-primary);
|
|
463
|
+
--stuic-gantt-bar-text: var(--stuic-color-primary-foreground);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
.stuic-gantt-bar[data-intent="accent"] {
|
|
467
|
+
--stuic-gantt-bar-bg: var(--stuic-color-accent);
|
|
468
|
+
--stuic-gantt-bar-text: var(--stuic-color-accent-foreground);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.stuic-gantt-bar[data-intent="success"] {
|
|
472
|
+
--stuic-gantt-bar-bg: var(--stuic-color-success);
|
|
473
|
+
--stuic-gantt-bar-text: var(--stuic-color-success-foreground);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
.stuic-gantt-bar[data-intent="warning"] {
|
|
477
|
+
--stuic-gantt-bar-bg: var(--stuic-color-warning);
|
|
478
|
+
--stuic-gantt-bar-text: var(--stuic-color-warning-foreground);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
.stuic-gantt-bar[data-intent="destructive"] {
|
|
482
|
+
--stuic-gantt-bar-bg: var(--stuic-color-destructive);
|
|
483
|
+
--stuic-gantt-bar-text: var(--stuic-color-destructive-foreground);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/* ============================================================================
|
|
487
|
+
EMPTY
|
|
488
|
+
============================================================================ */
|
|
489
|
+
|
|
490
|
+
/* positioned for the same reason the rows are: the grid layer is an absolute
|
|
491
|
+
element earlier in the DOM, so in-flow content would paint underneath it */
|
|
492
|
+
.stuic-gantt-empty {
|
|
493
|
+
position: relative;
|
|
494
|
+
padding: var(--stuic-gantt-empty-padding);
|
|
495
|
+
}
|
|
496
|
+
}
|