@dorsk/tsumikit 0.57.0 → 0.58.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.
Files changed (50) hide show
  1. package/README.md +125 -10
  2. package/dist/avatar.d.ts +6 -0
  3. package/dist/avatar.js +17 -0
  4. package/dist/components/atoms/Avatar.svelte +140 -0
  5. package/dist/components/atoms/Avatar.svelte.d.ts +25 -0
  6. package/dist/components/atoms/Badge.svelte +30 -8
  7. package/dist/components/atoms/Badge.svelte.d.ts +7 -0
  8. package/dist/components/atoms/Button.svelte +72 -1
  9. package/dist/components/atoms/Button.svelte.d.ts +2 -0
  10. package/dist/components/atoms/Input.svelte +22 -4
  11. package/dist/components/atoms/Swatch.svelte +114 -0
  12. package/dist/components/atoms/Swatch.svelte.d.ts +23 -0
  13. package/dist/components/atoms/Textarea.svelte +33 -6
  14. package/dist/components/molecules/Accordion.svelte +80 -62
  15. package/dist/components/molecules/Accordion.svelte.d.ts +15 -2
  16. package/dist/components/molecules/Combobox.svelte +299 -0
  17. package/dist/components/molecules/Combobox.svelte.d.ts +108 -0
  18. package/dist/components/molecules/Composer.svelte +32 -29
  19. package/dist/components/molecules/Composer.svelte.d.ts +2 -0
  20. package/dist/components/molecules/Disclosure.svelte +155 -0
  21. package/dist/components/molecules/Disclosure.svelte.d.ts +26 -0
  22. package/dist/components/molecules/IconButton.svelte +8 -0
  23. package/dist/components/molecules/IconButton.svelte.d.ts +4 -0
  24. package/dist/components/molecules/InputGroup.svelte +159 -0
  25. package/dist/components/molecules/InputGroup.svelte.d.ts +23 -0
  26. package/dist/components/molecules/Menu.svelte +28 -19
  27. package/dist/components/molecules/Menu.svelte.d.ts +9 -1
  28. package/dist/components/molecules/OptionButton.svelte +42 -1
  29. package/dist/components/molecules/OptionButton.svelte.d.ts +8 -1
  30. package/dist/components/molecules/Popover.svelte +61 -2
  31. package/dist/components/molecules/Popover.svelte.d.ts +7 -1
  32. package/dist/components/molecules/RadioGroup.svelte +59 -7
  33. package/dist/components/molecules/RadioGroup.svelte.d.ts +4 -0
  34. package/dist/components/molecules/SplitButton.svelte +175 -0
  35. package/dist/components/molecules/SplitButton.svelte.d.ts +41 -0
  36. package/dist/components/molecules/Tabs.svelte +155 -27
  37. package/dist/components/molecules/Tabs.svelte.d.ts +16 -1
  38. package/dist/components/molecules/combobox-keyboard.d.ts +100 -0
  39. package/dist/components/molecules/combobox-keyboard.js +170 -0
  40. package/dist/components/molecules/menu-item.d.ts +11 -0
  41. package/dist/components/molecules/menu-item.js +9 -0
  42. package/dist/count.d.ts +2 -0
  43. package/dist/count.js +7 -0
  44. package/dist/floating.d.ts +4 -0
  45. package/dist/floating.js +4 -1
  46. package/dist/index.d.ts +9 -0
  47. package/dist/index.js +9 -0
  48. package/dist/input-group-context.d.ts +10 -0
  49. package/dist/input-group-context.js +8 -0
  50. package/package.json +1 -1
@@ -8,6 +8,7 @@
8
8
  import type { HTMLInputAttributes } from 'svelte/elements';
9
9
  import Icon, { type IconName } from './Icon.svelte';
10
10
  import { getFieldContext, warnUnlabelled } from '../../field-context';
11
+ import { getInputGroupContext } from '../../input-group-context';
11
12
 
12
13
  // `size` shadows the native char-width attribute (unused in token-sized
