@dorsk/tsumikit 0.35.1 → 0.37.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.
Files changed (34) hide show
  1. package/README.md +26 -5
  2. package/dist/cap-bar.d.ts +8 -0
  3. package/dist/cap-bar.js +43 -0
  4. package/dist/components/atoms/Gauge.svelte +165 -0
  5. package/dist/components/atoms/Gauge.svelte.d.ts +24 -0
  6. package/dist/components/atoms/Select.svelte +112 -4
  7. package/dist/components/atoms/Select.svelte.d.ts +12 -0
  8. package/dist/components/layouts/MasterDetail.svelte +220 -0
  9. package/dist/components/layouts/MasterDetail.svelte.d.ts +22 -0
  10. package/dist/components/molecules/CapBar.svelte +254 -0
  11. package/dist/components/molecules/CapBar.svelte.d.ts +33 -0
  12. package/dist/components/molecules/Drawer.svelte +299 -0
  13. package/dist/components/molecules/Drawer.svelte.d.ts +35 -0
  14. package/dist/components/molecules/Fieldset.svelte +183 -0
  15. package/dist/components/molecules/Fieldset.svelte.d.ts +22 -0
  16. package/dist/components/molecules/GitRef.svelte +174 -0
  17. package/dist/components/molecules/GitRef.svelte.d.ts +30 -0
  18. package/dist/components/molecules/Menu.svelte +24 -1
  19. package/dist/components/molecules/Menu.svelte.d.ts +5 -0
  20. package/dist/components/molecules/RadioGroup.svelte +120 -14
  21. package/dist/components/molecules/RadioGroup.svelte.d.ts +6 -0
  22. package/dist/components/molecules/SectionHeader.svelte +35 -1
  23. package/dist/components/molecules/SectionHeader.svelte.d.ts +3 -0
  24. package/dist/components/molecules/Timestamp.svelte +7 -5
  25. package/dist/components/molecules/Timestamp.svelte.d.ts +2 -2
  26. package/dist/components/molecules/WorkingDir.svelte +134 -0
  27. package/dist/components/molecules/WorkingDir.svelte.d.ts +21 -0
  28. package/dist/index.d.ts +9 -0
  29. package/dist/index.js +8 -0
  30. package/dist/timestamp.d.ts +11 -5
  31. package/dist/timestamp.js +16 -2
  32. package/dist/working-dir.d.ts +12 -0
  33. package/dist/working-dir.js +38 -0
  34. package/package.json +10 -2
