@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
@@ -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;
@@ -0,0 +1,254 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { capFromPointer, capKeyStep, capTone, clampCap, snapCap } from '../../cap-bar';
4
+
5
+ let {
6
+ value = 0,
7
+ cap = $bindable(100),
8
+ min = 0,
9
+ max = 100,
10
+ step = 5,
11
+ warnAt = 75,
12
+ size = 'md',
13
+ label,
14
+ labelWidth = '96px',
15
+ readout,
16
+ readoutWidth = '76px',
17
+ hint,
18
+ caption,
19
+ tooltip,
20
+ readonly = false,
21
+ oninput,
22
+ onchange,
23
+ class: klass = '',
24
+ style: styleProp = ''
25
+ }: {
26
+ /** Consumption, in percent of `max`. */
27
+ value?: number;
28
+ /** Cap position; bindable, moved by drag/keyboard. */
29
+ cap?: number;
30
+ min?: number;
31
+ max?: number;
32
+ /** Snap increment for drag and keyboard. */
33
+ step?: number;
34
+ /** Consumption at which the fill turns `--warn`. */
35
+ warnAt?: number;
36
+ size?: 'md' | 'lg';
37
+ label?: string | Snippet;
38
+ labelWidth?: string;
39
+ /** Overrides the default `{value}% · {hint}` readout. */
40
+ readout?: string | Snippet;
41
+ readoutWidth?: string;
42
+ hint?: string;
43
+ caption?: Snippet;
44
+ /** Track tooltip; defaults to "cap N% — drag the bar". */
45
+ tooltip?: string;
46
+ readonly?: boolean;
47
+ /** Fires live while dragging or stepping. */
48
+ oninput?: (cap: number) => void;
49
+ /** Fires once the cap is committed (pointer release / key-up). */
50
+ onchange?: (cap: number) => void;
51
+ class?: string;
52
+ style?: string;
53
+ } = $props();
54
+
55
+ const range = $derived(max - min || 1);
56
+ const pct = $derived(clampCap(((value - min) / range) * 100, 0, 100));
57
+ const capPct = $derived(clampCap(((cap - min) / range) * 100, 0, 100));
58
+ const tone = $derived(capTone(value, cap, warnAt));
59
+ const readoutText = $derived(hint ? `${value}% · ${hint}` : `${value}%`);
60
+ const tip = $derived(tooltip ?? `cap ${cap}% — drag the bar`);
61
+ const ariaLabel = $derived(typeof label === 'string' ? `${label} cap` : 'cap');
62
+
63
+ let trackEl = $state<HTMLDivElement | null>(null);
64
+ let dragging = $state(false);
65
+ let committed = cap;
66
+
67
+ function setCap(next: number) {
68
+ if (next === cap) return;
69
+ cap = next;
70
+ oninput?.(cap);
71
+ }
72
+ function commit() {
73
+ if (cap === committed) return;
74
+ committed = cap;
75
+ onchange?.(cap);
76
+ }
77
+
78
+ function pointerDown(e: PointerEvent) {
79
+ if (readonly || !trackEl || e.button !== 0) return;
80
+ e.preventDefault();
81
+ dragging = true;
82
+ trackEl.setPointerCapture(e.pointerId);
83
+ setCap(capFromPointer(e.clientX, trackEl.getBoundingClientRect(), step, min, max));
84
+ trackEl.querySelector<HTMLElement>('.handle')?.focus();
85
+ }
86
+ function pointerMove(e: PointerEvent) {
87
+ if (!dragging || !trackEl) return;
88
+ setCap(capFromPointer(e.clientX, trackEl.getBoundingClientRect(), step, min, max));
89
+ }
90
+ function pointerUp() {
91
+ if (!dragging) return;
92
+ dragging = false;
93
+ commit();
94
+ }
95
+ function keyDown(e: KeyboardEvent) {
96
+ if (readonly) return;
97
+ const next = capKeyStep(e.key, e.shiftKey, snapCap(cap, step, min, max), step, min, max);
98
+ if (next === null) return;
99
+ e.preventDefault();
100
+ setCap(next);
101
+ }
102
+ function keyUp(e: KeyboardEvent) {
103
+ if (readonly || capKeyStep(e.key, e.shiftKey, cap, step, min, max) === null) return;
104
+ commit();
105
+ }
106
+ </script>
107
+
108
+ <div
109
+ data-tsu="CapBar"
110
+ class="cap-bar size-{size} tone-{tone} {klass}"
111
+ class:readonly
112
+ class:dragging
113
+ style="--label-w: {labelWidth}; --readout-w: {readoutWidth}; --pct: {pct}%; --cap: {capPct}%; {styleProp}"
114
+ >
115
+ {#if label}
116
+ <div class="label">
117
+ {#if typeof label === 'string'}{label}{:else}{@render label()}{/if}
118
+ </div>
119
+ {/if}
120
+ <div
121
+ class="track"
122
+ role="presentation"
123
+ bind:this={trackEl}
124
+ title={tip}
125
+ onpointerdown={pointerDown}
126
+ onpointermove={pointerMove}
127
+ onpointerup={pointerUp}
128
+ onpointercancel={pointerUp}
129
+ >
130
+ <div class="fill"></div>
131
+ <div
132
+ class="handle"
133
+ role="slider"
134
+ tabindex={readonly ? -1 : 0}
135
+ aria-label={ariaLabel}
136
+ aria-valuemin={min}
137
+ aria-valuemax={max}
138
+ aria-valuenow={cap}
139
+ aria-valuetext="cap {cap}%"
140
+ aria-disabled={readonly ? 'true' : undefined}
141
+ onkeydown={keyDown}
142
+ onkeyup={keyUp}
143
+ ></div>
144
+ </div>
145
+ <div class="readout">
146
+ {#if readout === undefined}{readoutText}{:else if typeof readout === 'string'}{readout}{:else}{@render readout()}{/if}
147
+ </div>
148
+ {#if caption}
149
+ <div class="caption">{@render caption()}</div>
150
+ {/if}
151
+ </div>
152
+
153
+ <style>
154
+ .cap-bar {
155
+ display: grid;
156
+ grid-template-columns: var(--label-w) 1fr var(--readout-w);
157
+ align-items: center;
158
+ column-gap: var(--sp-3);
159
+ row-gap: var(--sp-1);
160
+ width: 100%;
161
+ font-size: var(--fs-sm);
162
+ --track-h: 6px;
163
+ --fill: var(--ok);
164
+ }
165
+ .cap-bar.size-lg {
166
+ --track-h: 10px;
167
+ }
168
+ .tone-warn {
169
+ --fill: var(--warn);
170
+ }
171
+ .tone-danger {
172
+ --fill: var(--danger);
173
+ }
174
+ .label {
175
+ color: var(--text-muted);
176
+ overflow: hidden;
177
+ text-overflow: ellipsis;
178
+ white-space: nowrap;
179
+ }
180
+ .cap-bar:not(:has(.label)) .track {
181
+ grid-column: 1 / 3;
182
+ }
183
+ .track {
184
+ position: relative;
185
+ height: var(--track-h);
186
+ border-radius: var(--r-pill);
187
+ background: color-mix(in oklab, var(--accent) 26%, var(--bg));
188
+ cursor: ew-resize;
189
+ touch-action: none;
190
+ user-select: none;
191
+ }
192
+ .track::after {
193
+ content: '';
194
+ position: absolute;
195
+ inset: 0 0 0 var(--cap);
196
+ border-radius: 0 var(--r-pill) var(--r-pill) 0;
197
+ background: var(--bg);
198
+ box-shadow: inset 0 0 0 1px var(--border);
199
+ pointer-events: none;
200
+ }
201
+ .fill {
202
+ position: absolute;
203
+ inset: 0 auto 0 0;
204
+ width: var(--pct);
205
+ border-radius: var(--r-pill);
206
+ background: var(--fill);
207
+ transition: width 0.2s var(--ease);
208
+ z-index: 1;
209
+ }
210
+ .handle {
211
+ position: absolute;
212
+ top: 50%;
213
+ left: var(--cap);
214
+ width: 3px;
215
+ height: 16px;
216
+ transform: translate(-50%, -50%);
217
+ border-radius: 2px;
218
+ background: var(--text);
219
+ box-shadow: 0 0 0 2px var(--surface);
220
+ cursor: ew-resize;
221
+ z-index: 2;
222
+ }
223
+ .handle:focus-visible {
224
+ outline: 2px solid var(--accent);
225
+ outline-offset: 2px;
226
+ }
227
+ .readonly .track,
228
+ .readonly .handle {
229
+ cursor: default;
230
+ pointer-events: none;
231
+ }
232
+ .readonly .handle {
233
+ opacity: 0.6;
234
+ }
235
+ .readout {
236
+ text-align: right;
237
+ font-variant-numeric: tabular-nums;
238
+ color: var(--text-muted);
239
+ white-space: nowrap;
240
+ }
241
+ .caption {
242
+ grid-column: 2 / 3;
243
+ font-size: var(--fs-xs);
244
+ color: var(--text-faint);
245
+ }
246
+ .cap-bar:not(:has(.label)) .caption {
247
+ grid-column: 1 / 3;
248
+ }
249
+ @media (prefers-reduced-motion: reduce) {
250
+ .fill {
251
+ transition: none;
252
+ }
253
+ }
254
+ </style>
@@ -0,0 +1,33 @@
1
+ import type { Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ /** Consumption, in percent of `max`. */
4
+ value?: number;
5
+ /** Cap position; bindable, moved by drag/keyboard. */
6
+ cap?: number;
7
+ min?: number;
8
+ max?: number;
9
+ /** Snap increment for drag and keyboard. */
10
+ step?: number;
11
+ /** Consumption at which the fill turns `--warn`. */
12
+ warnAt?: number;
13
+ size?: 'md' | 'lg';
14
+ label?: string | Snippet;
15
+ labelWidth?: string;
16
+ /** Overrides the default `{value}% · {hint}` readout. */
17
+ readout?: string | Snippet;
18
+ readoutWidth?: string;
19
+ hint?: string;
20
+ caption?: Snippet;
21
+ /** Track tooltip; defaults to "cap N% — drag the bar". */
22
+ tooltip?: string;
23
+ readonly?: boolean;
24
+ /** Fires live while dragging or stepping. */
25
+ oninput?: (cap: number) => void;
26
+ /** Fires once the cap is committed (pointer release / key-up). */
27
+ onchange?: (cap: number) => void;
28
+ class?: string;
29
+ style?: string;
30
+ };
31
+ declare const CapBar: import("svelte").Component<$$ComponentProps, {}, "cap">;
32
+ type CapBar = ReturnType<typeof CapBar>;
33
+ export default CapBar;