@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 CHANGED
@@ -467,6 +467,10 @@ File upload input.
467
467
 
468
468
  Form-wrapped switch toggle.
469
469
 
470
+ #### `FieldColorPicker`
471
+
472
+ Form-wrapped `ColorPicker`: visible label (names the swatch group via `aria-labelledby`), description, validation box, label-left layout.
473
+
470
474
  #### `FieldOptions`
471
475
 
472
476
  Multi-select options field.
@@ -1226,6 +1230,49 @@ Responsive data table with paging, row selection, batch actions, and mobile card
1226
1230
  />
1227
1231
  ```
1228
1232
 
1233
+ #### `Gantt`
1234
+
1235
+ Horizontal schedule chart — project plans (task per row, progress, milestones) and resource lanes (many bars per row). Whole-day ranges, inclusive at both ends; day, week or month columns.
1236
+
1237
+ | Prop | Type | Default | Description |
1238
+ | ------------- | ------------------------------- | ---------- | ---------------------------------------------------------- |
1239
+ | `rows` | `GanttRow[]` | required | The lanes, in display order |
1240
+ | `from` / `to` | `IsoDate \| Date` | bar extent | The window, inclusive at both ends |
1241
+ | `unit` | `"day" \| "week" \| "month"` | `"day"` | Column granularity |
1242
+ | `unitWidth` | `number \| "fit"` | `2.5rem` | **Minimum** column width in px; `"fit"` drops the minimum |
1243
+ | `today` | `IsoDate \| Date \| null` | today | The marker line's day; `null` removes it |
1244
+ | `labels` | `boolean` | auto | Left label column — on when any row has a `label` |
1245
+ | `barLabels` | `"inside" \| "after" \| "none"` | `"inside"` | Where a bar's own label is drawn |
1246
+ | `onSelect` | `(detail) => void` | — | Bar click; also what turns bars into focusable `<button>`s |
1247
+
1248
+ ```svelte
1249
+ <Gantt
1250
+ rows={[
1251
+ {
1252
+ label: "Design",
1253
+ bars: [
1254
+ {
1255
+ from: "2026-09-07",
1256
+ to: "2026-09-18",
1257
+ label: "Wireframes",
1258
+ intent: "primary",
1259
+ progress: 0.65,
1260
+ },
1261
+ ],
1262
+ },
1263
+ {
1264
+ label: "Launch",
1265
+ bars: [
1266
+ { from: "2026-10-15", milestone: true, label: "Go live", intent: "success" },
1267
+ ],
1268
+ },
1269
+ ]}
1270
+ onSelect={(d) => console.log(d.row.label, d.bar.from)}
1271
+ />
1272
+ ```
1273
+
1274
+ The geometry is exported separately for axis-aligned overlays: `buildGanttAxis`, `placeRange`, `placePoint`, `dayToFraction`, `boundsOf`.
1275
+
1229
1276
  #### `ImageCycler`
1230
1277
 
1231
1278
  Auto-cycling image carousel with fade transitions. Preloads next image before displaying. Supports custom title/description snippets.
package/README.md CHANGED
@@ -175,11 +175,11 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
175
175
 
176
176
  ### Forms & Inputs
177
177
 
178
- FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldSingleAsset, FieldOptions, FieldKeyValues, FieldTable, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
178
+ FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldSingleAsset, FieldOptions, FieldKeyValues, FieldTable, FieldObject, FieldSwitch, FieldColorPicker, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
179
179
 
180
180
  ### Buttons & Controls
181
181
 
182
- Button, ButtonGroupRadio, Switch, Slider, RangeSlider, TwCheck, ListItemButton, X
182
+ Button, ButtonGroupRadio, Switch, ColorPicker, Slider, RangeSlider, TwCheck, ListItemButton, X
183
183
 
184
184
  ### Feedback & Notifications
185
185
 
package/dist/README.md CHANGED
@@ -46,6 +46,7 @@ npm install @marianmeres/stuic
46
46
  - **FieldOptions** - Modal-based multi-select picker (optional inline `chips` display)
47
47
  - **FieldKeyValues** - Key-value pairs editor with JSON serialization
48
48
  - **FieldSwitch** - Toggle switch within a form
49
+ - **FieldColorPicker** - Color picker (swatches + custom color) within a form
49
50
  - **Fieldset** - Group of form fields with legend
50
51
 
51
52
  ### Buttons & Controls
@@ -49,6 +49,13 @@
49
49
  disabled?: boolean;
50
50
  /** Accessible name of the swatch group (default `t("color")`, "Color") */
51
51
  label?: string;
52
+ /**
53
+ * Id of an element that names the swatch group (`aria-labelledby`). When set,
54
+ * it replaces the group's `aria-label` — what `FieldColorPicker` uses to let
55
+ * its visible label name the swatches. `...rest` cannot do this: it lands on
56
+ * the root, not on the radiogroup.
57
+ */
58
+ labelledby?: string;
52
59
  /** Form field name (hidden input) */
53
60
  name?: string;
54
61
  /** Require a non-empty value (enforced by the built-in validator) */
@@ -92,6 +99,7 @@
92
99
  allowClear = true,
93
100
  disabled = false,
94
101
  label,
102
+ labelledby,
95
103
  name,
96
104
  required = false,
97
105
  t = t_default,
@@ -323,6 +331,22 @@
323
331
  export function getValidation(): ValidationResult | undefined {
324
332
  return _validation;
325
333
  }
334
+
335
+ /**
336
+ * Focus the group's tab stop (the checked swatch, or the first one), or —
337
+ * with no swatches rendered — the first custom-color control.
338
+ */
339
+ export function focus(): void {
340
+ (
341
+ el?.querySelector<HTMLElement>(`[role="radiogroup"] [tabindex="0"]`) ??
342
+ el?.querySelector<HTMLElement>(`input:not([type="hidden"])`)
343
+ )?.focus();
344
+ }
345
+
346
+ /** Scroll the picker into view. Defaults to smooth + center. */
347
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
348
+ el?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
349
+ }
326
350
  </script>
327
351
 
328
352
  <div
@@ -340,7 +364,8 @@
340
364
  <div
341
365
  class={unstyled ? undefined : "stuic-color-picker-swatches"}
342
366
  role="radiogroup"
343
- aria-label={label || t("color", null, "Color")}
367
+ aria-labelledby={labelledby || undefined}
368
+ aria-label={labelledby ? undefined : label || t("color", null, "Color")}
344
369
  aria-required={required ? "true" : undefined}
345
370
  aria-disabled={disabled ? "true" : undefined}
346
371
  aria-invalid={_validation && !_validation.valid ? "true" : undefined}
@@ -40,6 +40,13 @@ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children" |
40
40
  disabled?: boolean;
41
41
  /** Accessible name of the swatch group (default `t("color")`, "Color") */
42
42
  label?: string;
43
+ /**
44
+ * Id of an element that names the swatch group (`aria-labelledby`). When set,
45
+ * it replaces the group's `aria-label` — what `FieldColorPicker` uses to let
46
+ * its visible label name the swatches. `...rest` cannot do this: it lands on
47
+ * the root, not on the radiogroup.
48
+ */
49
+ labelledby?: string;
43
50
  /** Form field name (hidden input) */
44
51
  name?: string;
45
52
  /** Require a non-empty value (enforced by the built-in validator) */
@@ -70,6 +77,8 @@ declare const ColorPicker: import("svelte").Component<Props, {
70
77
  validate: () => ValidationResult | undefined;
71
78
  clearValidation: () => void;
72
79
  getValidation: () => ValidationResult | undefined;
80
+ focus: () => void;
81
+ scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
73
82
  }, "el" | "value" | "inputEl">;
74
83
  type ColorPicker = ReturnType<typeof ColorPicker>;
75
84
  export default ColorPicker;
@@ -16,8 +16,12 @@ own full-screen color UI, so there is no anchored popover to fight the on-screen
16
16
  keyboard. Swatches grow to a ~44px hit target on a coarse pointer, and the hex field
17
17
  carries the iOS zoom guard.
18
18
 
19
+ In a form layout, use [`FieldColorPicker`](../Input/README.md#color-picker): this picker
20
+ inside the standard field shell (visible label, description, validation box, label-left
21
+ layout).
22
+
19
23
  Adjacent but different: `Rating` (the same input-with-hidden-input shape),
20
- `FieldSelect` (a labeled field wrapper), `ThemePreview` (theme token swatches).
24
+ `ThemePreview` (theme token swatches).
21
25
 
22
26
  ## Props
23
27
 
@@ -29,7 +33,8 @@ Adjacent but different: `Rating` (the same input-with-hidden-input shape),
29
33
  | `custom` | `"both" \| "native" \| "text" \| false` | `"both"` | Which custom-color controls to render under the palette |
30
34
  | `allowClear` | `boolean` | `true` | A crossed-out "no color" swatch, Delete / Backspace, and an emptied hex field all set `""` |
31
35
  | `disabled` | `boolean` | `false` | No interaction; the hidden input is disabled too (nothing submits) |
32
- | `label` | `string` | `"Color"` | Accessible name of the swatch group (via `t("color")` by default) |
36
+ | `label` | `string` | `"Color"` | Accessible name of the swatch group (via `t("color")` by default). Not a visible label — see `FieldColorPicker` |
37
+ | `labelledby` | `string` | — | Id of the element naming the swatch group (`aria-labelledby`); when set, replaces `label`'s `aria-label` |
33
38
  | `name` | `string` | — | Form field name of the hidden input |
34
39
  | `required` | `boolean` | `false` | Require a non-empty value — enforced by the built-in validator (hidden inputs skip native constraint validation) |
35
40
  | `t` | `TranslateFn` | English | i18n translate function (see below) |
@@ -46,11 +51,13 @@ Other attributes (`id`, `style`, `data-*`, …) are passed to the root `div`.
46
51
 
47
52
  ### Imperative API (via `bind:this`)
48
53
 
49
- | Method | Description |
50
- | ------------------- | -------------------------------------------------------------------- |
51
- | `validate()` | Trigger validation now; returns the `ValidationResult` |
52
- | `clearValidation()` | Clear the stored result and the hidden input's custom validity |
53
- | `getValidation()` | The last validation result (also reported via `setValidationResult`) |
54
+ | Method | Description |
55
+ | ------------------- | ----------------------------------------------------------------------- |
56
+ | `validate()` | Trigger validation now; returns the `ValidationResult` |
57
+ | `clearValidation()` | Clear the stored result and the hidden input's custom validity |
58
+ | `getValidation()` | The last validation result (also reported via `setValidationResult`) |
59
+ | `focus()` | Focus the checked swatch (the group's tab stop), else the first control |
60
+ | `scrollIntoView()` | Scroll the picker into view (defaults: `smooth` + `center`) |
54
61
 
55
62
  ## Usage
56
63
 
@@ -114,6 +121,9 @@ resolve to near-identical greys.
114
121
  </form>
115
122
  ```