13
14
  // layouts) to expose a height preset instead.
@@ -43,9 +44,10 @@
43
44
 
44
45
  let {
45
46
  mono = false,
46
- size = 'md',
47
+ size,
47
48
  grow = false,
48
49
  invalid = false,
50
+ disabled = false,
49
51
  icon,
50
52
  clearable = false,
51
53
  onclear,
@@ -65,7 +67,10 @@
65
67
  }: Props = $props();
66
68
 
67
69
  const field = getFieldContext();
68
- const isInvalid = $derived(invalid || !!field?.invalid);
70
+ const group = getInputGroupContext();
71
+ const sizeEff = $derived(size ?? group?.size ?? 'md');
72
+ const isInvalid = $derived(invalid || !!field?.invalid || !!group?.invalid);
73
+ const isDisabled = $derived(disabled || !!group?.disabled);
69
74
 
70
75
  $effect(() => warnUnlabelled(el, 'Input'));
71
76
 
@@ -90,8 +95,9 @@
90
95
  data-tsu="Input"
91
96
  class="input {klass}"
92
97
  class:mono
93
- class:input-sm={size === 'sm'}
94
- class:input-lg={size === 'lg'}
98
+ class:input-sm={sizeEff === 'sm'}
99
+ class:input-lg={sizeEff === 'lg'}
100
+ class:grouped={!!group}
95
101
  class:input-grow={grow}
96
102
  class:input-fixed={!!width && !wrapped}
97
103
  class:input-pill={shape === 'pill'}
@@ -100,6 +106,7 @@
100
106
  style:width={wrapped ? undefined : width}
101
107
  bind:value
102
108
  {...rest}
109
+ disabled={isDisabled}
103
110
  onkeydown={onenter || onsubmit || onkeydown ? handleKeydown : undefined}
104
111
  id={id ?? field?.id}
105
112
  aria-describedby={ariaDescribedby ?? field?.describedBy}
@@ -182,6 +189,17 @@
182
189
  .input[aria-invalid='true']:focus {
183
190
  border-color: var(--danger);
184
191
  }
192
+ /* Inside an InputGroup the group draws the focus ring and the overlaid
193
+ adornments report their widths; the text stays clear of them. */
194
+ .input.grouped {
195
+ width: 100%;
196
+ border-radius: var(--ig-radius);
197
+ padding-inline: max(var(--ig-pad-x), var(--ig-leading-w, 0px) + var(--ig-gap))
198
+ max(var(--ig-pad-x), var(--ig-trailing-w, 0px) + var(--ig-gap));
199
+ }
200
+ .input.grouped:focus-visible {
201
+ outline: none;
202
+ }
185
203
  .mono {
186
204
  font-family: var(--font-mono);
187
205
  }
