@dorsk/tsumikit 0.36.0 → 0.37.1

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
@@ -147,7 +147,8 @@ 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,
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,
151
152
  Slider, Progress, Gauge (vertical consumption cell, `variant` continuous/segments,
152
153
  threshold tones via `warnAt`/`dangerAt`, `corner` snippet), Artwork (lazy cover image with seeded gradient + initials
153
154
  fallback, `aspect`, `status` overlay), Card (`tone` tints the surface for inline banners), Badge
@@ -158,12 +159,19 @@ always/hover/none, `align`), Icon (open registry — pass a `children` snippet f
158
159
  any custom SVG).
159
160
 
160
161
  **Molecules:** Field (`grow`), IconButton, SelectButton, Toggle, OptionButton, Modal,
161
- 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,
162
166
  Dropzone, CodeBlock, Callout, EmptyState, ConfirmModal, Pagination, Toaster,
163
167
  ThemePicker, FontScalePicker, SectionHeader, KeyValue, LoadMore,
164
168
  GitRef (branch chip + PR link tinted by state + `+N −N` diff; `collapse`
165
169
  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
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
167
175
  (fish-style path chip: ancestors abbreviate left to right as the slot narrows,
168
176
  then the leaf ellipsises down to `minLeaf`; `full` skips the fit, `copy` makes it
169
177
  a copy-on-click button; candidates via the `workingDirCandidates` helper).
@@ -320,6 +328,8 @@ chip), `uppercase` (eyebrow group label), `divider`, `sticky` (pins at
320
328
  `--sticky-offset` / `--header-h` and publishes `--section-header-h` on its
321
329
  parent), `collapsible` + `bind:open` (title becomes a disclosure button with
322
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).
323
333
 
324
334
  `Card` gains `header` / `footer` snippets rendered outside the padded body
325
335
  behind a divider, `title` / `subtitle` / `actions` sugar that renders a
@@ -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">;
@@ -184,7 +184,7 @@
184
184
  position: relative;
185
185
  height: var(--track-h);
186
186
  border-radius: var(--r-pill);
187
- background: color-mix(in oklab, var(--accent) 26%, var(--bg));
187
+ background: color-mix(in oklab, var(--fill) 26%, var(--bg));
188
188
  cursor: ew-resize;
189
189
  touch-action: none;
190
190
  user-select: none;
