@dorsk/tsumikit 0.35.1 → 0.36.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/README.md CHANGED
@@ -148,7 +148,8 @@ for a light theme (`:root` defaults to dark, so dark themes may omit it).
148
148
  `clearable` + `onclear`, `shape="pill"`, `width` fixed + `flex: none`,
149
149
  `onenter(value)`; the bare `<input>` DOM is unchanged unless `icon`/`clearable`
150
150
  is used), Textarea, Select, Switch, Checkbox,
151
- Slider, Progress, Artwork (lazy cover image with seeded gradient + initials
151
+ Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
152
+ threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
152
153
  fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
153
154
  (`tone` semantic palette or `color` for any CSS colour, `size` xs/sm/md, `dot`,
154
155
  `icon`, `numeric`, `truncate`, `variant="text"`; all tints derive from
@@ -159,7 +160,15 @@ any custom SVG).
159
160
  **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
160
161
  Popover, Menu, Tabs, RadioGroup, Tooltip, Accordion, CopyButton, FileButton,
161
162
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
162
- ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore.
163
+ ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
164
+ GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
165
+ auto/never/glyph degrades to icons inside a narrow `.cq` container),
166
+ CapBar (consumption track with a draggable, keyboard-steppable cap handle; `oninput` live, `onchange` on commit), WorkingDir
167
+ (fish-style path chip: ancestors abbreviate left to right as the slot narrows,
168
+ then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
169
+ a copy-on-click button; candidates via the `workingDirCandidates` helper).
170
+ Fieldset (bordered zone, legend rides the border; `droppable` makes it an HTML5
171
+ drop target with `accepts`/`ondrop`/`dropHint` — keyboard alternative is the consumer's job).
163
172
 
164
173
  **Organisms:** DataTable (generic `<T>`, typed columns + cell snippets;
165
174
  `layout="fixed"` makes column widths authoritative, `Column.truncate` /
@@ -186,7 +195,9 @@ children are `min-width: 0` so a wide title/actions row can't widen the grid on
186
195
  mobile), NavItem
187
196
  (collapses to an icon rail when the sidebar is narrow; `icon` from the
188
197
  registry, `iconPath` / `iconChildren` for custom glyphs, `activeStyle="bar"`
189
- for the inset-bar active look), Container, Stack
198
+ for the inset-bar active look), MasterDetail (list + detail columns that
199
+ become two pages — one pane at a time with a sticky 44px back header — below
200
+ their own `breakpoint`, so `selected` can be driven from the URL), Container, Stack
190
201
  (vertical), Cluster (wrapping row; `stackAt="xs|sm|md|lg"` makes it its own
191
202
  query container and stacks children full-width below 18/30/40/48rem — phone
192
203
  action rows without a viewport query), AutoGrid (intrinsically responsive
@@ -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;
@@ -0,0 +1,220 @@
1
+ <script lang="ts">
2
+ // List + detail columns. Above `breakpoint` (measured on this component's own
3
+ // box, not the viewport) both columns render and scroll independently. Below
4
+ // it exactly one pane renders: the list while nothing is `selected`, the
5
+ // detail (with a sticky back header) once it is. The consumer drives
6
+ // `selected` — typically from the URL — so each pane can be its own route.
7
+ import type { Snippet } from 'svelte';
8
+ import { browser } from '../../env';
9
+ import Icon from '../atoms/Icon.svelte';
10
+
11
+ let {
12
+ list,
13
+ detail,
14
+ detailHeader,
15
+ empty,
16
+ listWidth = '17rem',
17
+ breakpoint = '48rem',
18
+ selected = false,
19
+ onback,
20
+ backLabel = 'Back',
21
+ listLabel = 'List',
22
+ class: klass = '',
23
+ style,
24
+ ...rest
25
+ }: {
26
+ list: Snippet;
27
+ detail: Snippet;
28
+ /** Rendered above the detail; on mobile it shares the sticky header with the back button. */
29
+ detailHeader?: Snippet;
30
+ /** Desktop placeholder in the detail column while nothing is selected. */
31
+ empty?: Snippet;
32
+ listWidth?: string;
33
+ /** Own-width threshold (px/em/rem) below which the panes become two pages. */
34
+ breakpoint?: string;
35
+ selected?: boolean;
36
+ onback?: () => void;
37
+ backLabel?: string;
38
+ listLabel?: string;
39
+ class?: string;
40
+ style?: string;
41
+ [key: string]: unknown;
42
+ } = $props();
43
+
44
+ let root = $state<HTMLElement | null>(null);
45
+ let mobile = $state(false);
46
+
47
+ function toPx(length: string, el: HTMLElement): number {
48
+ const n = Number.parseFloat(length);
49
+ const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
50
+ if (length.endsWith('rem')) return n * fontSize(document.documentElement);
51
+ if (length.endsWith('em')) return n * fontSize(el);
52
+ return n;
53
+ }
54
+
55
+ $effect(() => {
56
+ if (!browser || !root) return;
57
+ const el = root;
58
+ const limit = toPx(breakpoint, el);
59
+ const observer = new ResizeObserver(([entry]) => {
60
+ mobile = entry.contentRect.width < limit;
61
+ });
62
+ observer.observe(el);
63
+ return () => observer.disconnect();
64
+ });
65
+
66
+ const showList = $derived(!mobile || !selected);
67
+ const showDetail = $derived(!mobile || selected);
68
+ </script>
69
+
70
+ <div
71
+ bind:this={root}
72
+ class="md {klass}"
73
+ class:mobile
74
+ class:selected
75
+ style="--md-list-w: {listWidth};{style ? ` ${style}` : ''}"
76
+ data-tsu="MasterDetail"
77
+ {...rest}
78
+ >
79
+ {#if showList}
80
+ <aside class="md-list" aria-label={listLabel}>
81
+ {@render list()}
82
+ </aside>
83
+ {/if}
84
+ {#if showDetail}
85
+ <section class="md-detail">
86
+ {#if mobile}
87
+ <header class="md-detail-header">
88
+ <button type="button" class="md-back" onclick={() => onback?.()}>
89
+ <Icon name="chevron-left" />
90
+ <span>{backLabel}</span>
91
+ </button>
92
+ {#if detailHeader}
93
+ <div class="md-detail-header-content">{@render detailHeader()}</div>
94
+ {/if}
95
+ </header>
96
+ {:else if detailHeader}
97
+ <header class="md-detail-header">
98
+ <div class="md-detail-header-content">{@render detailHeader()}</div>
99
+ </header>
100
+ {/if}
101
+ <div class="md-detail-body">
102
+ {#if selected}
103
+ {@render detail()}
104
+ {:else}
105
+ {@render empty?.()}
106
+ {/if}
107
+ </div>
108
+ </section>
109
+ {/if}
110
+ </div>
111
+
112
+ <style>
113
+ .md {
114
+ display: grid;
115
+ grid-template-columns: var(--md-list-w) minmax(0, 1fr);
116
+ min-width: 0;
117
+ min-height: 0;
118
+ height: 100%;
119
+ overflow: hidden;
120
+ }
121
+ .md-list,
122
+ .md-detail {
123
+ min-width: 0;
124
+ min-height: 0;
125
+ overflow-y: auto;
126
+ overflow-x: hidden;
127
+ -webkit-overflow-scrolling: touch;
128
+ }
129
+ .md-list {
130
+ border-right: 1px solid var(--border);
131
+ }
132
+ .md-detail {
133
+ display: flex;
134
+ flex-direction: column;
135
+ }
136
+ .md-detail-header {
137
+ position: sticky;
138
+ top: 0;
139
+ z-index: var(--z-header);
140
+ display: flex;
141
+ align-items: center;
142
+ gap: var(--sp-2);
143
+ min-width: 0;
144
+ min-height: var(--touch-target);
145
+ padding-inline: var(--sp-3);
146
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
147
+ backdrop-filter: blur(8px);
148
+ border-bottom: 1px solid var(--border);
149
+ }
150
+ .md-detail-header-content {
151
+ flex: 1;
152
+ min-width: 0;
153
+ }
154
+ .md-detail-body {
155
+ flex: 1;
156
+ min-height: 0;
157
+ }
158
+ .md-back {
159
+ display: inline-flex;
160
+ align-items: center;
161
+ gap: var(--sp-1);
162
+ min-height: var(--touch-target);
163
+ min-width: var(--touch-target);
164
+ padding-inline: var(--sp-2);
165
+ margin-inline-start: calc(-1 * var(--sp-2));
166
+ border: 0;
167
+ border-radius: var(--r-sm);
168
+ background: transparent;
169
+ color: var(--accent);
170
+ font: inherit;
171
+ font-size: var(--fs-sm);
172
+ font-weight: var(--fw-medium);
173
+ cursor: pointer;
174
+ flex-shrink: 0;
175
+ }
176
+ .md-back:hover {
177
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
178
+ }
179
+ .md-back:focus-visible {
180
+ outline: 2px solid var(--accent);
181
+ outline-offset: 2px;
182
+ }
183
+
184
+ .md.mobile {
185
+ grid-template-columns: minmax(0, 1fr);
186
+ }
187
+ .md.mobile .md-list {
188
+ border-right: 0;
189
+ width: 100%;
190
+ padding-inline: var(--safe-left) var(--safe-right);
191
+ padding-bottom: var(--safe-bottom);
192
+ }
193
+ .md.mobile .md-detail {
194
+ width: 100%;
195
+ animation: md-slide-in 0.2s var(--ease);
196
+ }
197
+ .md.mobile .md-detail-header {
198
+ padding-inline: max(var(--sp-3), var(--safe-left)) max(var(--sp-3), var(--safe-right));
199
+ }
200
+ .md.mobile .md-detail-body {
201
+ padding-inline: var(--safe-left) var(--safe-right);
202
+ padding-bottom: var(--safe-bottom);
203
+ }
204
+
205
+ @keyframes md-slide-in {
206
+ from {
207
+ transform: translateX(8%);
208
+ opacity: 0;
209
+ }
210
+ to {
211
+ transform: none;
212
+ opacity: 1;
213
+ }
214
+ }
215
+ @media (prefers-reduced-motion: reduce) {
216
+ .md.mobile .md-detail {
217
+ animation: none;
218
+ }
219
+ }
220
+ </style>
@@ -0,0 +1,22 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ list: Snippet;
4
+ detail: Snippet;
5
+ /** Rendered above the detail; on mobile it shares the sticky header with the back button. */
6
+ detailHeader?: Snippet;
7
+ /** Desktop placeholder in the detail column while nothing is selected. */
8
+ empty?: Snippet;
9
+ listWidth?: string;
10
+ /** Own-width threshold (px/em/rem) below which the panes become two pages. */
11
+ breakpoint?: string;
12
+ selected?: boolean;
13
+ onback?: () => void;
14
+ backLabel?: string;
15
+ listLabel?: string;
16
+ class?: string;
17
+ style?: string;
18
+ [key: string]: unknown;
19
+ };
20
+ declare const MasterDetail: import("svelte").Component<$$ComponentProps, {}, "">;
21
+ type MasterDetail = ReturnType<typeof MasterDetail>;
22
+ export default MasterDetail;