@@ -0,0 +1,114 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { ControlSize } from '../../size';
5
+ // Colour-swatch primitive: a filled chip that shows a colour. With a `label`
6
+ // it is an image (role=img + aria-label); without one it is decorative
7
+ // (aria-hidden). `interactive` renders a <button> so a palette picker can
8
+ // build a row of swatches with `selected` mirrored to aria-pressed. The
9
+ // hairline token border keeps pale colours visible on light themes.
10
+
11
+ type Own = {
12
+ /** Any CSS colour (or var()). */
13
+ color: string;
14
+ size?: ControlSize;
15
+ shape?: 'circle' | 'square';
16
+ /** Accessible name; without it the swatch is hidden from assistive tech. */
17
+ label?: string;
18
+ /** Draws the selection ring; `aria-pressed` when interactive. */
19
+ selected?: boolean;
20
+ /** Renders a `<button>` and wires `onclick`. */
21
+ interactive?: boolean;
22
+ onclick?: (e: MouseEvent) => void;
23
+ class?: string;
24
+ /** Optional glyph drawn over the colour (e.g. an "A" for auto). */
25
+ children?: Snippet;
26
+ };
27
+ let {
28
+ color,
29
+ size = 'md',
30
+ shape = 'circle',
31
+ label,
32
+ selected = false,
33
+ interactive = false,
34
+ onclick,
35
+ class: klass = '',
36
+ children,
37
+ ...rest
38
+ }: Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own = $props();
39
+ </script>
40
+
41
+ <svelte:element
42
+ this={interactive ? 'button' : 'span'}
43
+ type={interactive ? 'button' : undefined}
44
+ data-tsu="Swatch"
45
+ class="swatch {klass}"
46
+ class:sm={size === 'sm'}
47
+ class:lg={size === 'lg'}
48
+ class:square={shape === 'square'}
49
+ class:selected
50
+ class:interactive
51
+ style:--swatch-color={color}
52
+ role={!interactive && label ? 'img' : undefined}
53
+ aria-label={interactive ? (label ?? color) : label}
54
+ aria-hidden={!interactive && !label ? 'true' : undefined}
55
+ aria-pressed={interactive ? selected : undefined}
56
+ onclick={interactive ? onclick : undefined}
57
+ {...rest}
58
+ >
59
+ {@render children?.()}
60
+ </svelte:element>
61
+
62
+ <style>
63
+ .swatch {
64
+ --swatch-size: 1rem;
65
+ display: inline-flex;
66
+ align-items: center;
67
+ justify-content: center;
68
+ flex: none;
69
+ width: var(--swatch-size);
70
+ height: var(--swatch-size);
71
+ padding: 0;
72
+ margin: 0;
73
+ border: 1px solid var(--border);
74
+ border-radius: var(--r-pill);
75
+ background: var(--swatch-color);
76
+ color: var(--text);
77
+ font: inherit;
78
+ font-size: calc(var(--swatch-size) * 0.6);
79
+ line-height: 1;
80
+ vertical-align: middle;
81
+ box-sizing: border-box;
82
+ }
83
+ .sm {
84
+ --swatch-size: 0.75rem;
85
+ }
86
+ .lg {
87
+ --swatch-size: 1.5rem;
88
+ }
89
+ .square {
90
+ border-radius: var(--r-sm);
91
+ }
92
+ .sm.square {
93
+ border-radius: 3px;
94
+ }
95
+ .selected {
96
+ border-color: var(--text);
97
+ box-shadow:
98
+ 0 0 0 2px var(--bg),
99
+ 0 0 0 3px var(--text);
100
+ }
101
+ .interactive {
102
+ cursor: pointer;
103
+ transition:
104
+ transform 0.1s var(--ease),
105
+ box-shadow 0.12s var(--ease);
106
+ }
107
+ .interactive:hover {
108
+ transform: scale(1.15);
109
+ }
110
+ .interactive:focus-visible {
111
+ outline: 2px solid var(--accent);
112
+ outline-offset: 3px;
113
+ }
114
+ </style>
@@ -0,0 +1,23 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { ControlSize } from '../../size';
4
+ type Own = {
5
+ /** Any CSS colour (or var()). */
6
+ color: string;
7
+ size?: ControlSize;
8
+ shape?: 'circle' | 'square';
9
+ /** Accessible name; without it the swatch is hidden from assistive tech. */
10
+ label?: string;
11
+ /** Draws the selection ring; `aria-pressed` when interactive. */
12
+ selected?: boolean;
13
+ /** Renders a `<button>` and wires `onclick`. */
14
+ interactive?: boolean;
15
+ onclick?: (e: MouseEvent) => void;
16
+ class?: string;
17
+ /** Optional glyph drawn over the colour (e.g. an "A" for auto). */
18
+ children?: Snippet;
19
+ };
20
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
21
+ declare const Swatch: import("svelte").Component<$$ComponentProps, {}, "">;
22
+ type Swatch = ReturnType<typeof Swatch>;
23
+ export default Swatch;
@@ -21,6 +21,7 @@
21
21
  import type { HTMLTextareaAttributes } from 'svelte/elements';
22
22
  import { autoresize as autoresizeAction } from '../../autoresize';
