@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
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-
|
|
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
|
-
`
|
|
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>
|