@dorsk/tsumikit 0.6.0 → 0.8.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.
@@ -0,0 +1,129 @@
1
+ <script lang="ts">
2
+ // Empty / placeholder state — the "nothing here yet" panel every unworked
3
+ // settings section, empty list, and zero-result table reaches for. Centered
4
+ // stack of:
5
+ // • a tinted icon chip (when `icon`/`iconChildren` is set)
6
+ // • a title
7
+ // • a muted, max-width-limited description
8
+ // • an optional action area (slot via `action`, or a built-in button when
9
+ // `actionLabel`+`onAction` are passed)
10
+ // Everything is token-driven. `compact` tightens spacing for inline/table use.
11
+ import type { Snippet } from 'svelte';
12
+ import Button from '../atoms/Button.svelte';
13
+ import Icon, { type IconName } from '../atoms/Icon.svelte';
14
+
15
+ let {
16
+ title,
17
+ description,
18
+ icon,
19
+ // Tints the icon chip with a semantic hue; neutral falls back to accent.
20
+ tone = 'neutral',
21
+ // Built-in primary action button. Provide both to render it; for anything
22
+ // richer (multiple buttons, links) use the `action` snippet instead.
23
+ actionLabel,
24
+ onAction,
25
+ // Tighter padding/sizing for inline use (empty table body, narrow panels).
26
+ compact = false,
27
+ class: klass = '',
28
+ // Raw SVG markup for a custom glyph — passed straight to `Icon`.
29
+ iconChildren,
30
+ // Custom action area (overrides the built-in button when present).
31
+ action,
32
+ ...rest
33
+ }: {
34
+ title: string;
35
+ description?: string;
36
+ icon?: IconName;
37
+ tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
38
+ actionLabel?: string;
39
+ onAction?: () => void;
40
+ compact?: boolean;
41
+ class?: string;
42
+ iconChildren?: Snippet;
43
+ action?: Snippet;
44
+ [key: string]: unknown;
45
+ } = $props();
46
+ </script>
47
+
48
+ <div class="empty empty-{tone} {compact ? 'empty-compact' : ''} {klass}" {...rest}>
49
+ {#if icon || iconChildren}
50
+ <span class="empty-chip" aria-hidden="true">
51
+ <Icon name={icon}>{@render iconChildren?.()}</Icon>
52
+ </span>
53
+ {/if}
54
+ <p class="empty-title">{title}</p>
55
+ {#if description}<p class="empty-desc">{description}</p>{/if}
56
+ {#if action}
57
+ <div class="empty-action">{@render action()}</div>
58
+ {:else if actionLabel && onAction}
59
+ <div class="empty-action">
60
+ <Button variant="primary" onclick={onAction}>{actionLabel}</Button>
61
+ </div>
62
+ {/if}
63
+ </div>
64
+
65
+ <style>
66
+ .empty {
67
+ /* The tone hue the chip pulls from; neutral falls back to accent. */
68
+ --empty-tone: var(--accent);
69
+ display: flex;
70
+ flex-direction: column;
71
+ align-items: center;
72
+ justify-content: center;
73
+ text-align: center;
74
+ gap: var(--sp-3);
75
+ padding: var(--sp-8) var(--sp-5);
76
+ }
77
+ :global(.empty-ok) {
78
+ --empty-tone: var(--ok);
79
+ }
80
+ :global(.empty-warn) {
81
+ --empty-tone: var(--warn);
82
+ }
83
+ :global(.empty-danger) {
84
+ --empty-tone: var(--danger);
85
+ }
86
+ :global(.empty-info) {
87
+ --empty-tone: var(--info);
88
+ }
89
+ .empty-compact {
90
+ gap: var(--sp-2);
91
+ padding: var(--sp-4) var(--sp-3);
92
+ }
93
+
94
+ .empty-chip {
95
+ display: inline-flex;
96
+ align-items: center;
97
+ justify-content: center;
98
+ flex: none;
99
+ width: 3rem;
100
+ height: 3rem;
101
+ border-radius: var(--r-lg);
102
+ font-size: 1.5rem;
103
+ color: var(--empty-tone);
104
+ background: color-mix(in srgb, var(--empty-tone) 14%, transparent);
105
+ border: 1px solid color-mix(in srgb, var(--empty-tone) 30%, transparent);
106
+ }
107
+ .empty-compact .empty-chip {
108
+ width: 2.25rem;
109
+ height: 2.25rem;
110
+ font-size: 1.125rem;
111
+ }
112
+ .empty-title {
113
+ margin: 0;
114
+ font-size: var(--fs-md);
115
+ font-weight: var(--fw-semibold);
116
+ color: var(--text);
117
+ line-height: 1.3;
118
+ }
119
+ .empty-desc {
120
+ margin: 0;
121
+ max-width: 32ch;
122
+ font-size: var(--fs-sm);
123
+ color: var(--text-muted);
124
+ line-height: 1.5;
125
+ }
126
+ .empty-action {
127
+ margin-top: var(--sp-1);
128
+ }
129
+ </style>
@@ -0,0 +1,18 @@
1
+ import type { Snippet } from 'svelte';
2
+ import { type IconName } from '../atoms/Icon.svelte';
3
+ type $$ComponentProps = {
4
+ title: string;
5
+ description?: string;
6
+ icon?: IconName;
7
+ tone?: 'neutral' | 'ok' | 'warn' | 'danger' | 'info';
8
+ actionLabel?: string;
9
+ onAction?: () => void;
10
+ compact?: boolean;
11
+ class?: string;
12
+ iconChildren?: Snippet;
13
+ action?: Snippet;
14
+ [key: string]: unknown;
15
+ };
16
+ declare const EmptyState: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ type EmptyState = ReturnType<typeof EmptyState>;
18
+ export default EmptyState;
@@ -0,0 +1,210 @@
1
+ <script lang="ts" module>
2
+ export interface SegmentOption {
3
+ value: string;
4
+ /** Visible text. Optional in `icon` mode where the icon carries the meaning. */
5
+ label?: string;
6
+ /** Optional count badge rendered after the label (filter-pill use). */
7
+ count?: number;
8
+ /** Icon glyph — required for icon-toggle mode, optional alongside a label. */
9
+ icon?: import('../atoms/Icon.svelte').IconName;
10
+ /** Greyed out, not selectable, skipped by keyboard navigation. */
11
+ disabled?: boolean;
12
+ }
13
+ </script>
14
+
15
+ <script lang="ts">
16
+ // Single-select segmented control. One token-driven row of options covering
17
+ // the two repeated redesign controls:
18
+ // • filter pills — label + optional count badge ("All 742 / Missing 120")
19
+ // • view toggle — compact icon-only buttons (grid / list)
20
+ // Implemented with `radiogroup`/`radio` semantics + roving tabindex so the
21
+ // browser-free keyboard model matches Tabs: ←/→ and Home/End move selection,
22
+ // Space/Enter (or click) activate. `value` is bindable.
23
+ import type { Snippet } from 'svelte';
24
+ import Icon from '../atoms/Icon.svelte';
25
+
26
+ let {
27
+ options,
28
+ value = $bindable(),
29
+ // `icon` packs the segments into compact square buttons (label hidden as an
30
+ // accessible name only); `pill` keeps the labelled filter-pill layout.
31
+ variant = 'pill',
32
+ size = 'md',
33
+ label = 'Options',
34
+ class: klass = '',
35
+ // Custom rendering per option (overrides the built-in label/count/icon).
36
+ option: optionSnippet
37
+ }: {
38
+ options: SegmentOption[];
39
+ value?: string;
40
+ variant?: 'pill' | 'icon';
41
+ size?: 'sm' | 'md';
42
+ label?: string;
43
+ class?: string;
44
+ option?: Snippet<[SegmentOption, boolean]>;
45
+ } = $props();
46
+
47
+ // Default to the first selectable option when no value is supplied.
48
+ $effect(() => {
49
+ if (value === undefined) {
50
+ const first = options.find((o) => !o.disabled);
51
+ if (first) value = first.value;
52
+ }
53
+ });
54
+
55
+ let listEl = $state<HTMLDivElement | null>(null);
56
+ const baseId = `seg-${Math.random().toString(36).slice(2, 8)}`;
57
+
58
+ function select(val: string, focus = false) {
59
+ if (options.find((o) => o.value === val)?.disabled) return;
60
+ value = val;
61
+ if (focus) {
62
+ queueMicrotask(() =>
63
+ listEl?.querySelector<HTMLButtonElement>(`#${baseId}-${val}`)?.focus()
64
+ );
65
+ }
66
+ }
67
+ // Step to the next non-disabled option in a direction, wrapping around.
68
+ function step(from: number, dir: 1 | -1): number {
69
+ const n = options.length;
70
+ for (let k = 1; k <= n; k++) {
71
+ const j = (((from + dir * k) % n) + n) % n;
72
+ if (!options[j].disabled) return j;
73
+ }
74
+ return from;
75
+ }
76
+ function onkeydown(e: KeyboardEvent) {
77
+ const i = options.findIndex((o) => o.value === value);
78
+ if (i < 0) return;
79
+ let next = i;
80
+ if (e.key === 'ArrowRight' || e.key === 'ArrowDown') next = step(i, 1);
81
+ else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') next = step(i, -1);
82
+ else if (e.key === 'Home') next = options.findIndex((o) => !o.disabled);
83
+ else if (e.key === 'End')
84
+ next = options.length - 1 - [...options].reverse().findIndex((o) => !o.disabled);
85
+ else return;
86
+ e.preventDefault();
87
+ select(options[next].value, true);
88
+ }
89
+ </script>
90
+
91
+ <div
92
+ bind:this={listEl}
93
+ role="radiogroup"
94
+ aria-label={label}
95
+ tabindex="-1"
96
+ class="seg seg-{variant} seg-{size} {klass}"
97
+ {onkeydown}
98
+ >
99
+ {#each options as o (o.value)}
100
+ {@const selected = value === o.value}
101
+ <button
102
+ type="button"
103
+ id="{baseId}-{o.value}"
104
+ role="radio"
105
+ aria-checked={selected}
106
+ aria-label={variant === 'icon' && !o.label ? o.value : o.label}
107
+ tabindex={selected ? 0 : -1}
108
+ class="seg-item"
109
+ class:selected
110
+ disabled={o.disabled}
111
+ onclick={() => select(o.value)}
112
+ >
113
+ {#if optionSnippet}
114
+ {@render optionSnippet(o, selected)}
115
+ {:else}
116
+ {#if o.icon}<Icon name={o.icon} />{/if}
117
+ {#if o.label}<span class="seg-label">{o.label}</span>{/if}
118
+ {#if o.count !== undefined}<span class="seg-count">{o.count}</span>{/if}
119
+ {/if}
120
+ </button>
121
+ {/each}
122
+ </div>
123
+
124
+ <style>
125
+ .seg {
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: var(--sp-1);
129
+ padding: 3px;
130
+ background: var(--bg-elevated-2);
131
+ border: 1px solid var(--border);
132
+ border-radius: var(--r-pill);
133
+ }
134
+ .seg-icon {
135
+ border-radius: var(--r-md);
136
+ }
137
+
138
+ .seg-item {
139
+ display: inline-flex;
140
+ align-items: center;
141
+ justify-content: center;
142
+ gap: var(--sp-2);
143
+ padding: 0.3rem var(--sp-3);
144
+ border: 0;
145
+ border-radius: var(--r-pill);
146
+ background: transparent;
147
+ color: var(--text-muted);
148
+ font-size: var(--fs-sm);
149
+ font-weight: var(--fw-medium);
150
+ line-height: 1.3;
151
+ white-space: nowrap;
152
+ cursor: pointer;
153
+ transition:
154
+ background 0.12s var(--ease),
155
+ color 0.12s var(--ease);
156
+ }
157
+ .seg-sm .seg-item {
158
+ padding: 0.2rem var(--sp-2);
159
+ font-size: var(--fs-xs);
160
+ }
161
+ .seg-icon .seg-item {
162
+ gap: 0;
163
+ padding: 0.35rem;
164
+ border-radius: var(--r-sm);
165
+ font-size: var(--fs-md);
166
+ }
167
+ .seg-icon.seg-sm .seg-item {
168
+ padding: 0.25rem;
169
+ font-size: var(--fs-sm);
170
+ }
171
+
172
+ .seg-item:hover:not(:disabled):not(.selected) {
173
+ color: var(--text);
174
+ background: var(--bg-elevated);
175
+ }
176
+ .seg-item.selected {
177
+ color: var(--text);
178
+ background: var(--bg);
179
+ box-shadow: var(--shadow-sm);
180
+ }
181
+ .seg-item:disabled {
182
+ opacity: 0.45;
183
+ cursor: not-allowed;
184
+ }
185
+ .seg-item:focus-visible {
186
+ outline: 2px solid var(--accent);
187
+ outline-offset: 2px;
188
+ }
189
+
190
+ .seg-count {
191
+ display: inline-flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ min-width: 1.25rem;
195
+ padding: 0 0.35rem;
196
+ border-radius: var(--r-pill);
197
+ background: var(--bg-elevated-2);
198
+ color: var(--text-faint);
199
+ font-size: 0.6875rem;
200
+ font-weight: var(--fw-semibold);
201
+ font-variant-numeric: tabular-nums;
202
+ transition:
203
+ background 0.12s var(--ease),
204
+ color 0.12s var(--ease);
205
+ }
206
+ .seg-item.selected .seg-count {
207
+ background: color-mix(in srgb, var(--accent) 18%, transparent);
208
+ color: var(--accent);
209
+ }
210
+ </style>
@@ -0,0 +1,24 @@
1
+ export interface SegmentOption {
2
+ value: string;
3
+ /** Visible text. Optional in `icon` mode where the icon carries the meaning. */
4
+ label?: string;
5
+ /** Optional count badge rendered after the label (filter-pill use). */
6
+ count?: number;
7
+ /** Icon glyph — required for icon-toggle mode, optional alongside a label. */
8
+ icon?: import('../atoms/Icon.svelte').IconName;
9
+ /** Greyed out, not selectable, skipped by keyboard navigation. */
10
+ disabled?: boolean;
11
+ }
12
+ import type { Snippet } from 'svelte';
13
+ type $$ComponentProps = {
14
+ options: SegmentOption[];
15
+ value?: string;
16
+ variant?: 'pill' | 'icon';
17
+ size?: 'sm' | 'md';
18
+ label?: string;
19
+ class?: string;
20
+ option?: Snippet<[SegmentOption, boolean]>;
21
+ };
22
+ declare const SegmentedControl: import("svelte").Component<$$ComponentProps, {}, "value">;
23
+ type SegmentedControl = ReturnType<typeof SegmentedControl>;
24
+ export default SegmentedControl;
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export { type AccordionItem, default as Accordion, } from './components/molecule
27
27
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
28
28
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
29
29
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
30
+ export { default as EmptyState } from './components/molecules/EmptyState.svelte';
30
31
  export { default as Field } from './components/molecules/Field.svelte';
31
32
  export { default as FileButton } from './components/molecules/FileButton.svelte';
32
33
  export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
@@ -37,6 +38,7 @@ export { default as Modal } from './components/molecules/Modal.svelte';
37
38
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
38
39
  export { default as Popover } from './components/molecules/Popover.svelte';
39
40
  export { default as RadioGroup, type RadioOption, } from './components/molecules/RadioGroup.svelte';
41
+ export { default as SegmentedControl, type SegmentOption, } from './components/molecules/SegmentedControl.svelte';
40
42
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
41
43
  export { default as Tabs, type TabItem } from './components/molecules/Tabs.svelte';
42
44
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
package/dist/index.js CHANGED
@@ -34,6 +34,7 @@ export { default as Accordion, } from './components/molecules/Accordion.svelte';
34
34
  export { default as CodeBlock } from './components/molecules/CodeBlock.svelte';
35
35
  export { default as CopyButton } from './components/molecules/CopyButton.svelte';
36
36
  export { default as Dropzone } from './components/molecules/Dropzone.svelte';
37
+ export { default as EmptyState } from './components/molecules/EmptyState.svelte';
37
38
  // ---- molecules ----
38
39
  export { default as Field } from './components/molecules/Field.svelte';
39
40
  export { default as FileButton } from './components/molecules/FileButton.svelte';
@@ -47,6 +48,7 @@ export { default as Modal } from './components/molecules/Modal.svelte';
47
48
  export { default as OptionButton } from './components/molecules/OptionButton.svelte';
48
49
  export { default as Popover } from './components/molecules/Popover.svelte';
49
50
  export { default as RadioGroup, } from './components/molecules/RadioGroup.svelte';
51
+ export { default as SegmentedControl, } from './components/molecules/SegmentedControl.svelte';
50
52
  export { default as SelectButton } from './components/molecules/SelectButton.svelte';
51
53
  export { default as Tabs } from './components/molecules/Tabs.svelte';
52
54
  export { default as ThemePicker } from './components/molecules/ThemePicker.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",