package/README.md CHANGED
@@ -147,8 +147,10 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
147
147
  **Atoms:** Text, Heading, Button, Input (`icon` inset leading glyph,
148
148
  `clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
149
149
  `onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
150
- is used), Textarea, Select, Switch, Checkbox,
151
- Slider, Progress, Artwork (lazy cover image with seeded gradient + initials
150
+ is used), Textarea, Select (`options` array with per-option `icon`/`emoji`/`hint`/`disabled`;
151
+ the trigger overlays the selected option's glyph + muted hint on the native control), Switch, Checkbox,
152
+ Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
153
+ threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
152
154
  fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
153
155
  (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
154
156
  `icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
@@ -157,9 +159,24 @@ always/hover/none, `align`), Icon (open registry — pass a `children` snippet f
157
159
  any custom SVG).
158
160
 
159
161
  **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
160
- Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
162
+ Popover, Menu (items take a free-form trailing `tag` + `tagTone`, or a `tag` snippet),
163
+ Tabs, RadioGroup (`variant="rows"`: bordered rows, per-option `note`/`description`,
164
+ `action(option)` trailing control that never toggles, `below(option)` inline panel),
165
+ Tooltip, Accordion, CopyButton, FileButton,
161
166
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
162
- ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
167
+ ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
168
+ GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
169
+ auto/never/glyph degrades to icons inside a narrow `.cq` container),
170
+ CapBar (consumption track with a draggable, keyboard-steppable cap handle; `oninput` live, `onchange` on commit),
171
+ Drawer (side-panel `<dialog>`: `side`, `width` clamped to the viewport, full-screen
172
+ under 48rem; `nav` page column that turns into a horizontal strip on narrow
173
+ screens, or `navMobile`; sticky `footer`; Escape / scrim / close button all close),
174
+ Timestamp (`mode` date/time/datetime/relative/iso/`short-iso` for a locale-independent `YYYY-MM-DD`; `utc`; click for a details popover), WorkingDir
175
+ (fish-style path chip: ancestors abbreviate left to right as the slot narrows,
176
+ then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
177
+ a copy-on-click button; candidates via the `workingDirCandidates` helper).
178
+ Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
179
+ drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job).
163
180
 
164
181
  **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
165
182
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
@@ -186,7 +203,9 @@ children are `min-width: 0` so a wide title/actions row can't widen the grid on
186
203
  mobile), NavItem
187
204
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
188
205
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
189
- for the inset-bar active look), Container, Stack
206
+ for the inset-bar active look), MasterDetail (list + detail columns that
207
+ become two pages — one pane at a time with a sticky 44px back header — below
208
+ their own `breakpoint`, so `selected` can be driven from the URL), Container, Stack
190
209
  (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
191
210
  query container and stacks children full-width below 18/30/40/48rem — phone
192
211
  action rows without a viewport query), AutoGrid (intrinsically responsive
@@ -309,6 +328,8 @@ chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
309
328
  `--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
310
329
  parent), `collapsible` + `bind:open` (title becomes a disclosure button with
311
330
  `aria-expanded`; `children` render beneath while open), `actions` snippet.
331
+ `variant="group"` is the non-wrapping list-group row: `lead` snippet (dot /
332
+ badge) · title · `count` · flexible 1px rule · `actions` (sort menu, eye toggle).
312
333
 
313
334
  `Card` gains `header` / `footer` snippets rendered outside the padded body
314
335
  behind a divider, `title` / `subtitle` / `actions` sugar that renders a
@@ -0,0 +1,8 @@
1
+ export declare function clampCap(value: number, min?: number, max?: number): number;
2
+ export declare function snapCap(value: number, step?: number, min?: number, max?: number): number;
3
+ export declare function capFromPointer(clientX: number, rect: {
4
+ left: number;
5
+ width: number;
6
+ }, step?: number, min?: number, max?: number): number;
7
+ export declare function capKeyStep(key: string, shift: boolean, current: number, step?: number, min?: number, max?: number): number | null;
8
+ export declare function capTone(value: number, cap: number, warnAt?: number): 'ok' | 'warn' | 'danger';
@@ -0,0 +1,43 @@
1
+ export function clampCap(value, min = 0, max = 100) {
2
+ if (Number.isNaN(value))
3
+ return min;
4
+ return Math.min(max, Math.max(min, value));
5
+ }
6
+ export function snapCap(value, step = 5, min = 0, max = 100) {
7
+ const s = step > 0 ? step : 1;
8
+ return clampCap(min + Math.round((value - min) / s) * s, min, max);
9
+ }
10
+ export function capFromPointer(clientX, rect, step = 5, min = 0, max = 100) {
11
+ if (rect.width <= 0)
12
+ return min;
13
+ const ratio = (clientX - rect.left) / rect.width;
14
+ return snapCap(min + ratio * (max - min), step, min, max);
15
+ }
16
+ export function capKeyStep(key, shift, current, step = 5, min = 0, max = 100) {
17
+ const delta = shift ? step * 5 : step;
18
+ switch (key) {
19
+ case 'ArrowRight':
20
+ case 'ArrowUp':
21
+ return snapCap(current + delta, step, min, max);
22
+ case 'ArrowLeft':
23
+ case 'ArrowDown':
24
+ return snapCap(current - delta, step, min, max);
25
+ case 'Home':
26
+ return min;
27
+ case 'End':
28
+ return max;
29
+ case 'PageUp':
30
+ return snapCap(current + step * 5, step, min, max);
31
+ case 'PageDown':
32
+ return snapCap(current - step * 5, step, min, max);
33
+ default:
34
+ return null;
35
+ }
36
+ }
37
+ export function capTone(value, cap, warnAt = 75) {
38
+ if (value >= cap)
39
+ return 'danger';
40
+ if (value >= warnAt)
41
+ return 'warn';
42
+ return 'ok';
43
+ }
@@ -0,0 +1,165 @@
1
+ <script lang="ts" module>
2
+ export type GaugeTone = 'ok' | 'warn' | 'danger';
3
+
4
+ export function clampPct(value: number): number {
5
+ return Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0;
6
+ }
7
+
8
+ export function gaugeTone(value: number, warnAt = 70, dangerAt = 90): GaugeTone {
9
+ const v = clampPct(value);
10
+ if (v >= dangerAt) return 'danger';
11
+ if (v >= warnAt) return 'warn';
12
+ return 'ok';
13
+ }
14
+
15
+ export function litSegments(value: number, segments = 3): number {
16
+ const v = clampPct(value);
17
+ if (segments <= 0) return 0;
18
+ return Math.min(segments, Math.floor((v / 100) * segments));
19
+ }
20
+ </script>
21
+
22
+ <script lang="ts">
23
+ import type { Snippet } from 'svelte';
24
+
25
+ let {
26
+ value,
27
+ variant = 'continuous',
28
+ segments = 3,
29
+ tone,
30
+ warnAt = 70,
31
+ dangerAt = 90,
32
+ label,
33
+ as = 'div',
34
+ width,
35
+ height,
36
+ corner,
37
+ class: klass = '',
38
+ style = '',
39
+ ...rest
40
+ }: {
41
+ value: number;
42
+ variant?: 'continuous' | 'segments';
43
+ // `segments` variant only: number of stacked bars, lit bottom-up.
44
+ segments?: number;
45
+ // Forces the fill colour; otherwise derived from `warnAt` / `dangerAt`.
46
+ tone?: GaugeTone;
47
+ warnAt?: number;
48
+ dangerAt?: number;
49
+ label?: string;
50
+ as?: 'div' | 'a' | 'button';
51
+ width?: string;
52
+ height?: string;
53
+ corner?: Snippet;
54
+ class?: string;
55
+ style?: string;
56
+ [key: string]: unknown;
57
+ } = $props();
58
+
59
+ const pct = $derived(clampPct(value));
60
+ const resolvedTone = $derived(tone ?? gaugeTone(pct, warnAt, dangerAt));
61
+ const lit = $derived(litSegments(pct, segments));
62
+ const segmentCount = $derived(Math.max(0, Math.floor(segments)));
63
+ </script>
64
+
65
+ <svelte:element
66
+ this={as}
67
+ data-tsu="Gauge"
68
+ class="gauge tone-{resolvedTone} {klass}"
69
+ class:interactive={as !== 'div'}
70
+ type={as === 'button' ? 'button' : undefined}
71
+ role="meter"
72
+ aria-label={label}
73
+ aria-valuemin={0}
74
+ aria-valuemax={100}
75
+ aria-valuenow={pct}
76
+ style:--gauge-w={width}
77
+ style:--gauge-h={height}
78
+ {style}
79
+ {...rest}
80
+ >
81
+ {#if variant === 'segments'}
82
+ <span class="segments" style:--gauge-segments={segmentCount}>
83
+ {#each { length: segmentCount } as _, i (i)}
84
+ <span class="segment" class:lit={i < lit}></span>
85
+ {/each}
86
+ </span>
87
+ {:else}
88
+ <span class="fill" style:height="{pct}%"></span>
89
+ {/if}
90
+ {#if corner}
91
+ <span class="corner">{@render corner()}</span>
92
+ {/if}
93
+ </svelte:element>
94
+
95
+ <style>
96
+ .gauge {
97
+ position: relative;
98
+ display: inline-flex;
99
+ flex: none;
100
+ box-sizing: border-box;
101
+ width: var(--gauge-w, 0.875rem);
102
+ height: var(--gauge-h, 1.25rem);
103
+ margin: 0;
104
+ padding: 0;
105
+ border: 1px solid var(--border);
106
+ border-radius: var(--r-sm);
107
+ background: var(--bg-elevated-2);
108
+ overflow: visible;
109
+ color: inherit;
110
+ font: inherit;
111
+ text-decoration: none;
112
+ vertical-align: middle;
113
+ }
114
+ .interactive {
115
+ cursor: pointer;
116
+ }
117
+ .interactive:hover {
118
+ border-color: var(--gauge-fill);
119
+ }
120
+ .interactive:focus-visible {
121
+ outline: 2px solid var(--accent);
122
+ outline-offset: 2px;
123
+ }
124
+ .tone-ok {
125
+ --gauge-fill: var(--ok);
126
+ }
127
+ .tone-warn {
128
+ --gauge-fill: var(--warn);
129
+ }
130
+ .tone-danger {
131
+ --gauge-fill: var(--danger);
132
+ }
133
+ .fill {
134
+ position: absolute;
135
+ inset: auto 0 0 0;
136
+ border-radius: calc(var(--r-sm) - 1px);
137
+ background: var(--gauge-fill);
138
+ transition: height 0.2s var(--ease), background 0.2s var(--ease);
139
+ }
140
+ .segments {
141
+ position: absolute;
142
+ inset: 2px;
143
+ display: flex;
144
+ flex-direction: column-reverse;
145
+ gap: 2px;
146
+ }
147
+ .segment {
148
+ flex: 1 1 0%;
149
+ border-radius: 2px;
150
+ background: var(--bg-elevated);
151
+ transition: background 0.2s var(--ease);
152
+ }
153
+ .segment.lit {
154
+ background: var(--gauge-fill);
155
+ }
156
+ .corner {
157
+ position: absolute;
158
+ right: -0.35em;
159
+ bottom: -0.35em;
160
+ display: inline-flex;
161
+ font-size: 0.625rem;
162
+ line-height: 1;
163
+ pointer-events: none;
164
+ }
165
+ </style>
@@ -0,0 +1,24 @@
1
+ export type GaugeTone = 'ok' | 'warn' | 'danger';
2
+ export declare function clampPct(value: number): number;
3
+ export declare function gaugeTone(value: number, warnAt?: number, dangerAt?: number): GaugeTone;
4
+ export declare function litSegments(value: number, segments?: number): number;
5
+ import type { Snippet } from 'svelte';
6
+ type $$ComponentProps = {
7
+ value: number;
8
+ variant?: 'continuous' | 'segments';
9
+ segments?: number;
10
+ tone?: GaugeTone;
11
+ warnAt?: number;
12
+ dangerAt?: number;
13
+ label?: string;
14
+ as?: 'div' | 'a' | 'button';
15
+ width?: string;
16
+ height?: string;
17
+ corner?: Snippet;
18
+ class?: string;
19
+ style?: string;
20
+ [key: string]: unknown;
21
+ };
22
+ declare const Gauge: import("svelte").Component<$$ComponentProps, {}, "">;
23
+ type Gauge = ReturnType<typeof Gauge>;
24
+ export default Gauge;
@@ -1,7 +1,30 @@
1
+ <script lang="ts" module>
2
+ import type { IconName } from './Icon.svelte';
3
+
4
+ export type SelectOption = {
5
+ value: string;
6
+ label: string;
7
+ icon?: IconName;
8
+ emoji?: string;
9
+ /** Muted, right-aligned secondary text (e.g. "62%"). */
10
+ hint?: string;
11
+ disabled?: boolean;
12
+ };
13
+ </script>
14
+
1
15
  <script lang="ts">
2
16
  // Native <select> primitive. Owns its styling from theme tokens; supports
3
- // `bind:value` and passes through every native attribute. Options are slotted
4
- // children so call-sites keep full control over <option> rendering.
17
+ // `bind:value` and passes through every native attribute. Options are either
18
+ // slotted children (full control over <option> rendering) or an `options`
19
+ // array, which adds per-option icon/emoji/hint.
20
+ //
21
+ // A native <select> cannot render icons in its popup or trigger. With
22
+ // `options`, the trigger text is drawn by a pointer-transparent face layer
23
+ // (leading icon/emoji, label, muted right-aligned hint) laid over the native
24
+ // control whose own text is made transparent, so keyboard, focus, form
25
+ // submission and the platform popup stay native. The popup degrades
26
+ // gracefully: emoji and hint are folded into the option text
27
+ // (`🐼 personal · 62%`); an `icon` only shows in the trigger.
5
28
  //
6
29
  // The default variant hides the OS chevron (appearance: none) and draws its
7
30
  // own, so the control looks identical across platforms and the chevron sits
@@ -42,6 +65,8 @@
42
65
  'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
43
66
  class?: string;
44
67
  value?: HTMLSelectAttributes['value'];
68
+ /** Rich option list; rendered instead of `children` when given. */
69
+ options?: SelectOption[];
45
70
  children?: Snippet;
46
71
  };
47
72
 
@@ -58,6 +83,7 @@
58
83
  'aria-invalid': ariaInvalid,
59
84
  class: klass = '',
60
85
  value = $bindable(),
86
+ options,
61
87
  children,
62
88
  ...rest
63
89
  }: Props = $props();
@@ -70,8 +96,23 @@
70
96
 
71
97
  const small = $derived(compact || size === 'sm');
72
98
  const showChevron = $derived(chevron ?? variant !== 'embedded');
99
+ const selected = $derived(options?.find((o) => o.value === value));
100
+ const hasFace = $derived(!!options && variant !== 'ghost');
101
+
102
+ const optionText = (o: SelectOption) =>
103
+ [o.emoji, o.label, o.hint && `· ${o.hint}`].filter(Boolean).join(' ');
73
104
  </script>
74
105
 
106
+ {#snippet optionList()}
107
+ {#if options}
108
+ {#each options as o (o.value)}
109
+ <option value={o.value} disabled={o.disabled}>{optionText(o)}</option>
110
+ {/each}
111
+ {:else}
112
+ {@render children?.()}
113
+ {/if}
114
+ {/snippet}
115
+
75
116
  {#if variant === 'ghost'}
76
117
  <select
77
118
  bind:this={el}
@@ -83,7 +124,7 @@
83
124
  aria-describedby={ariaDescribedby ?? field?.describedBy}
84
125
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
85
126
  >
86
- {@render children?.()}
127
+ {@render optionList()}
87
128
  </select>
88
129
  {:else}
89
130
  <div
@@ -100,14 +141,28 @@
100
141
  class:select-sm={size === 'sm'}
101
142
  class:w-auto={width === 'auto'}
102
143
  class:embedded={variant === 'embedded'}
144
+ class:has-face={hasFace}
103
145
  bind:value
104
146
  {...rest}
105
147
  id={id ?? field?.id}
106
148
  aria-describedby={ariaDescribedby ?? field?.describedBy}
107
149
  aria-invalid={ariaInvalid ?? (isInvalid ? 'true' : undefined)}
108
150
  >
109
- {@render children?.()}
151
+ {@render optionList()}
110
152
  </select>
153
+ {#if hasFace}
154
+ <span class="select-face" class:compact={small} aria-hidden="true">
155
+ {#if selected?.icon}
156
+ <Icon name={selected.icon} size={small ? 14 : 16} />
157
+ {:else if selected?.emoji}
158
+ <span class="select-emoji">{selected.emoji}</span>
159
+ {/if}
160
+ <span class="select-label">{selected?.label ?? ''}</span>
161
+ {#if selected?.hint}
162
+ <span class="select-hint">{selected.hint}</span>
163
+ {/if}
164
+ </span>
165
+ {/if}
111
166
  {#if showChevron}
112
167
  <span class="select-chevron" aria-hidden="true">
113
168
  <Icon name="chevron-down" size={16} />
@@ -178,6 +233,59 @@
178
233
  .select[aria-invalid='true']:focus {
179
234
  border-color: var(--danger);
180
235
  }
236
+ /* The face draws the trigger text; the native text underneath stays laid out
237
+ (so `width="auto"` still sizes to it) but invisible. Popup options keep
238
+ their own colour so the transparency does not inherit into the list. */
239
+ .select.has-face {
240
+ color: transparent;
241
+ }
242
+ .select.has-face option {
243
+ color: var(--text);
244
+ background: var(--bg);
245
+ }
246
+ .select-face {
247
+ position: absolute;
248
+ inset: 0;
249
+ display: flex;
250
+ align-items: center;
251
+ gap: var(--sp-2);
252
+ padding: 0 var(--sp-3);
253
+ padding-right: calc(var(--sp-3) + 1.25rem);
254
+ color: var(--text);
255
+ pointer-events: none;
256
+ overflow: hidden;
257
+ }
258
+ .select-wrap.no-chevron .select-face {
259
+ padding-right: var(--sp-3);
260
+ }
261
+ .select-face.compact {
262
+ padding: 0 var(--sp-2);
263
+ padding-right: calc(var(--sp-2) + 1rem);
264
+ font-size: var(--fs-xs);
265
+ }
266
+ .select-wrap.no-chevron .select-face.compact {
267
+ padding-right: var(--sp-2);
268
+ }
269
+ .select:disabled ~ .select-face {
270
+ color: var(--text-muted);
271
+ }
272
+ .select-emoji {
273
+ line-height: 1;
274
+ }
275
+ .select-label {
276
+ flex: 1 1 auto;
277
+ min-width: 0;
278
+ overflow: hidden;
279
+ text-overflow: ellipsis;
280
+ white-space: nowrap;
281
+ }
282
+ .select-hint {
283
+ flex: none;
284
+ margin-left: auto;
285
+ color: var(--text-muted);
286
+ font-size: var(--fs-xs);
287
+ font-variant-numeric: tabular-nums;
288
+ }
181
289
  .select-chevron {
182
290
  position: absolute;
183
291
  top: 50%;
@@ -1,3 +1,13 @@
1
+ import type { IconName } from './Icon.svelte';
2
+ export type SelectOption = {
3
+ value: string;
4
+ label: string;
5
+ icon?: IconName;
6
+ emoji?: string;
7
+ /** Muted, right-aligned secondary text (e.g. "62%"). */
8
+ hint?: string;
9
+ disabled?: boolean;
10
+ };
1
11
  import type { Snippet } from 'svelte';
2
12
  import type { HTMLSelectAttributes } from 'svelte/elements';
3
13
  type Props = Omit<HTMLSelectAttributes, 'size'> & {
@@ -21,6 +31,8 @@ type Props = Omit<HTMLSelectAttributes, 'size'> & {
21
31
  'aria-invalid'?: HTMLSelectAttributes['aria-invalid'];
22
32
  class?: string;
23
33
  value?: HTMLSelectAttributes['value'];
34
+ /** Rich option list; rendered instead of `children` when given. */
35
+ options?: SelectOption[];
24
36
  children?: Snippet;
25
37
  };
26
38
  declare const Select: import("svelte").Component<Props, {}, "value">;