@@ -0,0 +1,302 @@
1
+ <script lang="ts">
2
+ // Side panel on the native <dialog>: top layer, focus trap, inert background,
3
+ // focus restoration and Escape come from the platform. Adds click-outside,
4
+ // body scroll lock, an optional page-nav column and a sticky footer.
5
+ import type { Snippet } from 'svelte';
6
+ import { browser } from '../../env';
7
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
8
+ import IconButton from './IconButton.svelte';
9
+
10
+ let {
11
+ open = $bindable(),
12
+ side = 'right',
13
+ width = '620px',
14
+ navWidth = '150px',
15
+ title,
16
+ page,
17
+ icon,
18
+ onclose,
19
+ closeLabel = 'Close panel',
20
+ header,
21
+ nav,
22
+ navMobile,
23
+ children,
24
+ footer,
25
+ class: className,
26
+ style
27
+ }: {
28
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
29
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
30
+ * Omit to open on mount (mount/unmount the component to show/hide). */
31
+ open?: boolean;
32
+ side?: 'left' | 'right';
33
+ /** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
34
+ * 48rem the panel is always full-screen. */
35
+ width?: string;
36
+ /** Width of the `nav` column on wide viewports. */
37
+ navWidth?: string;
38
+ title: string;
39
+ /** Name of the current page, shown after the title in the header. */
40
+ page?: string;
41
+ /** Leading header glyph. */
42
+ icon?: IconName;
43
+ onclose?: () => void;
44
+ closeLabel?: string;
45
+ /** Replaces the whole header row (title, page, close button). */
46
+ header?: Snippet;
47
+ /** Vertical page list beside the content on wide viewports; on narrow
48
+ * viewports it becomes a horizontal strip above the content unless
49
+ * `navMobile` is provided. */
50
+ nav?: Snippet;
51
+ navMobile?: Snippet;
52
+ children: Snippet;
53
+ footer?: Snippet;
54
+ class?: string;
55
+ style?: string;
56
+ } = $props();
57
+
58
+ const titleId = `drawer-title-${Math.random().toString(36).slice(2, 8)}`;
59
+
60
+ let dialogEl = $state<HTMLDialogElement | null>(null);
61
+ const controlled = $derived(open !== undefined);
62
+ const shown = $derived(!controlled || open === true);
63
+
64
+ $effect(() => {
65
+ if (!dialogEl) return;
66
+ if (!controlled) {
67
+ dialogEl.showModal();
68
+ return;
69
+ }
70
+ if (open && !dialogEl.open) dialogEl.showModal();
71
+ else if (!open && dialogEl.open) dialogEl.close();
72
+ });
73
+
74
+ $effect(() => {
75
+ if (!browser || !shown) return;
76
+ const previous = document.body.style.overflow;
77
+ document.body.style.overflow = 'hidden';
78
+ return () => {
79
+ document.body.style.overflow = previous;
80
+ };
81
+ });
82
+
83
+ function requestClose() {
84
+ if (controlled) open = false;
85
+ onclose?.();
86
+ }
87
+
88
+ function onDialogClick(e: MouseEvent) {
89
+ if (e.target === dialogEl) requestClose();
90
+ }
91
+ </script>
92
+
93
+ <dialog
94
+ bind:this={dialogEl}
95
+ data-tsu="Drawer"
96
+ class="drawer"
97
+ data-side={side}
98
+ aria-labelledby={header ? undefined : titleId}
99
+ aria-label={header ? title : undefined}
100
+ oncancel={(e) => {
101
+ e.preventDefault();
102
+ requestClose();
103
+ }}
104
+ onclose={() => {
105
+ if (controlled) open = false;
106
+ }}
107
+ onclick={onDialogClick}
108
+ >
109
+ <div
110
+ class={['panel', className]}
111
+ class:has-nav={Boolean(nav)}
112
+ style:--drawer-w={width}
113
+ style:--drawer-nav-w={navWidth}
114
+ {style}
115
+ >
116
+ {#if header}
117
+ {@render header()}
118
+ {:else}
119
+ <div class="panel-head">
120
+ {#if icon}<span class="panel-icon"><Icon name={icon} size={18} /></span>{/if}
121
+ <span id={titleId} class="panel-title truncate">{title}</span>
122
+ {#if page}<span class="panel-page truncate">{page}</span>{/if}
123
+ <div class="spacer"></div>
124
+ <IconButton icon="x" label={closeLabel} onclick={requestClose} />
125
+ </div>
126
+ {/if}
127
+ {#if nav}
128
+ <nav class="panel-nav" class:has-mobile={Boolean(navMobile)}>{@render nav()}</nav>
129
+ {/if}
130
+ {#if navMobile}
131
+ <nav class="panel-nav-mobile">{@render navMobile()}</nav>
132
+ {/if}
133
+ <div class="panel-body">
134
+ {@render children()}
135
+ </div>
136
+ {#if footer}
137
+ <div class="panel-foot">{@render footer()}</div>
138
+ {/if}
139
+ </div>
140
+ </dialog>
141
+
142
+ <style>
143
+ .drawer {
144
+ margin: 0;
145
+ padding: 0;
146
+ border: 0;
147
+ background: none;
148
+ max-width: 100vw;
149
+ max-height: 100dvh;
150
+ width: 100%;
151
+ height: 100%;
152
+ color: var(--text);
153
+ display: flex;
154
+ justify-content: flex-end;
155
+ }
156
+ .drawer:not([open]) {
157
+ display: none;
158
+ }
159
+ .drawer[data-side='left'] {
160
+ justify-content: flex-start;
161
+ }
162
+ .drawer::backdrop {
163
+ background: rgba(0, 0, 0, 0.55);
164
+ animation: drawer-backdrop-in 0.18s var(--ease);
165
+ }
166
+ @keyframes drawer-backdrop-in {
167
+ from {
168
+ opacity: 0;
169
+ }
170
+ }
171
+
172
+ .panel {
173
+ display: grid;
174
+ grid-template-columns: minmax(0, 1fr);
175
+ grid-template-rows: auto auto minmax(0, 1fr) auto;
176
+ grid-template-areas:
177
+ 'head'
178
+ 'nav'
179
+ 'body'
180
+ 'foot';
181
+ width: 100vw;
182
+ height: 100dvh;
183
+ background: var(--bg-elevated);
184
+ padding-top: var(--safe-top);
185
+ padding-bottom: var(--safe-bottom);
186
+ animation: drawer-slide-right 0.2s var(--ease);
187
+ }
188
+ .drawer[data-side='left'] .panel {
189
+ animation-name: drawer-slide-left;
190
+ }
191
+ @keyframes drawer-slide-right {
192
+ from {
193
+ transform: translateX(100%);
194
+ }
195
+ }
196
+ @keyframes drawer-slide-left {
197
+ from {
198
+ transform: translateX(-100%);
199
+ }
200
+ }
201
+ @media (prefers-reduced-motion: reduce) {
202
+ .drawer::backdrop,
203
+ .panel {
204
+ animation: none;
205
+ }
206
+ }
207
+
208
+ .panel-head {
209
+ grid-area: head;
210
+ display: flex;
211
+ align-items: center;
212
+ gap: var(--sp-2);
213
+ padding: var(--sp-4);
214
+ border-bottom: 1px solid var(--border);
215
+ }
216
+ .panel-icon {
217
+ display: inline-flex;
218
+ flex: none;
219
+ color: var(--text-muted);
220
+ }
221
+ .panel-title {
222
+ font-size: var(--fs-lg);
223
+ font-weight: var(--fw-semibold);
224
+ }
225
+ .panel-page {
226
+ color: var(--text-muted);
227
+ font-size: var(--fs-sm);
228
+ }
229
+ .panel-page::before {
230
+ content: '·';
231
+ margin-right: var(--sp-2);
232
+ }
233
+
234
+ .panel-nav,
235
+ .panel-nav-mobile {
236
+ grid-area: nav;
237
+ display: flex;
238
+ gap: var(--sp-1);
239
+ padding: var(--sp-2);
240
+ border-bottom: 1px solid var(--border);
241
+ overflow-x: auto;
242
+ scrollbar-width: thin;
243
+ }
244
+ .panel-nav.has-mobile {
245
+ display: none;
246
+ }
247
+
248
+ .panel-body {
249
+ grid-area: body;
250
+ padding: var(--sp-4);
251
+ overflow-y: auto;
252
+ -webkit-overflow-scrolling: touch;
253
+ }
254
+ .panel-foot {
255
+ grid-area: foot;
256
+ display: flex;
257
+ align-items: center;
258
+ justify-content: flex-end;
259
+ gap: var(--sp-2);
260
+ padding: var(--sp-4);
261
+ border-top: 1px solid var(--border);
262
+ }
263
+
264
+ @media (min-width: 48rem) {
265
+ .panel {
266
+ width: min(var(--drawer-w), 100vw);
267
+ padding-top: 0;
268
+ padding-bottom: 0;
269
+ border-left: 1px solid var(--border-strong);
270
+ box-shadow: var(--shadow-lg);
271
+ grid-template-columns: minmax(0, 1fr);
272
+ grid-template-rows: auto minmax(0, 1fr) auto;
273
+ grid-template-areas:
274
+ 'head'
275
+ 'body'
276
+ 'foot';
277
+ }
278
+ .drawer[data-side='left'] .panel {
279
+ border-left: 0;
280
+ border-right: 1px solid var(--border-strong);
281
+ }
282
+ .panel.has-nav {
283
+ grid-template-columns: var(--drawer-nav-w) minmax(0, 1fr);
284
+ grid-template-areas:
285
+ 'head head'
286
+ 'nav body'
287
+ 'foot foot';
288
+ }
289
+ .panel-nav,
290
+ .panel-nav.has-mobile {
291
+ display: flex;
292
+ flex-direction: column;
293
+ overflow-x: hidden;
294
+ overflow-y: auto;
295
+ border-bottom: 0;
296
+ border-right: 1px solid var(--border);
297
+ }
298
+ .panel-nav-mobile {
299
+ display: none;
300
+ }
301
+ }
302
+ </style>
@@ -0,0 +1,35 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from '../atoms/Icon.svelte';
3
+ type $$ComponentProps = {
4
+ /** Controlled visibility. When provided the `<dialog>` stays mounted and
5
+ * `showModal()`/`close()` follow the value; closing sets it back to false.
6
+ * Omit to open on mount (mount/unmount the component to show/hide). */
7
+ open?: boolean;
8
+ side?: 'left' | 'right';
9
+ /** Panel width on wide viewports (any CSS length); clamps to 100vw. Below
10
+ * 48rem the panel is always full-screen. */
11
+ width?: string;
12
+ /** Width of the `nav` column on wide viewports. */
13
+ navWidth?: string;
14
+ title: string;
15
+ /** Name of the current page, shown after the title in the header. */
16
+ page?: string;
17
+ /** Leading header glyph. */
18
+ icon?: IconName;
19
+ onclose?: () => void;
20
+ closeLabel?: string;
21
+ /** Replaces the whole header row (title, page, close button). */
22
+ header?: Snippet;
23
+ /** Vertical page list beside the content on wide viewports; on narrow
24
+ * viewports it becomes a horizontal strip above the content unless
25
+ * `navMobile` is provided. */
26
+ nav?: Snippet;
27
+ navMobile?: Snippet;
28
+ children: Snippet;
29
+ footer?: Snippet;
30
+ class?: string;
31
+ style?: string;
32
+ };
33
+ declare const Drawer: import("svelte").Component<$$ComponentProps, {}, "open">;
34
+ type Drawer = ReturnType<typeof Drawer>;
35
+ export default Drawer;
@@ -5,6 +5,9 @@
5
5
  icon?: import('../atoms/Icon.svelte').IconName;
6
6
  danger?: boolean;
7
7
  disabled?: boolean;
8
+ /** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
9
+ tag?: string;
10
+ tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
8
11
  }
9
12
  </script>
10
13
 
@@ -13,10 +16,12 @@
13
16
  // `menuitem`s navigable with ↑/↓/Home/End, activated with Enter/Space (and
14
17
  // click). Selecting an item runs its action and closes the menu. Focus moves
15
18
  // to the first item when the menu opens. Escape / click-outside close it
16
- // (inherited from the native popover).
19
+ // (inherited from the native popover). An item's `tag` renders as a soft
20
+ // Badge pill after the label; the `tag` snippet replaces that pill.
17
21
  import type { ComponentProps, Snippet } from 'svelte';
18
22
  import Popover from './Popover.svelte';
19
23
  import Icon from '../atoms/Icon.svelte';
24
+ import Badge from '../atoms/Badge.svelte';
20
25
 
21
26
  type PopoverProps = ComponentProps<typeof Popover>;
22
27
  type TriggerChrome = Pick<
@@ -40,6 +45,7 @@
40
45
  label,
41
46
  items,
42
47
  trigger,
48
+ tag,
43
49
  placement = 'bottom-start',
44
50
  variant,
45
51
  tone,
@@ -58,6 +64,8 @@
58
64
  label: string;
59
65
  items: MenuItem[];
60
66
  trigger: Snippet;
67
+ /** Custom trailing content for items that carry a `tag`. */
68
+ tag?: Snippet<[MenuItem]>;
61
69
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
62
70
  } & TriggerChrome = $props();
63
71
 
@@ -133,6 +141,15 @@
133
141
  >
134
142
  {#if item.icon}<Icon name={item.icon} />{/if}
135
143
  <span>{item.label}</span>
144
+ {#if item.tag !== undefined}
145
+ {#if tag}
146
+ {@render tag(item)}
147
+ {:else}
148
+ <span class="menu-tag">
149
+ <Badge size="xs" tone={item.tagTone ?? 'neutral'} border={false}>{item.tag}</Badge>
150
+ </span>
151
+ {/if}
152
+ {/if}
136
153
  </button>
137
154
  {/each}
138
155
  </div>
@@ -166,6 +183,12 @@
166
183
  .menu-item.danger {
167
184
  color: var(--danger);
168
185
  }
186
+ .menu-tag {
187
+ display: inline-flex;
188
+ flex: none;
189
+ margin-inline-start: auto;
190
+ padding-inline-start: var(--sp-3);
191
+ }
169
192
  .menu-item:disabled {
170
193
  opacity: 0.45;
171
194
  cursor: not-allowed;
@@ -4,6 +4,9 @@ export interface MenuItem {
4
4
  icon?: import('../atoms/Icon.svelte').IconName;
5
5
  danger?: boolean;
6
6
  disabled?: boolean;
7
+ /** Free-form trailing pill ("admin", "pro", "beta"…), rendered as a Badge after the label. */
8
+ tag?: string;
9
+ tagTone?: import('svelte').ComponentProps<typeof import('../atoms/Badge.svelte').default>['tone'];
7
10
  }
8
11
  import type { ComponentProps, Snippet } from 'svelte';
9
12
  import Popover from './Popover.svelte';
@@ -13,6 +16,8 @@ type $$ComponentProps = {
13
16
  label: string;
14
17
  items: MenuItem[];
15
18
  trigger: Snippet;
19
+ /** Custom trailing content for items that carry a `tag`. */
20
+ tag?: Snippet<[MenuItem]>;
16
21
  placement?: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end';
17
22
  } & TriggerChrome;
18
23
  declare const Menu: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -3,40 +3,78 @@
3
3
  value: string;
4
4
  label: string;
5
5
  hint?: string;
6
+ description?: string;
7
+ note?: string;
6
8
  disabled?: boolean;
7
9
  }
8
10
  </script>
9
11
 
10
12
  <script lang="ts">
11
- // Radio group built on native <input type="radio"> sharing one `name`, so the
12
- // browser handles single-selection, arrow-key navigation and form
13
- // participation. Wrapped in a labelled `radiogroup` with token-styled dots.
14
- // `value` is bindable.
13
+ import type { Snippet } from 'svelte';
14
+
15
+ // Native <input type="radio"> sharing one `name`: the browser handles
16
+ // single-selection, arrow keys and form participation. `variant="rows"`
17
+ // turns each option into a bordered row with optional trailing `action`
18
+ // (outside the label, so clicking it never toggles) and a `below` panel.
15
19
  let {
16
20
  options,
17
21
  value = $bindable(),
18
22
  name = `radio-${Math.random().toString(36).slice(2, 8)}`,
19
23
  label,
24
+ variant = 'list',
25
+ action,
26
+ below,
20
27
  class: klass = ''
21
28
  }: {
22
29
  options: RadioOption[];
23
30
  value?: string;
24
31
  name?: string;
25
32
  label: string;
33
+ variant?: 'list' | 'rows';
34
+ action?: Snippet<[RadioOption]>;
35
+ below?: Snippet<[RadioOption]>;
26
36
  class?: string;
27
37
  } = $props();
28
38
  </script>
29
39
 
30
- <div role="radiogroup" aria-label={label} class="radio-group {klass}" data-tsu="RadioGroup">
40
+ <div
41
+ role="radiogroup"
42
+ aria-label={label}
43
+ class="radio-group {klass}"
44
+ class:rows={variant === 'rows'}
45
+ data-tsu="RadioGroup"
46
+ data-variant={variant}
47
+ >
31
48
  {#each options as o (o.value)}
32
- <label class="radio" class:disabled={o.disabled}>
33
- <input type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
34
- <span class="dot-ctl" aria-hidden="true"></span>
35
- <span class="texts">
36
- <span class="label-text">{o.label}</span>
37
- {#if o.hint}<span class="hint">{o.hint}</span>{/if}
38
- </span>
39
- </label>
49
+ {#if variant === 'rows'}
50
+ <div class="row-wrap">
51
+ <div class="row" class:selected={o.value === value} class:disabled={o.disabled}>
52
+ <label class="radio">
53
+ <input type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
54
+ <span class="dot-ctl" aria-hidden="true"></span>
55
+ <span class="texts">
56
+ <span class="label-line">
57
+ <span class="label-text">{o.label}</span>
58
+ {#if o.note}<span class="note">{o.note}</span>{/if}
59
+ </span>
60
+ {#if o.description}<span class="description">{o.description}</span>{/if}
61
+ {#if o.hint}<span class="hint">{o.hint}</span>{/if}
62
+ </span>
63
+ </label>
64
+ {#if action}<span class="action">{@render action(o)}</span>{/if}
65
+ </div>
66
+ {#if below}<div class="below">{@render below(o)}</div>{/if}
67
+ </div>
68
+ {:else}
69
+ <label class="radio" class:disabled={o.disabled}>
70
+ <input type="radio" {name} value={o.value} bind:group={value} disabled={o.disabled} />
71
+ <span class="dot-ctl" aria-hidden="true"></span>
72
+ <span class="texts">
73
+ <span class="label-text">{o.label}</span>
74
+ {#if o.hint}<span class="hint">{o.hint}</span>{/if}
75
+ </span>
76
+ </label>
77
+ {/if}
40
78
  {/each}
41
79
  </div>
42
80
 
@@ -54,8 +92,12 @@
54
92
  font-size: var(--fs-sm);
55
93
  color: var(--text);
56
94
  }
57
- .radio.disabled {
95
+ .radio.disabled,
96
+ .row.disabled .radio {
58
97
  cursor: not-allowed;
98
+ }
99
+ .radio.disabled,
100
+ .row.disabled {
59
101
  opacity: 0.45;
60
102
  }
61
103
  input {
@@ -102,9 +144,73 @@
102
144
  display: flex;
103
145
  flex-direction: column;
104
146
  gap: 1px;
147
+ min-width: 0;
105
148
  }
106
149
  .hint {
107
150
  font-size: var(--fs-xs);
108
151
  color: var(--text-faint);
109
152
  }
153
+
154
+ .rows {
155
+ gap: var(--sp-1);
156
+ }
157
+ .row-wrap {
158
+ display: flex;
159
+ flex-direction: column;
160
+ }
161
+ .row {
162
+ display: flex;
163
+ align-items: flex-start;
164
+ gap: var(--sp-2);
165
+ padding: var(--pad, var(--sp-2) var(--sp-3));
166
+ border: 1px solid var(--border);
167
+ border-radius: var(--r-md);
168
+ transition:
169
+ border-color 0.12s var(--ease),
170
+ background-color 0.12s var(--ease);
171
+ }
172
+ .row.selected {
173
+ border-color: var(--accent-dim);
174
+ background: var(--bg-elevated);
175
+ }
176
+ .row .radio {
177
+ flex: 1 1 auto;
178
+ min-width: 0;
179
+ }
180
+ .row .texts {
181
+ flex: 1 1 auto;
182
+ }
183
+ .label-line {
184
+ display: flex;
185
+ align-items: baseline;
186
+ gap: var(--sp-2);
187
+ min-width: 0;
188
+ }
189
+ .row .label-text {
190
+ font-weight: 600;
191
+ }
192
+ .note {
193
+ font-size: var(--fs-xs);
194
+ color: var(--text-faint);
195
+ white-space: nowrap;
196
+ }
197
+ .description {
198
+ font-size: var(--fs-xs);
199
+ color: var(--text-muted);
200
+ overflow: hidden;
201
+ text-overflow: ellipsis;
202
+ white-space: nowrap;
203
+ }
204
+ .action {
205
+ flex: none;
206
+ margin-left: auto;
207
+ display: flex;
208
+ align-items: center;
209
+ }
210
+ .below {
211
+ padding: var(--sp-2) 0 0 var(--sp-3);
212
+ }
213
+ .below:empty {
214
+ display: none;
215
+ }
110
216
  </style>
@@ -2,13 +2,19 @@ export interface RadioOption {
2
2
  value: string;
3
3
  label: string;
4
4
  hint?: string;
5
+ description?: string;
6
+ note?: string;
5
7
  disabled?: boolean;
6
8
  }
9
+ import type { Snippet } from 'svelte';
7
10
  type $$ComponentProps = {
8
11
  options: RadioOption[];
9
12
  value?: string;
10
13
  name?: string;
11
14
  label: string;
15
+ variant?: 'list' | 'rows';
16
+ action?: Snippet<[RadioOption]>;
17
+ below?: Snippet<[RadioOption]>;
12
18
  class?: string;
13
19
  };
14
20
  declare const RadioGroup: import("svelte").Component<$$ComponentProps, {}, "value">;
@@ -4,7 +4,8 @@
4
4
  // it, `sticky` pins it under the app header and publishes its height as
5
5
  // `--section-header-h` on the parent so sibling sticky content can offset
6
6
  // itself. `collapsible` turns the title into a disclosure button that
7
- // toggles the `children` block rendered beneath.
7
+ // toggles the `children` block rendered beneath. `variant="group"` is the
8
+ // non-wrapping list-group row: `lead` · title · count · flexible rule · actions.
8
9
  import type { Snippet } from 'svelte';
9
10
  import Heading from '../atoms/Heading.svelte';
10
11
  import Icon, { type IconName } from '../atoms/Icon.svelte';
@@ -16,6 +17,8 @@
16
17
  let {
17
18
  title,
18
19
  label,
20
+ variant = 'default',
21
+ lead,
19
22
  level = 2,
20
23
  size,
21
24
  subtitle,
@@ -36,6 +39,9 @@
36
39
  title?: string;
37
40
  /** Alias for `title`. */
38
41
  label?: string;
42
+ variant?: 'default' | 'group';
43
+ /** Leading content before the title (status dot, badge); group rows. */
44
+ lead?: Snippet;
39
45
  level?: 1 | 2 | 3 | 4;
40
46
  size?: Size;
41
47
  subtitle?: string;
@@ -76,6 +82,7 @@
76
82
  data-tsu="SectionHeader"
77
83
  class="section-header sh-{tone} {klass}"
78
84
  class:sh-divider={divider}
85
+ class:sh-group={variant === 'group'}
79
86
  class:sh-sticky={sticky}
80
87
  class:sh-uppercase={uppercase}
81
88
  class:sh-open={open}
@@ -99,6 +106,9 @@
99
106
  {#if subtitle}
100
107
  <Text variant="caption" class="sh-subtitle">{subtitle}</Text>
101
108
  {/if}
109
+ {#if variant === 'group'}
110
+ <span class="sh-rule" aria-hidden="true"></span>
111
+ {/if}
102
112
  {#if actions}
103
113
  <div class="sh-actions">{@render actions()}</div>
104
114
  {/if}
@@ -109,6 +119,9 @@
109
119
  </div>
110
120
 
111
121
  {#snippet head()}
122
+ {#if lead}
123
+ <span class="sh-lead">{@render lead()}</span>
124
+ {/if}
112
125
  {#if hue !== undefined}
113
126
  <span class="sh-swatch" style:--sh-hue={hue} aria-hidden="true"></span>
114
127
  {/if}
@@ -148,6 +161,27 @@
148
161
  padding-bottom: var(--sp-2);
149
162
  border-bottom: 1px solid var(--border);
150
163
  }
164
+ .sh-group > .sh-row {
165
+ flex-wrap: nowrap;
166
+ gap: var(--sp-2);
167
+ }
168
+ .sh-lead {
169
+ display: inline-flex;
170
+ align-items: center;
171
+ gap: var(--sp-2);
172
+ flex: none;
173
+ }
174
+ .sh-rule {
175
+ flex: 1 1 auto;
176
+ min-width: var(--sp-4);
177
+ height: 1px;
178
+ background: var(--border);
179
+ }
180
+ .sh-group .sh-actions {
181
+ flex: none;
182
+ flex-wrap: nowrap;
183
+ margin-inline-start: 0;
184
+ }
151
185
  .sh-sticky {
152
186
  position: sticky;
153
187
  top: var(--sticky-offset, var(--header-h, 0));
@@ -6,6 +6,9 @@ type $$ComponentProps = {
6
6
  title?: string;
7
7
  /** Alias for `title`. */
8
8
  label?: string;
9
+ variant?: 'default' | 'group';
10
+ /** Leading content before the title (status dot, badge); group rows. */
11
+ lead?: Snippet;
9
12
  level?: 1 | 2 | 3 | 4;
10
13
  size?: Size;
11
14
  subtitle?: string;
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
- // A timestamp you can read five ways and inspect in one place. Inline it
3
- // renders in the chosen `mode` (date / time / datetime / relative / iso),
2
+ // A timestamp you can read six ways and inspect in one place. Inline it
3
+ // renders in the chosen `mode` (date / time / datetime / relative / iso /
4
+ // short-iso),
4
5
  // in the viewer's zone or UTC via the `utc` flag; by default clicking it
5
6
  // opens a popover that lays out the same instant as ISO-UTC, local, relative,
6
7
  // the IANA zone and the unix epoch — so the one value answers "when,
@@ -45,8 +46,8 @@
45
46
  value: TimeInput | null | undefined;
46
47
  /** Inline display mode. Default 'datetime'. */
47
48
  mode?: TimestampMode;
48
- /** Render the date/time/datetime modes in UTC rather than the viewer's
49
- * zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
49
+ /** Render the date/time/datetime/short-iso modes in UTC rather than the
50
+ * viewer's zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
50
51
  * (zoneless). Handy for calendar-date fields, where a local midnight-UTC
51
52
  * value would otherwise show the wrong day. */
52
53
  utc?: boolean;
@@ -101,7 +102,8 @@
101
102
  { id: 'time', name: 'Time' },
102
103
  { id: 'datetime', name: 'Date+time' },
103
104
  { id: 'relative', name: 'Relative' },
104
- { id: 'iso', name: 'ISO' }
105
+ { id: 'iso', name: 'ISO' },
106
+ { id: 'short-iso', name: 'YYYY-MM-DD' }
105
107
  ];
106
108
 
107
109
  const rows = $derived(
@@ -7,8 +7,8 @@ type $$ComponentProps = {
7
7
  value: TimeInput | null | undefined;
8
8
  /** Inline display mode. Default 'datetime'. */
9
9
  mode?: TimestampMode;
10
- /** Render the date/time/datetime modes in UTC rather than the viewer's
11
- * zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
10
+ /** Render the date/time/datetime/short-iso modes in UTC rather than the
11
+ * viewer's zone. Default false. Ignored by 'iso' (always UTC) and 'relative'
12
12
  * (zoneless). Handy for calendar-date fields, where a local midnight-UTC
13
13
  * value would otherwise show the wrong day. */
14
14
  utc?: boolean;
@@ -114,6 +114,7 @@
114
114
  align-items: center;
115
115
  flex: 1 1 0;
116
116
  min-width: 0;
117
+ min-height: max-content;
117
118
  overflow: hidden;
118
119
  }
119
120
  .rail.full {
package/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export { default as Link } from './components/atoms/Link.svelte';
17
17
  export { default as Progress } from './components/atoms/Progress.svelte';
18
18
  export { default as Scrim } from './components/atoms/Scrim.svelte';
19
19
  export { default as SegmentedProgress, type ProgressSegment, } from './components/atoms/SegmentedProgress.svelte';
20
+ export type { SelectOption } from './components/atoms/Select.svelte';
20
21
  export { default as Select } from './components/atoms/Select.svelte';
21
22
  export { default as Skeleton } from './components/atoms/Skeleton.svelte';
22
23
  export { default as Slider } from './components/atoms/Slider.svelte';
@@ -41,6 +42,7 @@ export { default as CapBar } from './components/molecules/CapBar.svelte';
41
42
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
42
43
  export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
43
44
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
45
+ export { default as Drawer } from './components/molecules/Drawer.svelte';
44
46
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
45
47
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
46
48
  export { default as Field } from './components/molecules/Field.svelte';
package/dist/index.js CHANGED
@@ -48,6 +48,7 @@ export { default as CapBar } from './components/molecules/CapBar.svelte';
48
48
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
49
49
  export { default as ConfirmModal } from './components/molecules/ConfirmModal.svelte';
50
50
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
51
+ export { default as Drawer } from './components/molecules/Drawer.svelte';
51
52
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
52
53
  export { default as EmptyState } from './components/molecules/EmptyState.svelte';
53
54
  // ---- molecules ----
@@ -110,7 +110,7 @@ function parseOr(sc, schema) {
110
110
  children.push(first);
111
111
  for (;;) {
112
112
  const kw = peekKeyword(sc);
113
- if (!kw || kw.word !== 'OR')
113
+ if (kw?.word !== 'OR')
114
114
  break;
115
115
  sc.i = kw.next;
116
116
  const next = parseAnd(sc, schema);
@@ -1,10 +1,10 @@
1
1
  export type TimeInput = Date | number | string;
2
2
  /**
3
3
  * Which slice of the instant the inline text shows. Orthogonal to the zone:
4
- * `date`/`time`/`datetime` honour the `utc` flag, while `iso` is always UTC
5
- * (by definition) and `relative` is zoneless.
4
+ * `date`/`time`/`datetime`/`short-iso` honour the `utc` flag, while `iso` is
5
+ * always UTC (by definition) and `relative` is zoneless.
6
6
  */
7
- export type TimestampMode = 'date' | 'time' | 'datetime' | 'relative' | 'iso';
7
+ export type TimestampMode = 'date' | 'time' | 'datetime' | 'relative' | 'iso' | 'short-iso';
8
8
  /**
9
9
  * Coerce a loose input to a Date, or null when it's missing or can't be parsed.
10
10
  * Accepting null/undefined lets callers pass an optional field straight through
@@ -20,6 +20,12 @@ export declare function toISO(d: Date): string;
20
20
  * without the off-by-one a local midnight-UTC value otherwise shows.
21
21
  */
22
22
  export declare function toLocale(d: Date, parts?: 'date' | 'time' | 'datetime', utc?: boolean): string;
23
+ /**
24
+ * Calendar date as `YYYY-MM-DD`, locale-independent — in UTC or, with
25
+ * `utc=false`, the viewer's zone (so a local evening past midnight UTC still
26
+ * reads as the viewer's day).
27
+ */
28
+ export declare function toShortISO(d: Date, utc?: boolean): string;
23
29
  /** Unix epoch in whole seconds. */
24
30
  export declare function toEpochSeconds(d: Date): number;
25
31
  /** The viewer's IANA time zone — `Asia/Tokyo`, `UTC`, … */
@@ -38,7 +44,7 @@ export declare function localTimeZone(): string;
38
44
  export declare function relativeTime(value: TimeInput | null | undefined, now?: number, precision?: boolean): string;
39
45
  /**
40
46
  * Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
41
- * zone for the date/time/datetime modes (ignored by `iso`, always UTC, and
42
- * `relative`, zoneless). Returns '' for unparseable input.
47
+ * zone for the date/time/datetime/short-iso modes (ignored by `iso`, always
48
+ * UTC, and `relative`, zoneless). Returns '' for unparseable input.
43
49
  */
44
50
  export declare function formatTimestamp(value: TimeInput | null | undefined, mode: TimestampMode, now?: number, utc?: boolean, precision?: boolean): string;
package/dist/timestamp.js CHANGED
@@ -41,6 +41,18 @@ export function toLocale(d, parts = 'datetime', utc = false) {
41
41
  }
42
42
  return new Intl.DateTimeFormat(undefined, opts).format(d);
43
43
  }
44
+ /**
45
+ * Calendar date as `YYYY-MM-DD`, locale-independent — in UTC or, with
46
+ * `utc=false`, the viewer's zone (so a local evening past midnight UTC still
47
+ * reads as the viewer's day).
48
+ */
49
+ export function toShortISO(d, utc = true) {
50
+ const y = utc ? d.getUTCFullYear() : d.getFullYear();
51
+ const m = (utc ? d.getUTCMonth() : d.getMonth()) + 1;
52
+ const day = utc ? d.getUTCDate() : d.getDate();
53
+ const pad = (n, w = 2) => String(n).padStart(w, '0');
54
+ return `${pad(y, 4)}-${pad(m)}-${pad(day)}`;
55
+ }
44
56
  /** Unix epoch in whole seconds. */
45
57
  export function toEpochSeconds(d) {
46
58
  return Math.floor(d.getTime() / 1000);
@@ -103,8 +115,8 @@ export function relativeTime(value, now = Date.now(), precision = false) {
103
115
  }
104
116
  /**
105
117
  * Render a date in the chosen inline mode. `utc` selects UTC over the viewer's
106
- * zone for the date/time/datetime modes (ignored by `iso`, always UTC, and
107
- * `relative`, zoneless). Returns '' for unparseable input.
118
+ * zone for the date/time/datetime/short-iso modes (ignored by `iso`, always
119
+ * UTC, and `relative`, zoneless). Returns '' for unparseable input.
108
120
  */
109
121
  export function formatTimestamp(value, mode, now, utc = false, precision = false) {
110
122
  const d = toDate(value);
@@ -113,6 +125,8 @@ export function formatTimestamp(value, mode, now, utc = false, precision = false
113
125
  switch (mode) {
114
126
  case 'iso':
115
127
  return toISO(d);
128
+ case 'short-iso':
129
+ return toShortISO(d, utc);
116
130
  case 'date':
117
131
  case 'time':
118
132
  case 'datetime':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.36.0",
3
+ "version": "0.37.1",
4
4
  "description": "Minimal, dependency-free Svelte 5 + pure-CSS UI kit. Token-driven atoms, molecules & layouts with theming out of the box.",
5
5
  "type": "module",
6
6
  "license": "MIT",