@dorsk/tsumikit 0.30.0 → 0.32.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.
@@ -29,6 +29,8 @@
29
29
  width?: string;
30
30
  /** Fires with the current value on Enter. */
31
31
  onenter?: (value: string) => void;
32
+ /** Alias of `onenter`; both fire when given. */
33
+ onsubmit?: (value: string) => void;
32
34
  class?: string;
33
35
  value?: HTMLInputAttributes['value'];
34
36
  el?: HTMLInputElement | null;
@@ -46,6 +48,7 @@
46
48
  shape = 'square',
47
49
  width,
48
50
  onenter,
51
+ onsubmit,
49
52
  onkeydown,
50
53
  class: klass = '',
51
54
  value = $bindable(),
@@ -58,6 +61,7 @@
58
61
  function handleKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLInputElement }) {
59
62
  onkeydown?.(e);
60
63
  if (onenter && e.key === 'Enter' && !e.defaultPrevented) onenter(String(value ?? ''));
64
+ if (onsubmit && e.key === 'Enter' && !e.defaultPrevented) onsubmit(String(value ?? ''));
61
65
  }
62
66
 
63
67
  function clear() {
@@ -82,7 +86,7 @@
82
86
  style:width={wrapped ? undefined : width}
83
87
  bind:value
84
88
  {...rest}
85
- onkeydown={onenter || onkeydown ? handleKeydown : undefined}
89
+ onkeydown={onenter || onsubmit || onkeydown ? handleKeydown : undefined}
86
90
  aria-invalid={invalid || undefined}
87
91
  />
88
92
  {/snippet}
@@ -20,6 +20,8 @@ type Props = Omit<HTMLInputAttributes, 'size'> & {
20
20
  width?: string;
21
21
  /** Fires with the current value on Enter. */
22
22
  onenter?: (value: string) => void;
23
+ /** Alias of `onenter`; both fire when given. */
24
+ onsubmit?: (value: string) => void;
23
25
  class?: string;
24
26
  value?: HTMLInputAttributes['value'];
25
27
  el?: HTMLInputElement | null;
@@ -0,0 +1,96 @@
1
+ <script lang="ts">
2
+ // Keyboard shortcut chip: `keys="mod+enter"` (or an array of tokens) renders
3
+ // one <kbd> per key with platform-aware glyphs — `mod` is ⌘ on Mac, Ctrl
4
+ // elsewhere (Ctrl during SSR).
5
+ type Props = {
6
+ keys: string | string[];
7
+ size?: 'sm' | 'md';
8
+ class?: string;
9
+ };
10
+
11
+ let { keys, size = 'sm', class: klass = '' }: Props = $props();
12
+
13
+ const isMac =
14
+ typeof navigator !== 'undefined' &&
15
+ /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent || '');
16
+
17
+ function label(token: string): string {
18
+ const t = token.trim().toLowerCase();
19
+ switch (t) {
20
+ case 'mod':
21
+ return isMac ? '⌘' : 'Ctrl';
22
+ case 'ctrl':
23
+ case 'control':
24
+ return 'Ctrl';
25
+ case 'meta':
26
+ case 'cmd':
27
+ case 'command':
28
+ return '⌘';
29
+ case 'alt':
30
+ case 'option':
31
+ return isMac ? '⌥' : 'Alt';
32
+ case 'shift':
33
+ return '⇧';
34
+ case 'enter':
35
+ case 'return':
36
+ return '↵';
37
+ case 'esc':
38
+ case 'escape':
39
+ return 'Esc';
40
+ case 'tab':
41
+ return 'Tab';
42
+ case 'space':
43
+ return 'Space';
44
+ case 'up':
45
+ return '↑';
46
+ case 'down':
47
+ return '↓';
48
+ case 'left':
49
+ return '←';
50
+ case 'right':
51
+ return '→';
52
+ default:
53
+ return t.charAt(0).toUpperCase() + t.slice(1);
54
+ }
55
+ }
56
+
57
+ const tokens = $derived(
58
+ (Array.isArray(keys) ? keys : keys.split('+')).filter((k) => k.trim() !== '').map(label)
59
+ );
60
+ </script>
61
+
62
+ <span data-tsu="Kbd" class="kbd-group {klass}" class:kbd-md={size === 'md'}>
63
+ {#each tokens as token, i (i)}
64
+ {#if i > 0}<span class="kbd-sep" aria-hidden="true">+</span>{/if}
65
+ <kbd class="kbd">{token}</kbd>
66
+ {/each}
67
+ </span>
68
+
69
+ <style>
70
+ .kbd-group {
71
+ display: inline-flex;
72
+ align-items: center;
73
+ gap: 2px;
74
+ font-size: var(--fs-xs);
75
+ color: var(--text-muted);
76
+ vertical-align: baseline;
77
+ }
78
+ .kbd-md {
79
+ font-size: var(--fs-sm);
80
+ }
81
+ .kbd {
82
+ font-family: var(--font-mono);
83
+ font-size: inherit;
84
+ line-height: 1.4;
85
+ padding: 0 var(--sp-1);
86
+ border: 1px solid var(--border-strong);
87
+ border-bottom-width: 2px;
88
+ border-radius: var(--r-sm);
89
+ background: var(--bg-elevated-2);
90
+ color: inherit;
91
+ }
92
+ .kbd-sep {
93
+ font-size: 0.85em;
94
+ color: var(--text-faint);
95
+ }
96
+ </style>
@@ -0,0 +1,8 @@
1
+ type Props = {
2
+ keys: string | string[];
3
+ size?: 'sm' | 'md';
4
+ class?: string;
5
+ };
6
+ declare const Kbd: import("svelte").Component<Props, {}, "">;
7
+ type Kbd = ReturnType<typeof Kbd>;
8
+ export default Kbd;
@@ -11,6 +11,9 @@
11
11
  // transparently — for the "native select overlaid on a custom trigger" pattern
12
12
  // (SelectButton): the platform popup with no custom outside-click logic, while
13
13
  // a styled trigger shows through underneath.
14
+ //
15
+ // `variant="embedded"` is a borderless, elevated-surface select for inline
16
+ // use inside cards/toolbars; it hides the chevron unless asked for explicitly.
14
17
  import type { Snippet } from 'svelte';
15
18
  import type { HTMLSelectAttributes } from 'svelte/elements';
16
19
  import Icon from './Icon.svelte';
@@ -18,7 +21,7 @@
18
21
  // `size` shadows the native option-count attribute (unused in token layouts) to
19
22
  // expose the sm|md height scale instead.
20
23
  type Props = Omit<HTMLSelectAttributes, 'size'> & {
21
- variant?: 'default' | 'ghost';
24
+ variant?: 'default' | 'ghost' | 'embedded';
22
25
  /** Error state: danger border + aria-invalid. */
23
26
  invalid?: boolean;
24
27
  /** Compact inline form: smaller padding + font for dense toolbars/headers.
@@ -29,6 +32,10 @@
29
32
  size?: 'sm' | 'md';
30
33
  /** Draw the custom chevron (default). Set false for a bare inline select. */
31
34
  chevron?: boolean;
35
+ /** `auto` sizes to the selected option instead of filling the row. */
36
+ width?: 'full' | 'auto';
37
+ /** Fill the available width of a flex/Cluster row (flex: 1). */
38
+ grow?: boolean;
32
39
  class?: string;
33
40
  value?: HTMLSelectAttributes['value'];
34
41
  children?: Snippet;
@@ -39,7 +46,9 @@
39
46
  invalid = false,
40
47
  compact = false,
41
48
  size = 'md',
42
- chevron = true,
49
+ chevron,
50
+ width = 'full',
51
+ grow = false,
43
52
  class: klass = '',
44
53
  value = $bindable(),
45
54
  children,
@@ -47,6 +56,7 @@
47
56
  }: Props = $props();
48
57
 
49
58
  const small = $derived(compact || size === 'sm');
59
+ const showChevron = $derived(chevron ?? variant !== 'embedded');
50
60
  </script>
51
61
 
52
62
  {#if variant === 'ghost'}
@@ -54,18 +64,26 @@
54
64
  {@render children?.()}
55
65
  </select>
56
66
  {:else}
57
- <div class="select-wrap {klass}" class:no-chevron={!chevron} data-tsu="Select">
67
+ <div
68
+ class="select-wrap {klass}"
69
+ class:no-chevron={!showChevron}
70
+ class:w-auto={width === 'auto'}
71
+ class:select-grow={grow}
72
+ data-tsu="Select"
73
+ >
58
74
  <select
59
75
  class="select"
60
76
  class:compact={small}
61
77
  class:select-sm={size === 'sm'}
78
+ class:w-auto={width === 'auto'}
79
+ class:embedded={variant === 'embedded'}
62
80
  bind:value
63
81
  {...rest}
64
82
  aria-invalid={invalid || undefined}
65
83
  >
66
84
  {@render children?.()}
67
85
  </select>
68
- {#if chevron}
86
+ {#if showChevron}
69
87
  <span class="select-chevron" aria-hidden="true">
70
88
  <Icon name="chevron-down" size={16} />
71
89
  </span>
@@ -79,6 +97,14 @@
79
97
  display: block;
80
98
  width: 100%;
81
99
  }
100
+ .select-wrap.w-auto {
101
+ display: inline-block;
102
+ width: auto;
103
+ }
104
+ .select-wrap.select-grow {
105
+ flex: 1 1 0;
106
+ min-width: 0;
107
+ }
82
108
  .select {
83
109
  width: 100%;
84
110
  padding: var(--sp-3);
@@ -111,6 +137,14 @@
111
137
  .select.select-sm {
112
138
  height: var(--control-height-compact);
113
139
  }
140
+ .select.w-auto {
141
+ width: auto;
142
+ }
143
+ .select.embedded {
144
+ background: var(--bg-elevated-2);
145
+ border: none;
146
+ border-radius: var(--r-sm);
147
+ }
114
148
  .select:focus {
115
149
  outline: none;
116
150
  border-color: var(--accent);
@@ -1,7 +1,7 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLSelectAttributes } from 'svelte/elements';
3
3
  type Props = Omit<HTMLSelectAttributes, 'size'> & {
4
- variant?: 'default' | 'ghost';
4
+ variant?: 'default' | 'ghost' | 'embedded';
5
5
  /** Error state: danger border + aria-invalid. */
6
6
  invalid?: boolean;
7
7
  /** Compact inline form: smaller padding + font for dense toolbars/headers.
@@ -12,6 +12,10 @@ type Props = Omit<HTMLSelectAttributes, 'size'> & {
12
12
  size?: 'sm' | 'md';
13
13
  /** Draw the custom chevron (default). Set false for a bare inline select. */
14
14
  chevron?: boolean;
15
+ /** `auto` sizes to the selected option instead of filling the row. */
16
+ width?: 'full' | 'auto';
17
+ /** Fill the available width of a flex/Cluster row (flex: 1). */
18
+ grow?: boolean;
15
19
  class?: string;
16
20
  value?: HTMLSelectAttributes['value'];
17
21
  children?: Snippet;
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ // Shimmering placeholder bar(s) standing in for content that is still
3
+ // loading. Decorative only — the surrounding region should carry aria-busy.
4
+ let {
5
+ width = '100%',
6
+ height = '1em',
7
+ lines = 1,
8
+ circle = false,
9
+ radius = 'var(--r-sm)',
10
+ class: klass = '',
11
+ ...rest
12
+ }: {
13
+ width?: string;
14
+ height?: string;
15
+ /** Stacked bars; the last one is shortened to read as a paragraph. */
16
+ lines?: number;
17
+ /** Round avatar placeholder — width follows height. */
18
+ circle?: boolean;
19
+ radius?: string;
20
+ class?: string;
21
+ [key: string]: unknown;
22
+ } = $props();
23
+
24
+ const w = $derived(circle ? height : width);
25
+ const r = $derived(circle ? '50%' : radius);
26
+ const count = $derived(Math.max(1, Math.floor(lines)));
27
+ </script>
28
+
29
+ <span class="skeleton {klass}" data-tsu="Skeleton" aria-hidden="true" style:width={w} {...rest}>
30
+ {#each { length: count } as _, i (i)}
31
+ <span
32
+ class="skeleton-bar"
33
+ style:height
34
+ style:border-radius={r}
35
+ style:width={count > 1 && i === count - 1 ? '60%' : undefined}
36
+ ></span>
37
+ {/each}
38
+ </span>
39
+
40
+ <style>
41
+ .skeleton {
42
+ display: flex;
43
+ flex-direction: column;
44
+ gap: var(--sp-2);
45
+ max-width: 100%;
46
+ }
47
+ .skeleton-bar {
48
+ display: block;
49
+ width: 100%;
50
+ background: linear-gradient(90deg, var(--bg-elevated-2) 25%, var(--bg-elevated) 50%, var(--bg-elevated-2) 75%);
51
+ background-size: 200% 100%;
52
+ animation: tsu-shimmer 1.4s ease-in-out infinite;
53
+ }
54
+ @keyframes tsu-shimmer {
55
+ from {
56
+ background-position: 200% 0;
57
+ }
58
+ to {
59
+ background-position: -200% 0;
60
+ }
61
+ }
62
+ @media (prefers-reduced-motion: reduce) {
63
+ .skeleton-bar {
64
+ animation: none;
65
+ background: var(--bg-elevated-2);
66
+ }
67
+ }
68
+ </style>
@@ -0,0 +1,14 @@
1
+ type $$ComponentProps = {
2
+ width?: string;
3
+ height?: string;
4
+ /** Stacked bars; the last one is shortened to read as a paragraph. */
5
+ lines?: number;
6
+ /** Round avatar placeholder — width follows height. */
7
+ circle?: boolean;
8
+ radius?: string;
9
+ class?: string;
10
+ [key: string]: unknown;
11
+ };
12
+ declare const Skeleton: import("svelte").Component<$$ComponentProps, {}, "">;
13
+ type Skeleton = ReturnType<typeof Skeleton>;
14
+ export default Skeleton;
@@ -14,6 +14,10 @@
14
14
  // *floor* (min-height) rather than a fixed height: the textarea still grows
15
15
  // with content, but dragging up reserves extra space. (A bottom handle makes
16
16
  // no sense alongside content-driven sizing, so it's suppressed.)
17
+ //
18
+ // `onsubmit` fires with the value on Enter (`submitOn="enter"`, Shift+Enter
19
+ // still inserts a newline) or Ctrl/Meta+Enter (`submitOn="mod-enter"`, the
20
+ // default whenever `onsubmit` is given).
17
21
  import type { HTMLTextareaAttributes } from 'svelte/elements';
18
22
  import { autoresize as autoresizeAction } from '../../autoresize';
19
23
 
@@ -28,6 +32,11 @@
28
32
  /** Error state: danger border + aria-invalid (also styles if a consumer
29
33
  * sets aria-invalid directly). */
30
34
  invalid?: boolean;
35
+ /** Cap the height (with autoresize: stop growing and scroll past this). */
36
+ maxHeight?: string;
37
+ /** Fires with the current value on the `submitOn` key combo. */
38
+ onsubmit?: (value: string) => void;
39
+ submitOn?: 'enter' | 'mod-enter' | 'none';
31
40
  class?: string;
32
41
  value?: HTMLTextareaAttributes['value'];
33
42
  el?: HTMLTextAreaElement | null;
@@ -39,6 +48,10 @@
39
48
  size = 'md',
40
49
  resize = 'bottom',
41
50
  invalid = false,
51
+ maxHeight,
52
+ onsubmit,
53
+ submitOn = 'none',
54
+ onkeydown,
42
55
  class: klass = '',
43
56
  value = $bindable(),
44
57
  el = $bindable(null),
@@ -48,6 +61,18 @@
48
61
  // With autoresize, only the top handle (a min-height floor) is meaningful.
49
62
  const handleEdge = $derived(autoresize ? (resize === 'top' ? 'top' : 'none') : resize);
50
63
  const showHandle = $derived(handleEdge !== 'none');
64
+ const submitMode = $derived(submitOn === 'none' && onsubmit ? 'mod-enter' : submitOn);
65
+
66
+ function handleKeydown(e: KeyboardEvent & { currentTarget: EventTarget & HTMLTextAreaElement }) {
67
+ onkeydown?.(e);
68
+ if (!onsubmit || e.key !== 'Enter' || e.defaultPrevented || submitMode === 'none') return;
69
+ const mod = e.ctrlKey || e.metaKey;
70
+ const submits =
71
+ submitMode === 'enter' ? !e.shiftKey && !mod && !e.altKey : mod && !e.shiftKey && !e.altKey;
72
+ if (!submits) return;
73
+ e.preventDefault();
74
+ onsubmit(String(value ?? ''));
75
+ }
51
76
 
52
77
  // --- manual resize drag (mirrors AppShell/Modal: rAF-throttled pointer drag) ---
53
78
  let dragging = $state(false);
@@ -106,9 +131,12 @@
106
131
  class="textarea {klass}"
107
132
  class:mono
108
133
  class:textarea-sm={size === 'sm'}
134
+ class:capped={!!maxHeight}
135
+ style:max-height={maxHeight}
109
136
  bind:value
110
137
  use:autoresizeAction={typeof value === 'string' ? value : ''}
111
138
  {...rest}
139
+ onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
112
140
  aria-invalid={invalid || undefined}
113
141
  ></textarea>
114
142
  {:else}
@@ -117,8 +145,11 @@
117
145
  class="textarea {klass}"
118
146
  class:mono
119
147
  class:textarea-sm={size === 'sm'}
148
+ class:capped={!!maxHeight}
149
+ style:max-height={maxHeight}
120
150
  bind:value
121
151
  {...rest}
152
+ onkeydown={onsubmit || onkeydown ? handleKeydown : undefined}
122
153
  aria-invalid={invalid || undefined}
123
154
  ></textarea>
124
155
  {/if}
@@ -165,6 +196,9 @@
165
196
  outline: none;
166
197
  border-color: var(--accent);
167
198
  }
199
+ .textarea.capped {
200
+ overflow-y: auto;
201
+ }
168
202
  .textarea-sm {
169
203
  padding: var(--sp-1) var(--sp-2);
170
204
  font-size: var(--fs-sm);
@@ -10,6 +10,11 @@ type Props = HTMLTextareaAttributes & {
10
10
  /** Error state: danger border + aria-invalid (also styles if a consumer
11
11
  * sets aria-invalid directly). */
12
12
  invalid?: boolean;
13
+ /** Cap the height (with autoresize: stop growing and scroll past this). */
14
+ maxHeight?: string;
15
+ /** Fires with the current value on the `submitOn` key combo. */
16
+ onsubmit?: (value: string) => void;
17
+ submitOn?: 'enter' | 'mod-enter' | 'none';
13
18
  class?: string;
14
19
  value?: HTMLTextareaAttributes['value'];
15
20
  el?: HTMLTextAreaElement | null;
@@ -7,10 +7,13 @@
7
7
  // • a muted, max-width-limited description
8
8
  // • an optional action area (slot via `action`, or a built-in button when
9
9
  // `actionLabel`+`onAction` are passed)
10
- // Everything is token-driven. `compact` tightens spacing for inline/table use.
10
+ // Everything is token-driven. `size` picks the layout: `default`, `compact`
11
+ // (tighter spacing), or `inline` (a single muted line). `loading` swaps the
12
+ // chip for a spinner and marks the root busy.
11
13
  import type { Snippet } from 'svelte';
12
14
  import Button from '../atoms/Button.svelte';
13
15
  import Icon, { type IconName } from '../atoms/Icon.svelte';
16
+ import Spinner from '../atoms/Spinner.svelte';
14
17
 
15
18
  let {
16
19
  title,
@@ -22,8 +25,10 @@
22
25
  // richer (multiple buttons, links) use the `action` snippet instead.
23
26
  actionLabel,
24
27
  onAction,
25
- // Tighter padding/sizing for inline use (empty table body, narrow panels).
28
+ // Legacy alias for size="compact".
26
29
  compact = false,
30
+ size = 'default',
31
+ loading = false,
27
32
  class: klass = '',
28
33
  // Raw SVG markup for a custom glyph — passed straight to `Icon`.
29
34
  iconChildren,
@@ -31,22 +36,38 @@
31
36
  action,
32
37
  ...rest
33
38
  }: {
34
- title: string;
35
- description?: string;
39
+ title?: string;
40
+ description?: string | Snippet;
36
41
  icon?: IconName;
37
42
  tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
38
43
  actionLabel?: string;
39
44
  onAction?: () => void;
40
45
  compact?: boolean;
46
+ size?: 'inline' | 'compact' | 'default';
47
+ loading?: boolean;
41
48
  class?: string;
42
49
  iconChildren?: Snippet;
43
50
  action?: Snippet;
44
51
  [key: string]: unknown;
45
52
  } = $props();
53
+
54
+ const sz = $derived(compact ? 'compact' : size);
46
55
  </script>
47
56
 
48
- <div class="empty empty-{tone} {compact ? 'empty-compact' : ''} {klass}" data-tsu="EmptyState" {...rest}>
49
- {#if icon || iconChildren}
57
+ <div
58
+ class="empty empty-{tone} {klass}"
59
+ class:empty-compact={sz === 'compact'}
60
+ class:empty-inline={sz === 'inline'}
61
+ class:empty-loading={loading}
62
+ aria-busy={loading ? 'true' : undefined}
63
+ data-tsu="EmptyState"
64
+ {...rest}
65
+ >
66
+ {#if loading}
67
+ <span class="empty-chip">
68
+ <Spinner />
69
+ </span>
70
+ {:else if (icon || iconChildren) && sz !== 'inline'}
50
71
  <span class="empty-chip" aria-hidden="true">
51
72
  {#if iconChildren}
52
73
  <Icon>{@render iconChildren()}</Icon>
@@ -55,8 +76,12 @@
55
76
  {/if}
56
77
  </span>
57
78
  {/if}
58
- <p class="empty-title">{title}</p>
59
- {#if description}<p class="empty-desc">{description}</p>{/if}
79
+ {#if title}<p class="empty-title">{title}</p>{/if}
80
+ {#if description}
81
+ <p class="empty-desc">
82
+ {#if typeof description === 'function'}{@render description()}{:else}{description}{/if}
83
+ </p>
84
+ {/if}
60
85
  {#if action}
61
86
  <div class="empty-action">{@render action()}</div>
62
87
  {:else if actionLabel && onAction}
@@ -113,6 +138,30 @@
113
138
  height: 2.25rem;
114
139
  font-size: 1.125rem;
115
140
  }
141
+ .empty-inline {
142
+ flex-direction: row;
143
+ justify-content: center;
144
+ gap: var(--sp-2);
145
+ padding: var(--sp-6) var(--sp-4);
146
+ }
147
+ .empty-inline .empty-chip {
148
+ width: auto;
149
+ height: auto;
150
+ font-size: 1em;
151
+ background: none;
152
+ border: none;
153
+ }
154
+ .empty-inline .empty-title {
155
+ color: var(--text-muted);
156
+ font-weight: var(--fw-normal);
157
+ font-size: var(--fs-sm);
158
+ }
159
+ .empty-inline .empty-desc {
160
+ max-width: none;
161
+ }
162
+ .empty-inline .empty-action {
163
+ margin-top: 0;
164
+ }
116
165
  .empty-title {
117
166
  margin: 0;
118
167
  font-size: var(--fs-md);
@@ -1,13 +1,15 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import { type IconName } from '../atoms/Icon.svelte';
3
3
  type $$ComponentProps = {
4
- title: string;
5
- description?: string;
4
+ title?: string;
5
+ description?: string | Snippet;
6
6
  icon?: IconName;
7
7
  tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
8
8
  actionLabel?: string;
9
9
  onAction?: () => void;
10
10
  compact?: boolean;
11
+ size?: 'inline' | 'compact' | 'default';
12
+ loading?: boolean;
11
13
  class?: string;
12
14
  iconChildren?: Snippet;
13
15
  action?: Snippet;
@@ -3,6 +3,8 @@
3
3
  // error text. Replaces the ad-hoc `<div class="field"><label class="label">`
4
4
  // markup. Renders a real <label for> when `for` is given (associates with the
5
5
  // control), else a plain <span> for control groups (radio/segment rows).
6
+ // `layout="inline"` puts the label beside the control (fixed `labelWidth`
7
+ // aligns a column of them); hint/error then follow the control on the row.
6
8
  import type { Snippet } from 'svelte';
7
9
 
8
10
  let {
@@ -10,14 +12,19 @@
10
12
  for: forId,
11
13
  hint,
12
14
  error,
15
+ layout = 'stack',
16
+ labelWidth,
13
17
  grow = false,
14
18
  class: klass = '',
15
19
  children
16
20
  }: {
17
21
  label?: string;
18
22
  for?: string;
19
- hint?: string;
23
+ hint?: string | Snippet;
20
24
  error?: string;
25
+ layout?: 'stack' | 'inline';
26
+ /** Inline layout: fixed label column width. */
27
+ labelWidth?: string;
21
28
  /** Fill the available width of a flex/Cluster row (flex: 1). */
22
29
  grow?: boolean;
23
30
  class?: string;
@@ -25,16 +32,18 @@
25
32
  } = $props();
26
33
  </script>
27
34
 
28
- <div class="field {klass}" class:field-grow={grow} data-tsu="Field">
35
+ <div class="field {klass}" class:field-grow={grow} class:field-inline={layout === 'inline'} data-tsu="Field">
29
36
  {#if label}
30
37
  {#if forId}
31
- <label class="label" for={forId}>{label}</label>
38
+ <label class="label" for={forId} style:width={labelWidth}>{label}</label>
32
39
  {:else}
33
- <span class="label">{label}</span>
40
+ <span class="label" style:width={labelWidth}>{label}</span>
34
41
  {/if}
35
42
  {/if}
36
43
  {@render children?.()}
37
- {#if hint}<span class="hint">{hint}</span>{/if}
44
+ {#if hint}
45
+ <span class="hint">{#if typeof hint === 'function'}{@render hint()}{:else}{hint}{/if}</span>
46
+ {/if}
38
47
  {#if error}<span class="error">{error}</span>{/if}
39
48
  </div>
40
49
 
@@ -48,6 +57,14 @@
48
57
  flex: 1 1 0;
49
58
  min-width: 0;
50
59
  }
60
+ .field-inline {
61
+ flex-direction: row;
62
+ align-items: center;
63
+ gap: var(--sp-2);
64
+ }
65
+ .field-inline .label {
66
+ flex: none;
67
+ }
51
68
  .label {
52
69
  font-size: var(--fs-sm);
53
70
  font-weight: var(--fw-medium);
@@ -2,8 +2,11 @@ import type { Snippet } from 'svelte';
2
2
  type $$ComponentProps = {
3
3
  label?: string;
4
4
  for?: string;
5
- hint?: string;
5
+ hint?: string | Snippet;
6
6
  error?: string;
7
+ layout?: 'stack' | 'inline';
8
+ /** Inline layout: fixed label column width. */
9
+ labelWidth?: string;
7
10
  /** Fill the available width of a flex/Cluster row (flex: 1). */
8
11
  grow?: boolean;
9
12
  class?: string;
package/dist/index.d.ts CHANGED
@@ -11,11 +11,13 @@ export { default as Heading } from './components/atoms/Heading.svelte';
11
11
  export type { IconName } from './components/atoms/Icon.svelte';
12
12
  export { default as Icon } from './components/atoms/Icon.svelte';
13
13
  export { default as Input } from './components/atoms/Input.svelte';
14
+ export { default as Kbd } from './components/atoms/Kbd.svelte';
14
15
  export { default as Link } from './components/atoms/Link.svelte';
15
16
  export { default as Progress } from './components/atoms/Progress.svelte';
16
17
  export { default as Scrim } from './components/atoms/Scrim.svelte';
17
18
  export { default as SegmentedProgress, type ProgressSegment, } from './components/atoms/SegmentedProgress.svelte';
18
19
  export { default as Select } from './components/atoms/Select.svelte';
20
+ export { default as Skeleton } from './components/atoms/Skeleton.svelte';
19
21
  export { default as Slider } from './components/atoms/Slider.svelte';
20
22
  export { default as Spinner } from './components/atoms/Spinner.svelte';
21
23
  export { default as Switch } from './components/atoms/Switch.svelte';
package/dist/index.js CHANGED
@@ -16,11 +16,13 @@ export { default as Dot } from './components/atoms/Dot.svelte';
16
16
  export { default as Heading } from './components/atoms/Heading.svelte';
17
17
  export { default as Icon } from './components/atoms/Icon.svelte';
18
18
  export { default as Input } from './components/atoms/Input.svelte';
19
+ export { default as Kbd } from './components/atoms/Kbd.svelte';
19
20
  export { default as Link } from './components/atoms/Link.svelte';
20
21
  export { default as Progress } from './components/atoms/Progress.svelte';
21
22
  export { default as Scrim } from './components/atoms/Scrim.svelte';
22
23
  export { default as SegmentedProgress, } from './components/atoms/SegmentedProgress.svelte';
23
24
  export { default as Select } from './components/atoms/Select.svelte';
25
+ export { default as Skeleton } from './components/atoms/Skeleton.svelte';
24
26
  export { default as Slider } from './components/atoms/Slider.svelte';
25
27
  export { default as Spinner } from './components/atoms/Spinner.svelte';
26
28
  export { default as Switch } from './components/atoms/Switch.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
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",