116
123
 
124
+ A bare picker only reports the result (`setValidationResult`, `aria-invalid`); it renders no
125
+ message. `FieldColorPicker` shows it in the field's validation box.
126
+
117
127
  ## Preview vs commit
118
128
 
119
129
  `value` updates **live** while the native picker is being dragged and while a valid
@@ -144,6 +154,8 @@ value when `allowClear` is on.
144
154
  Up / Down step by one **rendered** row (measured from the layout, so it stays right
145
155
  when a narrow screen wraps to fewer per row) and stop at the edges.
146
156
  `aria-required` / `aria-invalid` sit on the group.
157
+ - The group is named by `label` (an `aria-label`, default "Color") or, when `labelledby`
158
+ is set, by that element instead — never both.
147
159
  - Every swatch is named: an entry's `label` (through `t`) or, without one, its color
148
160
  string. The clear swatch is "No color".
149
161
  - Selection is signalled by a ring drawn outside the swatch, not only by color.
@@ -0,0 +1,451 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from "svelte/elements";
3
+ import type { Snippet } from "svelte";
4
+ import type { Weekday } from "@marianmeres/calendar-utils";
5
+ import type { THC } from "../Thc/Thc.svelte";
6
+ import type { IntentColorKey } from "../../utils/design-tokens.js";
7
+ import type {
8
+ GanttAxis,
9
+ GanttColumn,
10
+ GanttDateInput,
11
+ GanttGroup,
12
+ GanttPlacement,
13
+ GanttUnit,
14
+ } from "./gantt-geometry.js";
15
+
16
+ /** Where a bar's own label is drawn. */
17
+ export type GanttBarLabels = "inside" | "after" | "none";
18
+
19
+ /** One bar (or milestone) on a row's track. */
20
+ export interface GanttBar {
21
+ /** Stable key — falls back to the index, which is fine for a static list. */
22
+ id?: string;
23
+ /** First day, inclusive. `YYYY-MM-DD`, or a `Date` (its **local** calendar date). */
24
+ from: GanttDateInput;
25
+ /** Last day, **inclusive**. Omitted = a single day. */
26
+ to?: GanttDateInput;
27
+ /** Text on the bar (see `barLabels`) */
28
+ label?: THC;
29
+ /** Semantic color */
30
+ intent?: IntentColorKey;
31
+ /** `0`–`1` — a darker fill from the bar's start ("% complete") */
32
+ progress?: number;
33
+ /** Draw a diamond at `from` instead of a bar (`to` is ignored) */
34
+ milestone?: boolean;
35
+ /** Draw thinner and on top — an emphasis range inside a wider bar of the same row */
36
+ inset?: boolean;
37
+ /** Renders the bar as a link */
38
+ href?: string;
39
+ /** Native tooltip (`title`) — the cheap way to show the detail a bar has no room for */
40
+ title?: string;
41
+ /** Not clickable, dimmed */
42
+ disabled?: boolean;
43
+ /** Additional classes for this bar */
44
+ class?: string;
45
+ /** Anything of yours — handed back by `onSelect` */
46
+ data?: unknown;
47
+ }
48
+
49
+ /** One lane of the chart: a label on the left, any number of bars on its track. */
50
+ export interface GanttRow {
51
+ /** Stable key — falls back to the index */
52
+ id?: string;
53
+ /** The left column's primary line */
54
+ label?: THC;
55
+ /** Secondary line under the label */
56
+ description?: THC;
57
+ /** The row's bars — one for a classic task, several for a resource lane */
58
+ bars: GanttBar[];
59
+ /** Renders the row label as a link */
60
+ href?: string;
61
+ /** Anything of yours — handed back by `onSelect` */
62
+ data?: unknown;
63
+ }
64
+
65
+ /** What `onSelect` (and the `renderBar` snippet) receives. */
66
+ export interface GanttSelectDetail {
67
+ bar: GanttBar;
68
+ row: GanttRow;
69
+ rowIndex: number;
70
+ barIndex: number;
71
+ /** The bar's track geometry, or `null` for a milestone (see `point`) */
72
+ placement: GanttPlacement | null;
73
+ /** A milestone's 0..1 position on the track, or `null` for a bar */
74
+ point: number | null;
75
+ }
76
+
77
+ export interface Props extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
78
+ /** The lanes, in display order (sort them yourself) */
79
+ rows: GanttRow[];
80
+ /** Window start, inclusive. Default: the earliest date in `rows` */
81
+ from?: GanttDateInput;
82
+ /** Window end, inclusive. Default: the latest date in `rows` */
83
+ to?: GanttDateInput;
84
+ /** Column granularity. `week`/`month` snap the window outwards to whole units */
85
+ unit?: GanttUnit;
86
+ /** First day of a week for `unit="week"` (1 = Monday … 7 = Sunday) */
87
+ weekStartsOn?: Weekday;
88
+ /** BCP 47 tag for month/weekday names and the default `aria-label`s */
89
+ locale?: string;
90
+ /** The day the marker line sits on. `null` removes it. Default: today */
91
+ today?: GanttDateInput | null;
92
+ /**
93
+ * MINIMUM column width in px (the horizontal zoom). The chart always fills its
94
+ * parent; when the axis needs less than that, the columns share out the rest.
95
+ * When it needs more, the frame scrolls horizontally.
96
+ *
97
+ * `"fit"` drops the minimum: the columns always divide the available width, so
98
+ * the chart never scrolls horizontally however many of them there are.
99
+ *
100
+ * Default: the `--stuic-gantt-unit-width` token.
101
+ */
102
+ unitWidth?: number | "fit";
103
+ /** Show the left label column. Default: whenever any row has a label */
104
+ labels?: boolean;
105
+ /** Where a bar's own `label` is drawn */
106
+ barLabels?: GanttBarLabels;
107
+ /** Fires on a bar click (and Enter/Space) — also what makes bars focusable */
108
+ onSelect?: (detail: GanttSelectDetail) => void;
109
+ /** Override a unit cell's header text */
110
+ formatColumn?: (column: GanttColumn, axis: GanttAxis) => string;
111
+ /** Override a group cell's header text (the month over its days, the year over its months) */
112
+ formatGroup?: (group: GanttGroup, axis: GanttAxis) => string;
113
+ /** Override a bar's accessible name */
114
+ formatBarAria?: (detail: GanttSelectDetail) => string;
115
+ /** Override the whole bar body (progress, label) */
116
+ renderBar?: Snippet<[GanttSelectDetail]>;
117
+ /** Override the left column's cell */
118
+ renderRowLabel?: Snippet<[{ row: GanttRow; index: number }]>;
119
+ /** The header's top-left cell (above the labels) */
120
+ renderCorner?: Snippet<[{ axis: GanttAxis }]>;
121
+ /** Shown in place of the rows when `rows` is empty (the axis still renders) */
122
+ empty?: Snippet;
123
+ /** Skip all default styling */
124
+ unstyled?: boolean;
125
+ /** Additional CSS classes */
126
+ class?: string;
127
+ /** Class for the sticky header */
128
+ classHeader?: string;
129
+ /** Class for every row */
130
+ classRow?: string;
131
+ /** Class for every left-column cell */
132
+ classRowLabel?: string;
133
+ /** Class for every row's track */
134
+ classTrack?: string;
135
+ /** Class for every bar */
136
+ classBar?: string;
137
+ /** Bindable element reference */
138
+ el?: HTMLElement;
139
+ }
140
+ </script>
141
+
142
+ <script lang="ts">
143
+ import { twMerge } from "../../utils/tw-merge.js";
144
+ import Thc from "../Thc/Thc.svelte";
145
+ import {
146
+ formatIsoDate,
147
+ formatIsoDateRange,
148
+ normalizeIsoDate,
149
+ todayIso,
150
+ addDaysIso,
151
+ } from "../Calendar/iso-date.js";
152
+ import { boundsOf, buildGanttAxis, placePoint, placeRange } from "./gantt-geometry.js";
153
+
154
+ let {
155
+ rows,
156
+ from,
157
+ to,
158
+ unit = "day",
159
+ weekStartsOn = 1,
160
+ locale,
161
+ today,
162
+ unitWidth,
163
+ labels,
164
+ barLabels = "inside",
165
+ onSelect,
166
+ formatColumn,
167
+ formatGroup,
168
+ formatBarAria,
169
+ renderBar,
170
+ renderRowLabel,
171
+ renderCorner,
172
+ empty,
173
+ unstyled = false,
174
+ class: classProp,
175
+ classHeader: classHeaderProp,
176
+ classRow: classRowProp,
177
+ classRowLabel: classRowLabelProp,
178
+ classTrack: classTrackProp,
179
+ classBar: classBarProp,
180
+ el = $bindable(),
181
+ ...rest
182
+ }: Props = $props();
183
+
184
+ const cx = (base: string, extra?: string) => (unstyled ? extra : twMerge(base, extra));
185
+
186
+ let _class = $derived(cx("stuic-gantt", classProp));
187
+ let _classHeader = $derived(cx("stuic-gantt-head", classHeaderProp));
188
+ let _classRow = $derived(cx("stuic-gantt-row", classRowProp));
189
+ let _classRowLabel = $derived(cx("stuic-gantt-row-label", classRowLabelProp));
190
+ let _classTrack = $derived(cx("stuic-gantt-track", classTrackProp));
191
+
192
+ let _today = $derived(
193
+ today === null ? null : (normalizeIsoDate(today ?? todayIso()) ?? null)
194
+ );
195
+
196
+ // The window: explicit ends win, the rows' own extent fills the rest, and with
197
+ // neither (no rows at all) a month from today — an axis is still a useful answer.
198
+ let _window = $derived.by(() => {
199
+ const explicitFrom = from === undefined ? null : normalizeIsoDate(from);
200
+ const explicitTo = to === undefined ? null : normalizeIsoDate(to);
201
+ if (explicitFrom && explicitTo) return { from: explicitFrom, to: explicitTo };
202
+ const bounds = boundsOf(rows.flatMap((r) => r.bars ?? []));
203
+ const fallback = _today ?? todayIso();
204
+ return {
205
+ from: explicitFrom ?? bounds?.from ?? fallback,
206
+ to: explicitTo ?? bounds?.to ?? addDaysIso(fallback, 29),
207
+ };
208
+ });
209
+
210
+ let axis = $derived(
211
+ buildGanttAxis(_window.from, _window.to, {
212
+ unit,
213
+ weekStartsOn,
214
+ locale,
215
+ today: _today,
216
+ })
217
+ );
218
+
219
+ let todayAt = $derived(_today ? placePoint(_today, axis) : null);
220
+
221
+ let showLabels = $derived(labels ?? rows.some((r) => r.label !== undefined));
222
+
223
+ interface PlacedBar extends GanttSelectDetail {
224
+ key: string | number;
225
+ }
226
+
227
+ let placed = $derived.by(() =>
228
+ rows.map((row, rowIndex) => ({
229
+ row,
230
+ rowIndex,
231
+ key: row.id ?? rowIndex,
232
+ bars: (row.bars ?? []).reduce<PlacedBar[]>((acc, bar, barIndex) => {
233
+ const point = bar.milestone ? placePoint(bar.from, axis) : null;
234
+ const placement = bar.milestone
235
+ ? null
236
+ : placeRange(bar.from, bar.to ?? bar.from, axis);
237
+ // Off-window (or undatable) bars are dropped rather than pinned to an
238
+ // edge, where a hairline reads as "starts today".
239
+ if (point === null && placement === null) return acc;
240
+ acc.push({
241
+ bar,
242
+ row,
243
+ rowIndex,
244
+ barIndex,
245
+ placement,
246
+ point,
247
+ key: bar.id ?? barIndex,
248
+ });
249
+ return acc;
250
+ }, []),
251
+ }))
252
+ );
253
+
254
+ // 4 decimals is sub-pixel at any realistic track width; the unary + drops the
255
+ // trailing zeros so the style attribute stays readable ("20%", not "20.0000%").
256
+ const pct = (n: number) => `${+(n * 100).toFixed(4)}%`;
257
+
258
+ function columnLabel(c: GanttColumn) {
259
+ return formatColumn ? formatColumn(c, axis) : c.label;
260
+ }
261
+
262
+ function groupLabel(g: GanttGroup) {
263
+ return formatGroup ? formatGroup(g, axis) : g.label;
264
+ }
265
+
266
+ function barAria(d: PlacedBar): string {
267
+ if (formatBarAria) return formatBarAria(d);
268
+ // Only plain-string labels can become an accessible name; an html/component
269
+ // THC is markup, and `title` is the documented way out of that.
270
+ const name = [d.row.label, d.bar.label]
271
+ .filter((v): v is string => typeof v === "string" && v !== "")
272
+ .join(" – ");
273
+ const a = normalizeIsoDate(d.bar.from);
274
+ const b = normalizeIsoDate(d.bar.to ?? d.bar.from);
275
+ const when = !a
276
+ ? ""
277
+ : !b || a === b || d.bar.milestone
278
+ ? formatIsoDate(a, locale)
279
+ : formatIsoDateRange(a, b, locale);
280
+ return [name, when].filter(Boolean).join(": ");
281
+ }
282
+
283
+ function barTag(bar: GanttBar) {
284
+ if (bar.disabled) return "span";
285
+ if (bar.href) return "a";
286
+ return onSelect ? "button" : "span";
287
+ }
288
+ </script>
289
+
290
+ {#snippet barBody(d: PlacedBar)}
291
+ {#if renderBar}
292
+ {@render renderBar(d)}
293
+ {:else}
294
+ {#if !d.bar.milestone && d.bar.progress !== undefined}
295
+ <span
296
+ class={unstyled ? undefined : "stuic-gantt-bar-progress"}
297
+ style:width={pct(Math.min(1, Math.max(0, d.bar.progress)))}
298
+ ></span>
299
+ {/if}
300
+ {#if d.bar.label !== undefined && barLabels !== "none"}
301
+ <span class={unstyled ? undefined : "stuic-gantt-bar-label"}>
302
+ <Thc thc={d.bar.label} />
303
+ </span>
304
+ {/if}
305
+ {/if}
306
+ {/snippet}
307
+
308
+ <div
309
+ bind:this={el}
310
+ class={_class}
311
+ data-unit={unit}
312
+ data-fit={unitWidth === "fit" ? "" : undefined}
313
+ data-labels={showLabels ? "" : undefined}
314
+ data-bar-labels={barLabels}
315
+ style:--stuic-gantt-unit-width={typeof unitWidth === "number"
316
+ ? `${unitWidth}px`
317
+ : undefined}
318
+ style:--_gantt-columns={axis.columns.length}
319
+ {...rest}
320
+ >
321
+ <div class={unstyled ? undefined : "stuic-gantt-viewport"}>
322
+ <div class={unstyled ? undefined : "stuic-gantt-inner"}>
323
+ <div class={_classHeader}>
324
+ {#if showLabels}
325
+ <div class={unstyled ? undefined : "stuic-gantt-corner"}>
326
+ {@render renderCorner?.({ axis })}
327
+ </div>
328
+ {/if}
329
+ <div class={unstyled ? undefined : "stuic-gantt-axis"}>
330
+ <div class={unstyled ? undefined : "stuic-gantt-groups"}>
331
+ {#each axis.groups as group (group.key)}
332
+ <div
333
+ class={unstyled ? undefined : "stuic-gantt-group"}
334
+ style:--_gantt-span={group.span}
335
+ data-group={group.key}
336
+ >
337
+ <span>{groupLabel(group)}</span>
338
+ </div>
339
+ {/each}
340
+ </div>
341
+ <div class={unstyled ? undefined : "stuic-gantt-units"}>
342
+ {#each axis.columns as column (column.date)}
343
+ <div
344
+ class={unstyled ? undefined : "stuic-gantt-unit"}
345
+ data-date={column.date}
346
+ data-weekend={column.isWeekend ? "" : undefined}
347
+ data-today={column.isToday ? "" : undefined}
348
+ >
349
+ <span class={unstyled ? undefined : "stuic-gantt-unit-label"}>
350
+ {columnLabel(column)}
351
+ </span>
352
+ {#if column.subLabel && !formatColumn}
353
+ <span class={unstyled ? undefined : "stuic-gantt-unit-sub"}>
354
+ {column.subLabel}
355
+ </span>
356
+ {/if}
357
+ </div>
358
+ {/each}
359
+ </div>
360
+ </div>
361
+ </div>
362
+
363
+ <div class={unstyled ? undefined : "stuic-gantt-body"}>
364
+ <!-- One grid layer for the whole body: the alternative is a cell per
365
+ row per column, which is rows×columns nodes for the same pixels. -->
366
+ <div class={unstyled ? undefined : "stuic-gantt-grid"} aria-hidden="true">
367
+ {#each axis.columns as column (column.date)}
368
+ <div
369
+ class={unstyled ? undefined : "stuic-gantt-gridline"}
370
+ data-weekend={column.isWeekend ? "" : undefined}
371
+ data-today={column.isToday ? "" : undefined}
372
+ ></div>
373
+ {/each}
374
+ {#if todayAt !== null}
375
+ <div
376
+ class={unstyled ? undefined : "stuic-gantt-today"}
377
+ style:left={pct(todayAt)}
378
+ data-today-marker
379
+ ></div>
380
+ {/if}
381
+ </div>
382
+
383
+ {#each placed as line (line.key)}
384
+ <div class={_classRow} data-row-id={line.row.id}>
385
+ {#if showLabels}
386
+ <div class={_classRowLabel}>
387
+ {#if renderRowLabel}
388
+ {@render renderRowLabel({ row: line.row, index: line.rowIndex })}
389
+ {:else}
390
+ {#if line.row.label !== undefined}
391
+ <span class={unstyled ? undefined : "stuic-gantt-row-title"}>
392
+ {#if line.row.href}
393
+ <a
394
+ href={line.row.href}
395
+ class={unstyled ? undefined : "stuic-gantt-row-link"}
396
+ >
397
+ <Thc thc={line.row.label} />
398
+ </a>
399
+ {:else}
400
+ <Thc thc={line.row.label} />
401
+ {/if}
402
+ </span>
403
+ {/if}
404
+ {#if line.row.description !== undefined}
405
+ <span class={unstyled ? undefined : "stuic-gantt-row-description"}>
406
+ <Thc thc={line.row.description} />
407
+ </span>
408
+ {/if}
409
+ {/if}
410
+ </div>
411
+ {/if}
412
+ <div class={_classTrack}>
413
+ {#each line.bars as d (d.key)}
414
+ {@const tag = barTag(d.bar)}
415
+ <svelte:element
416
+ this={tag}
417
+ class={cx("stuic-gantt-bar", twMerge(classBarProp, d.bar.class))}
418
+ href={tag === "a" ? d.bar.href : undefined}
419
+ type={tag === "button" ? "button" : undefined}
420
+ role={tag === "span" ? "img" : undefined}
421
+ aria-label={barAria(d)}
422
+ aria-disabled={d.bar.disabled ? "true" : undefined}
423
+ title={d.bar.title}
424
+ data-kind={d.bar.milestone ? "milestone" : "bar"}
425
+ data-intent={d.bar.intent}
426
+ data-inset={d.bar.inset ? "" : undefined}
427
+ data-disabled={d.bar.disabled ? "" : undefined}
428
+ data-clipped-start={d.placement?.clippedStart ? "" : undefined}
429
+ data-clipped-end={d.placement?.clippedEnd ? "" : undefined}
430
+ style:left={pct(d.point ?? d.placement!.start)}
431
+ style:width={d.placement
432
+ ? pct(d.placement.end - d.placement.start)
433
+ : undefined}
434
+ onclick={d.bar.disabled || !onSelect ? undefined : () => onSelect(d)}
435
+ >
436
+ {@render barBody(d)}
437
+ </svelte:element>
438
+ {/each}
439
+ </div>
440
+ </div>
441
+ {:else}
442
+ {#if empty}
443
+ <div class={unstyled ? undefined : "stuic-gantt-empty"}>
444
+ {@render empty()}
445
+ </div>
446
+ {/if}
447
+ {/each}
448
+ </div>
449
+ </div>
450
+ </div>
451
+ </div>