23
23
  import { getFieldContext, warnUnlabelled } from '../../field-context';
24
+ import { getInputGroupContext } from '../../input-group-context';
24
25
 
25
26
  type Props = HTMLTextareaAttributes & {
26
27
  mono?: boolean;
@@ -55,12 +56,13 @@
55
56
  let {
56
57
  mono = false,
57
58
  autoresize = false,
58
- size = 'md',
59
+ size,
59
60
  grow = false,
60
61
  shrink = true,
61
62
  block = false,
62
63
  resize = 'bottom',
63
64
  invalid = false,
65
+ disabled = false,
64
66
  maxHeight,
65
67
  onsubmit,
66
68
  submitOn = 'none',
@@ -75,7 +77,10 @@
75
77
  }: Props = $props();
76
78
 
77
79
  const field = getFieldContext();
78
- const isInvalid = $derived(invalid || !!field?.invalid);
80
+ const group = getInputGroupContext();
81
+ const sizeEff = $derived(size ?? group?.size ?? 'md');
82
+ const isInvalid = $derived(invalid || !!field?.invalid || !!group?.invalid);
83
+ const isDisabled = $derived(disabled || !!group?.disabled);
79
84
 
80
85
  $effect(() => warnUnlabelled(el, 'Textarea'));
81
86
 
@@ -150,6 +155,7 @@
150
155
  class:grow={grow}
151
156
  class:no-shrink={!shrink}
152
157
  class:block={block}
158
+ class:grouped={!!group}
153
159
  data-tsu="Textarea"
154
160
  >
155
161
  {#if autoresize}
@@ -157,9 +163,11 @@
157
163
  bind:this={el}
158
164
  class="textarea {klass}"
159
165
  class:mono
160
- class:textarea-sm={size === 'sm'}
161
- class:textarea-lg={size === 'lg'}
166
+ class:textarea-sm={sizeEff === 'sm'}
167
+ class:textarea-lg={sizeEff === 'lg'}
168
+ class:grouped={!!group}
162
169
  class:capped={!!maxHeight}
170
+ disabled={isDisabled}
163
171
  style:max-height={maxHeight}
164
172
  bind:value
165
173
  use:autoresizeAction={typeof value === 'string' ? value : ''}
@@ -174,9 +182,11 @@
174
182
  bind:this={el}
175
183
  class="textarea {klass}"
176
184
  class:mono
177
- class:textarea-sm={size === 'sm'}
178
- class:textarea-lg={size === 'lg'}
185
+ class:textarea-sm={sizeEff === 'sm'}
186
+ class:textarea-lg={sizeEff === 'lg'}
187
+ class:grouped={!!group}
179
188
  class:capped={!!maxHeight}
189
+ disabled={isDisabled}
180
190
  style:max-height={maxHeight}
181
191
  bind:value
182
192
  {...rest}
@@ -268,6 +278,23 @@
268
278
  .textarea[aria-invalid='true']:focus {
269
279
  border-color: var(--danger);
270
280
  }
281
+ /* Inside an InputGroup the group draws the focus ring and the overlaid
282
+ adornments report their widths; the text stays clear of them. */
283
+ .textarea.grouped {
284
+ border-radius: var(--ig-radius);
285
+ padding-inline: max(var(--ig-pad-x), var(--ig-leading-w, 0px) + var(--ig-gap))
286
+ max(var(--ig-pad-x), var(--ig-trailing-w, 0px) + var(--ig-gap));
287
+ }
288
+ .textarea.grouped:focus-visible {
289
+ outline: none;
290
+ }
291
+ .textarea-wrap.grouped {
292
+ width: 100%;
293
+ }
294
+ .textarea-wrap.grouped .resize-handle {
295
+ left: var(--ig-leading-w, 0px);
296
+ right: var(--ig-trailing-w, 0px);
297
+ }
271
298
  .mono {
272
299
  font-family: var(--font-mono);
273
300
  }
@@ -1,46 +1,99 @@
1
1
  <script lang="ts" module>
2
2
  import type { Snippet } from 'svelte';
3
+ import type { DisclosureHeaderContext } from './Disclosure.svelte';
3
4
  export interface AccordionItem {
4
5
  id: string;
5
- title: string;
6
+ /** Plain-text header; `summary` wins when both are given. */
7
+ title?: string;
8
+ /** Rich header content; receives the live open state. */
9
+ summary?: Snippet<[DisclosureHeaderContext]>;
6
10
  /** Panel content for this item. */
7
11
  content: Snippet;
12
+ /** Open state. Re-passing a new value takes over from any local toggle. */
8
13
  open?: boolean;
14
+ onchange?: (open: boolean) => void;
15
+ disabled?: boolean;
9
16
  }
10
17
  </script>
11
18
 
12
19
  <script lang="ts">
13
- // Disclosure accordion built on native <details>/<summary> — zero JS for the
14
- // open/close, full keyboard + a11y for free. When `multiple` is false the
15
- // items share a `name`, using the platform's exclusive-accordion behavior
16
- // (only one open at a time). The summary marker is replaced with a rotating
17
- // chevron.
18
- import Icon from '../atoms/Icon.svelte';
20
+ // A stack of Disclosures. Each item's `open` is the parent's word: local
21
+ // toggles override it until the parent passes a new value. `multiple=false`
22
+ // closes the other items when one opens.
23
+ import type { HTMLAttributes } from 'svelte/elements';
24
+ import Disclosure from './Disclosure.svelte';
25
+ import type { DisclosureChevron } from './Disclosure.svelte';
19
26
 
20
27
  let {
21
28
  items,
22
29
  multiple = true,
23
- class: klass = ''
24
- }: {
30
+ chevron = 'end',
31
+ onchange,
32
+ class: klass = '',
33
+ style: styleProp = '',
34
+ ...rest
35
+ }: Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own = $props();
36
+
37
+ type Own = {
25
38
  items: AccordionItem[];
26
39
  multiple?: boolean;
40
+ chevron?: DisclosureChevron;
41
+ onchange?: (id: string, open: boolean) => void;
27
42
  class?: string;
28
- } = $props();
43
+ style?: string;
44
+ };
45
+
46
+ let local = $state<Record<string, boolean>>({});
47
+ let seen: Record<string, boolean | undefined> = {};
48
+
49
+ $effect.pre(() => {
50
+ for (const item of items) {
51
+ if (seen[item.id] !== item.open) {
52
+ seen[item.id] = item.open;
53
+ delete local[item.id];
54
+ }
55
+ }
56
+ });
57
+
58
+ const isOpen = (item: AccordionItem) => local[item.id] ?? item.open ?? false;
29
59
 
30
- // One shared name → native single-open accordion (stable id, derived toggle).
31
- const gid = `acc-${Math.random().toString(36).slice(2, 8)}`;
32
- const groupName = $derived(multiple ? undefined : gid);
60
+ function set(item: AccordionItem, open: boolean) {
61
+ local[item.id] = open;
62
+ item.onchange?.(open);
63
+ onchange?.(item.id, open);
64
+ if (!open || multiple) return;
65
+ for (const other of items) {
66
+ if (other.id === item.id || !isOpen(other)) continue;
67
+ local[other.id] = false;
68
+ other.onchange?.(false);
69
+ onchange?.(other.id, false);
70
+ }
71
+ }
33
72
  </script>
34
73
 
35
- <div class="accordion {klass}" data-tsu="Accordion">
74
+ {#snippet plain(title: string | undefined)}
75
+ <span class="acc-title">{title}</span>
76
+ {/snippet}
77
+
78
+ <div {...rest} class="accordion {klass}" style={styleProp} data-tsu="Accordion">
36
79
  {#each items as item (item.id)}
37
- <details name={groupName} open={item.open}>
38
- <summary>
39
- <span class="acc-title">{item.title}</span>
40
- <span class="acc-chevron"><Icon name="chevron-down" /></span>
41
- </summary>
42
- <div class="acc-panel">{@render item.content()}</div>
43
- </details>
80
+ <Disclosure
81
+ class="acc-item"
82
+ id={`acc-${item.id}`}
83
+ open={isOpen(item)}
84
+ onchange={(o) => set(item, o)}
85
+ disabled={item.disabled}
86
+ {chevron}
87
+ >
88
+ {#snippet header(ctx)}
89
+ {#if item.summary}
90
+ {@render item.summary(ctx)}
91
+ {:else}
92
+ {@render plain(item.title)}
93
+ {/if}
94
+ {/snippet}
95
+ {@render item.content()}
96
+ </Disclosure>
44
97
  {/each}
45
98
  </div>
46
99
 
@@ -50,48 +103,13 @@
50
103
  border-radius: var(--r-lg);
51
104
  overflow: hidden;
52
105
  }
53
- details + details {
106
+ .accordion > :global(.acc-item + .acc-item) {
54
107
  border-top: 1px solid var(--border);
55
108
  }
56
- summary {
57
- display: flex;
58
- align-items: center;
59
- justify-content: space-between;
60
- gap: var(--sp-2);
61
- padding: var(--sp-3) var(--sp-4);
62
- cursor: pointer;
63
- font-weight: var(--fw-medium);
64
- font-size: var(--fs-sm);
65
- list-style: none;
66
- user-select: none;
67
- }
68
- /* Hide the default disclosure triangle across engines. */
69
- summary::-webkit-details-marker {
70
- display: none;
71
- }
72
- summary::marker {
73
- content: '';
74
- }
75
- summary:hover {
76
- background: var(--bg-elevated-2);
77
- }
78
- summary:focus-visible {
79
- outline: 2px solid var(--accent);
80
- outline-offset: -2px;
81
- }
82
- /* Wrap the chevron in our own element so rotation is a scoped rule (no
83
- :global into the Icon child). The Icon inherits the color via currentColor. */
84
- .acc-chevron {
85
- display: inline-flex;
86
- color: var(--text-muted);
87
- transition: transform 0.15s var(--ease);
88
- }
89
- details[open] summary .acc-chevron {
90
- transform: rotate(180deg);
91
- }
92
- .acc-panel {
93
- padding: 0 var(--sp-4) var(--sp-4);
94
- font-size: var(--fs-sm);
95
- color: var(--text-muted);
109
+ .acc-title {
110
+ min-width: 0;
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ white-space: nowrap;
96
114
  }
97
115
  </style>
@@ -1,16 +1,29 @@
1
1
  import type { Snippet } from 'svelte';
2
+ import type { DisclosureHeaderContext } from './Disclosure.svelte';
2
3
  export interface AccordionItem {
3
4
  id: string;
4
- title: string;
5
+ /** Plain-text header; `summary` wins when both are given. */
6
+ title?: string;
7
+ /** Rich header content; receives the live open state. */
8
+ summary?: Snippet<[DisclosureHeaderContext]>;
5
9
  /** Panel content for this item. */
6
10
  content: Snippet;
11
+ /** Open state. Re-passing a new value takes over from any local toggle. */
7
12
  open?: boolean;
13
+ onchange?: (open: boolean) => void;
14
+ disabled?: boolean;
8
15
  }
9
- type $$ComponentProps = {
16
+ import type { HTMLAttributes } from 'svelte/elements';
17
+ import type { DisclosureChevron } from './Disclosure.svelte';
18
+ type Own = {
10
19
  items: AccordionItem[];
11
20
  multiple?: boolean;
21
+ chevron?: DisclosureChevron;
22
+ onchange?: (id: string, open: boolean) => void;
12
23
  class?: string;
24
+ style?: string;
13
25
  };
26
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLDivElement>, keyof Own> & Own;
14
27
  declare const Accordion: import("svelte").Component<$$ComponentProps, {}, "">;
15
28
  type Accordion = ReturnType<typeof Accordion>;
16
29
  export default Accordion;