@dorsk/tsumikit 0.58.0 → 0.59.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.
package/README.md CHANGED
@@ -520,8 +520,14 @@ pins them to the bottom edge as a textarea grows; `align="center"` is for a
520
520
  single-line `Input`. `size` sm/md/lg, `disabled` and `error` flow to the field
521
521
  through context (a `size`/`invalid`/`disabled` set on the field still wins).
522
522
  `Textarea` keeps `autoresize`, `maxHeight` and `resize="top"` (the grip is
523
- clipped to the space between the adornments). Tab order is leading → field →
524
- trailing; the group shows the focus ring around the whole unit.
523
+ clipped to the space between the adornments). Once a `Textarea` needs more
524
+ than one row (content, `rows`, or a dragged floor) the adornments move into a
525
+ bar under the text: every line then runs the full width with the normal
526
+ padding, the textarea's box (and its scrollbar) ends above the controls, and
527
+ the bar continues the field's border. The textarea measures that in the
528
+ single-line layout, so the switch cannot flip back and forth; the single-line
529
+ look is untouched. Tab order is leading → field → trailing; the group shows
530
+ the focus ring around the whole unit.
525
531
 
526
532
  ```svelte
527
533
  <InputGroup>
@@ -534,6 +540,24 @@ trailing; the group shows the focus ring around the whole unit.
534
540
  `Composer` is built on it: attachments above, `FileButton` leading, the send
535
541
  button trailing, `resize="top"` opt-in.
536
542
 
543
+ ### AttachmentList
544
+
545
+ `AttachmentList` renders `files` (`File` or `{ name, size?, type?, url? }`)
546
+ as removable mono chips with the formatted size. It sets `container-type:
547
+ inline-size` on itself: at 30rem or narrower every file collapses to a square
548
+ tile (a file glyph, or the image's thumbnail — an object URL for a `File`,
549
+ `url` for a plain attachment). Tapping a tile opens a `Popover` with the full
550
+ wrapping name, the size and a Remove button, so four screenshots take one row
551
+ on a phone instead of four. `tiles` forces the tile mode (`false` forces
552
+ chips); `removeLabel` names the popover button. Both variants are in the DOM
553
+ and the container query displays one; the hidden one is `display: none`, so
554
+ it is neither focusable nor announced. `Composer` inherits the behaviour.
555
+
556
+ ```svelte
557
+ <AttachmentList files={attachments} onremove={(i) => (attachments = attachments.toSpliced(i, 1))} />
558
+ <AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
559
+ ```
560
+
537
561
  ### Artwork
538
562
 
539
563
  `Artwork` is the one cover/thumbnail tile: `src` + `alt` (required) render a
@@ -574,6 +598,30 @@ top-right corner, ringed with `--avatar-status-ring` (default `--bg-elevated`).
574
598
  </Avatar>
575
599
  ```
576
600
 
601
+ ### GlyphStack
602
+
603
+ `GlyphStack` lays out up to four small glyphs (a pin, a status dot, an entity
604
+ tag, an avatar) as an inline run, and folds them into one 2×2 grid of 1rem
605
+ cells with a 1px gap when the row is tight — the four slots plus three gaps
606
+ a phone-width title cannot spare. `stack` never | always | auto (default);
607
+ `auto` measures the nearest query container (any `.cq`, an AppShell region,
608
+ or the ancestor whose `container-name` is `container`) and folds below
609
+ `stackBelow` (default `30rem`, like the `.cq-*` utilities). Pass `items` as
610
+ `{ inline, stacked? }` pairs (snippets or strings) so a glyph can swap to a
611
+ compact variant in the grid — a full `Badge` inline, a tinted initial tile
612
+ stacked — or render free-form `children`, where each direct child is a cell.
613
+ The grid is visual only: DOM order, tab order and reading order are those of
614
+ the inline run. `--glyph-stack-gap` sets the inline gap (default `--sp-1`).
615
+
616
+ ```svelte
617
+ <GlyphStack stackBelow="34rem" items={[
618
+ { inline: pin },
619
+ { inline: dot },
620
+ { inline: machine, stacked: machineTile },
621
+ { inline: avatar }
622
+ ]} />
623
+ ```
624
+
577
625
  ### SplitButton
578
626
 
579
627
  `SplitButton` fuses a primary action with a narrow caret that opens a `Menu`:
@@ -5,7 +5,9 @@
5
5
  *
6
6
  * Usage: <textarea use:autoresize={value} ...></textarea>
7
7
  */
8
- /** Height `rows` lines would occupy: line boxes plus vertical padding and borders. */
8
+ /** Height `rows` lines occupy: line boxes plus vertical padding and borders. */
9
+ export declare function rowsHeight(node: HTMLTextAreaElement, rows: number): number;
10
+ /** The `rows` attribute as a height floor; a single row has none. */
9
11
  export declare function rowsFloor(node: HTMLTextAreaElement): number;
10
12
  export declare function autoresize(node: HTMLTextAreaElement, _value?: string): {
11
13
  update(): void;
@@ -5,11 +5,8 @@
5
5
  *
6
6
  * Usage: <textarea use:autoresize={value} ...></textarea>
7
7
  */
8
- /** Height `rows` lines would occupy: line boxes plus vertical padding and borders. */
9
- export function rowsFloor(node) {
10
- const rows = node.rows || 0;
11
- if (rows <= 1)
12
- return 0;
8
+ /** Height `rows` lines occupy: line boxes plus vertical padding and borders. */
9
+ export function rowsHeight(node, rows) {
13
10
  const cs = getComputedStyle(node);
14
11
  const fontSize = parseFloat(cs.fontSize) || 16;
15
12
  const lh = parseFloat(cs.lineHeight) || fontSize * 1.2;
@@ -19,6 +16,11 @@ export function rowsFloor(node) {
19
16
  (parseFloat(cs.borderBottomWidth) || 0);
20
17
  return rows * lh + box;
21
18
  }
19
+ /** The `rows` attribute as a height floor; a single row has none. */
20
+ export function rowsFloor(node) {
21
+ const rows = node.rows || 0;
22
+ return rows <= 1 ? 0 : rowsHeight(node, rows);
23
+ }
22
24
  export function autoresize(node, _value) {
23
25
  const resize = () => {
24
26
  // A manual drag handle may set `min-height` as a user-chosen floor; grow
@@ -19,7 +19,7 @@
19
19
  // still inserts a newline) or Ctrl/Meta+Enter (`submitOn="mod-enter"`, the
20
20
  // default whenever `onsubmit` is given).
21
21
  import type { HTMLTextareaAttributes } from 'svelte/elements';
22
- import { autoresize as autoresizeAction } from '../../autoresize';
22
+ import { autoresize as autoresizeAction, rowsFloor, rowsHeight } from '../../autoresize';
23
23
  import { getFieldContext, warnUnlabelled } from '../../field-context';
24
24
  import { getInputGroupContext } from '../../input-group-context';
25
25
 
@@ -84,6 +84,41 @@
84
84
 
85
85
  $effect(() => warnUnlabelled(el, 'Textarea'));
86
86
 
87
+ // Grouped: report whether the content needs more than one row when laid out
88
+ // with the single-line padding (measured in that layout so the bar cannot
89
+ // flip back and forth), which moves the adornments into a bar under the text.
90
+ function probeBar() {
91
+ if (!el || !group) return;
92
+ const node = el;
93
+ const wasBar = node.classList.contains('bar');
94
+ const prevHeight = node.style.height;
95
+ const floor = parseFloat(node.style.minHeight) || 0;
96
+ node.classList.remove('bar');
97
+ node.style.height = 'auto';
98
+ node.style.minHeight = '';
99
+ const cs = getComputedStyle(node);
100
+ const borders = (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.borderBottomWidth) || 0);
101
+ const height = Math.max(node.scrollHeight + borders, floor, rowsFloor(node));
102
+ const oneRow = Math.max(rowsHeight(node, 1), parseFloat(cs.minHeight) || 0);
103
+ node.style.height = prevHeight;
104
+ if (floor) node.style.minHeight = `${floor}px`;
105
+ if (wasBar) node.classList.add('bar');
106
+ group.setBar(height > oneRow + 1);
107
+ }
108
+ $effect(() => {
109
+ if (!group) return;
110
+ void value;
111
+ void group.leadingW;
112
+ void group.trailingW;
113
+ probeBar();
114
+ });
115
+ $effect(() => {
116
+ if (!el || !group || typeof ResizeObserver === 'undefined') return;
117
+ const ro = new ResizeObserver(probeBar);
118
+ ro.observe(el);
119
+ return () => ro.disconnect();
120
+ });
121
+
87
122
  const handleEdge = $derived(resize);
88
123
  const showHandle = $derived(handleEdge !== 'none');
89
124
  const submitMode = $derived(submitOn === 'none' && onsubmit ? 'mod-enter' : submitOn);
@@ -156,6 +191,7 @@
156
191
  class:no-shrink={!shrink}
157
192
  class:block={block}
158
193
  class:grouped={!!group}
194
+ class:bar={!!group?.bar}
159
195
  data-tsu="Textarea"
160
196
  >
161
197
  {#if autoresize}
@@ -166,6 +202,7 @@
166
202
  class:textarea-sm={sizeEff === 'sm'}
167
203
  class:textarea-lg={sizeEff === 'lg'}
168
204
  class:grouped={!!group}
205
+ class:bar={!!group?.bar}
169
206
  class:capped={!!maxHeight}
170
207
  disabled={isDisabled}
171
208
  style:max-height={maxHeight}
@@ -185,6 +222,7 @@
185
222
  class:textarea-sm={sizeEff === 'sm'}
186
223
  class:textarea-lg={sizeEff === 'lg'}
187
224
  class:grouped={!!group}
225
+ class:bar={!!group?.bar}
188
226
  class:capped={!!maxHeight}
189
227
  disabled={isDisabled}
190
228
  style:max-height={maxHeight}
@@ -288,6 +326,12 @@
288
326
  .textarea.grouped:focus-visible {
289
327
  outline: none;
290
328
  }
329
+ .textarea.grouped.bar {
330
+ padding-inline: var(--ig-pad-x);
331
+ border-bottom: 0;
332
+ border-end-start-radius: 0;
333
+ border-end-end-radius: 0;
334
+ }
291
335
  .textarea-wrap.grouped {
292
336
  width: 100%;
293
337
  }
@@ -295,6 +339,10 @@
295
339
  left: var(--ig-leading-w, 0px);
296
340
  right: var(--ig-trailing-w, 0px);
297
341
  }
342
+ .textarea-wrap.grouped.bar .resize-handle {
343
+ left: 0;
344
+ right: 0;
345
+ }
298
346
  .mono {
299
347
  font-family: var(--font-mono);
300
348
  }
@@ -1,17 +1,30 @@
1
1
  <script lang="ts">
2
- // Removable file chips for a composer or upload form.
2
+ // Removable file chips; narrow containers (or `tiles`) collapse each file to a
3
+ // tile whose Popover carries name, size and Remove. A Tooltip would not open on
4
+ // iOS, where tapping never focuses a button. Both variants render and a
5
+ // container query hides one with `display: none`, keeping it out of the tab order.
6
+ import { untrack } from 'svelte';
3
7
  import Badge from '../atoms/Badge.svelte';
8
+ import Button from '../atoms/Button.svelte';
9
+ import Icon from '../atoms/Icon.svelte';
10
+ import Text from '../atoms/Text.svelte';
11
+ import Popover from './Popover.svelte';
4
12
 
5
- export type Attachment = { name: string; size?: number };
13
+ export type Attachment = { name: string; size?: number; type?: string; url?: string };
6
14
 
7
15
  let {
8
16
  files,
9
17
  onremove,
18
+ tiles = 'auto',
19
+ removeLabel = 'Remove',
10
20
  class: klass = '',
11
21
  style: styleProp = ''
12
22
  }: {
13
23
  files: (File | Attachment)[];
14
24
  onremove?: (index: number) => void;
25
+ /** `auto` switches to tiles when the list is at most 30rem wide. */
26
+ tiles?: boolean | 'auto';
27
+ removeLabel?: string;
15
28
  class?: string;
16
29
  style?: string;
17
30
  } = $props();
@@ -22,23 +35,89 @@
22
35
  if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
23
36
  return `${(size / 1024 / 1024).toFixed(1)} MB`;
24
37
  }
38
+
39
+ function isImage(f: File | Attachment): boolean {
40
+ return !!f.type?.startsWith('image/');
41
+ }
42
+
43
+ let objectUrls = $state<Map<File, string>>(new Map());
44
+
45
+ $effect(() => {
46
+ const prev = untrack(() => objectUrls);
47
+ const next = new Map<File, string>();
48
+ for (const f of files) {
49
+ if (!(f instanceof File) || !isImage(f)) continue;
50
+ next.set(f, prev.get(f) ?? URL.createObjectURL(f));
51
+ }
52
+ for (const [f, url] of prev) if (!next.has(f)) URL.revokeObjectURL(url);
53
+ objectUrls = next;
54
+ });
55
+
56
+ $effect(() => () => {
57
+ for (const url of untrack(() => objectUrls).values()) URL.revokeObjectURL(url);
58
+ });
59
+
60
+ function thumb(f: File | Attachment): string | undefined {
61
+ if (!isImage(f)) return undefined;
62
+ return f instanceof File ? objectUrls.get(f) : f.url;
63
+ }
25
64
  </script>
26
65
 
27
66
  {#if files.length}
28
- <ul data-tsu="AttachmentList" class="attachments {klass}" style={styleProp}>
67
+ <ul
68
+ data-tsu="AttachmentList"
69
+ class="attachments {klass}"
70
+ class:auto={tiles === 'auto'}
71
+ class:tiles={tiles === true}
72
+ style={styleProp}
73
+ >
29
74
  {#each files as f, i (`${f.name}-${i}`)}
30
- <li>
31
- <Badge
32
- mono
33
- truncate
34
- maxWidth="16rem"
35
- removable={!!onremove}
36
- onremove={() => onremove?.(i)}
37
- title={f.name}
38
- >
39
- {f.name}{#if f.size !== undefined}<span class="size">{fmt(f.size)}</span>{/if}
40
- </Badge>
41
- </li>
75
+ {#if tiles !== true}
76
+ <li class="chip">
77
+ <Badge
78
+ mono
79
+ truncate
80
+ maxWidth="16rem"
81
+ removable={!!onremove}
82
+ onremove={() => onremove?.(i)}
83
+ title={f.name}
84
+ >
85
+ {f.name}{#if f.size !== undefined}<span class="size">{fmt(f.size)}</span>{/if}
86
+ </Badge>
87
+ </li>
88
+ {/if}
89
+ {#if tiles !== false}
90
+ <li class="tile">
91
+ <Popover label={f.name} box="lg" placement="top-start" style="--pop-trigger-pad: 0; --pop-trigger-border: var(--border-strong); --pop-trigger-bg: var(--bg)">
92
+ {#snippet trigger()}
93
+ {@const src = thumb(f)}
94
+ {#if src}
95
+ <img class="thumb" {src} alt="" />
96
+ {:else}
97
+ <Icon name={isImage(f) ? 'image' : 'file'} size={18} />
98
+ {/if}
99
+ {/snippet}
100
+ {#snippet children({ close })}
101
+ <div class="detail">
102
+ <Text variant="code" size="sm" class="detail-name">{f.name}</Text>
103
+ {#if f.size !== undefined}<Text size="xs" tone="faint">{fmt(f.size)}</Text>{/if}
104
+ {#if onremove}
105
+ <Button
106
+ size="sm"
107
+ variant="danger"
108
+ onclick={() => {
109
+ close();
110
+ onremove?.(i);
111
+ }}
112
+ >
113
+ {removeLabel}
114
+ </Button>
115
+ {/if}
116
+ </div>
117
+ {/snippet}
118
+ </Popover>
119
+ </li>
120
+ {/if}
42
121
  {/each}
43
122
  </ul>
44
123
  {/if}
@@ -52,8 +131,43 @@
52
131
  padding: 0;
53
132
  list-style: none;
54
133
  }
134
+ .attachments.auto {
135
+ container-type: inline-size;
136
+ }
55
137
  .size {
56
138
  margin-inline-start: var(--sp-1);
57
139
  color: var(--text-faint);
58
140
  }
141
+ li {
142
+ display: flex;
143
+ min-width: 0;
144
+ }
145
+ .auto .tile {
146
+ display: none;
147
+ }
148
+ @container (max-width: 30rem) {
149
+ .auto .chip {
150
+ display: none;
151
+ }
152
+ .auto .tile {
153
+ display: flex;
154
+ }
155
+ }
156
+ .thumb {
157
+ width: 100%;
158
+ height: 100%;
159
+ object-fit: cover;
160
+ border-radius: calc(var(--r-md) - 1px);
161
+ }
162
+ .detail {
163
+ display: flex;
164
+ flex-direction: column;
165
+ align-items: flex-start;
166
+ gap: var(--sp-1);
167
+ padding: var(--sp-1);
168
+ max-width: 16rem;
169
+ }
170
+ .detail :global(.detail-name) {
171
+ overflow-wrap: anywhere;
172
+ }
59
173
  </style>
@@ -1,10 +1,15 @@
1
1
  export type Attachment = {
2
2
  name: string;
3
3
  size?: number;
4
+ type?: string;
5
+ url?: string;
4
6
  };
5
7
  type $$ComponentProps = {
6
8
  files: (File | Attachment)[];
7
9
  onremove?: (index: number) => void;
10
+ /** `auto` switches to tiles when the list is at most 30rem wide. */
11
+ tiles?: boolean | 'auto';
12
+ removeLabel?: string;
8
13
  class?: string;
9
14
  style?: string;
10
15
  };
@@ -1,8 +1,8 @@
1
1
  <script lang="ts">
2
2
  // Chat composer: autoresizing Textarea with attach + send controls fused into
3
3
  // one InputGroup, submit shortcuts, prompt history on ↑/↓ at the text edges,
4
- // paste-to-attach and drag-over state. Attachments render as removable chips
5
- // above the field.
4
+ // paste-to-attach and drag-over state. Attachments render above the field as
5
+ // removable chips, or as tiles once the composer is narrow.
6
6
  import type { Snippet } from 'svelte';
7
7
  import Button from '../atoms/Button.svelte';
8
8
  import Textarea from '../atoms/Textarea.svelte';
@@ -0,0 +1,136 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+
4
+ export interface GlyphStackItem {
5
+ /** Rendered in the inline run, and in the grid when `stacked` is unset. */
6
+ inline: Snippet | string;
7
+ /** Compact variant for a 1rem grid cell (e.g. a tinted initial tile for a full Badge). */
8
+ stacked?: Snippet | string;
9
+ }
10
+ </script>
11
+
12
+ <script lang="ts">
13
+ // Up to four small glyphs (pin, status dot, entity tag, avatar) as an inline
14
+ // run, folded into one 2×2 grid of 1rem cells when the row is tight. The grid
15
+ // is purely visual: DOM order, focus order and reading order never change.
16
+ import type { HTMLAttributes } from 'svelte/elements';
17
+ import { browser } from '../../env';
18
+
19
+ type Own = {
20
+ /** Glyphs as `{ inline, stacked? }` pairs; alternative to `children`. */
21
+ items?: GlyphStackItem[];
22
+ /** Free-form run — each direct child becomes a cell. */
23
+ children?: Snippet;
24
+ /** `auto` folds below `stackBelow`, measured on the nearest query container
25
+ * (any `.cq`, an AppShell region, or the ancestor named `container`). */
26
+ stack?: 'never' | 'always' | 'auto';
27
+ /** Container width (px/em/rem) under which `auto` folds. */
28
+ stackBelow?: string;
29
+ /** `container-name` of the ancestor to measure instead of the nearest one. */
30
+ container?: string;
31
+ class?: string;
32
+ };
33
+ let {
34
+ items,
35
+ children,
36
+ stack = 'auto',
37
+ stackBelow = '30rem',
38
+ container,
39
+ class: klass = '',
40
+ ...rest
41
+ }: Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own = $props();
42
+
43
+ let root = $state<HTMLElement>();
44
+ let width = $state(Number.POSITIVE_INFINITY);
45
+ let limit = $state(0);
46
+ const stacked = $derived(stack === 'always' || (stack === 'auto' && width < limit));
47
+
48
+ function toPx(length: string, el: HTMLElement): number {
49
+ const n = Number.parseFloat(length);
50
+ const fontSize = (node: Element) => Number.parseFloat(getComputedStyle(node).fontSize);
51
+ if (length.endsWith('rem')) return n * fontSize(document.documentElement);
52
+ if (length.endsWith('em')) return n * fontSize(el);
53
+ return n;
54
+ }
55
+
56
+ function queryContainer(el: HTMLElement, name?: string): HTMLElement | null {
57
+ for (let node = el.parentElement; node; node = node.parentElement) {
58
+ const style = getComputedStyle(node);
59
+ if (name) {
60
+ if (style.containerName?.split(/\s+/).includes(name)) return node;
61
+ } else if (style.containerType && style.containerType !== 'normal') {
62
+ return node;
63
+ }
64
+ }
65
+ return el.parentElement;
66
+ }
67
+
68
+ $effect(() => {
69
+ if (!browser || !root || stack !== 'auto') return;
70
+ const el = root;
71
+ const target = queryContainer(el, container);
72
+ if (!target) return;
73
+ limit = toPx(stackBelow, el);
74
+ const observer = new ResizeObserver(([entry]) => {
75
+ width = entry.contentRect.width;
76
+ });
77
+ observer.observe(target);
78
+ return () => observer.disconnect();
79
+ });
80
+ </script>
81
+
82
+ <span
83
+ bind:this={root}
84
+ data-tsu="GlyphStack"
85
+ class="glyph-stack {klass}"
86
+ class:stacked
87
+ data-stack={stack}
88
+ data-stacked={stacked || undefined}
89
+ {...rest}
90
+ >
91
+ {#if items}
92
+ {#each items.slice(0, 4) as item, i (i)}
93
+ {@const glyph = stacked && item.stacked !== undefined ? item.stacked : item.inline}
94
+ <span class="cell">
95
+ {#if typeof glyph === 'string'}{glyph}{:else}{@render glyph()}{/if}
96
+ </span>
97
+ {/each}
98
+ {:else}
99
+ {@render children?.()}
100
+ {/if}
101
+ </span>
102
+
103
+ <style>
104
+ .glyph-stack {
105
+ display: inline-flex;
106
+ align-items: center;
107
+ gap: var(--glyph-stack-gap, var(--sp-1));
108
+ flex: none;
109
+ vertical-align: middle;
110
+ }
111
+ .cell {
112
+ display: inline-flex;
113
+ align-items: center;
114
+ justify-content: center;
115
+ flex: none;
116
+ }
117
+ .stacked {
118
+ display: inline-grid;
119
+ grid-template-columns: repeat(2, 1rem);
120
+ grid-auto-rows: 1rem;
121
+ gap: 1px;
122
+ place-items: center;
123
+ }
124
+ .stacked > :global(*) {
125
+ width: 1rem;
126
+ height: 1rem;
127
+ max-width: 1rem;
128
+ max-height: 1rem;
129
+ display: inline-flex;
130
+ align-items: center;
131
+ justify-content: center;
132
+ overflow: hidden;
133
+ font-size: var(--fs-xs);
134
+ line-height: 1;
135
+ }
136
+ </style>
@@ -0,0 +1,26 @@
1
+ import type { Snippet } from 'svelte';
2
+ export interface GlyphStackItem {
3
+ /** Rendered in the inline run, and in the grid when `stacked` is unset. */
4
+ inline: Snippet | string;
5
+ /** Compact variant for a 1rem grid cell (e.g. a tinted initial tile for a full Badge). */
6
+ stacked?: Snippet | string;
7
+ }
8
+ import type { HTMLAttributes } from 'svelte/elements';
9
+ type Own = {
10
+ /** Glyphs as `{ inline, stacked? }` pairs; alternative to `children`. */
11
+ items?: GlyphStackItem[];
12
+ /** Free-form run — each direct child becomes a cell. */
13
+ children?: Snippet;
14
+ /** `auto` folds below `stackBelow`, measured on the nearest query container
15
+ * (any `.cq`, an AppShell region, or the ancestor named `container`). */
16
+ stack?: 'never' | 'always' | 'auto';
17
+ /** Container width (px/em/rem) under which `auto` folds. */
18
+ stackBelow?: string;
19
+ /** `container-name` of the ancestor to measure instead of the nearest one. */
20
+ container?: string;
21
+ class?: string;
22
+ };
23
+ type $$ComponentProps = Omit<HTMLAttributes<HTMLSpanElement>, keyof Own> & Own;
24
+ declare const GlyphStack: import("svelte").Component<$$ComponentProps, {}, "">;
25
+ type GlyphStack = ReturnType<typeof GlyphStack>;
26
+ export default GlyphStack;
@@ -44,11 +44,24 @@
44
44
  },
45
45
  get invalid() {
46
46
  return error;
47
+ },
48
+ get leadingW() {
49
+ return leadingW;
50
+ },
51
+ get trailingW() {
52
+ return trailingW;
53
+ },
54
+ get bar() {
55
+ return bar;
56
+ },
57
+ setBar(next) {
58
+ bar = next && !!(leading || trailing);
47
59
  }
48
60
  });
49
61
 
50
62
  let leadingW = $state(0);
51
63
  let trailingW = $state(0);
64
+ let bar = $state(false);
52
65
 
53
66
  function measure(set: (width: number) => void): Attachment {
54
67
  return (node) => {
@@ -73,6 +86,7 @@
73
86
  data-tsu="InputGroup"
74
87
  class="ig {klass}"
75
88
  class:ig-center={align === 'center'}
89
+ class:ig-bar={bar}
76
90
  class:ig-sm={size === 'sm'}
77
91
  class:ig-lg={size === 'lg'}
78
92
  class:disabled
@@ -93,7 +107,10 @@
93
107
  /* The field's own border box spans the whole group: Gecko zooms to the
94
108
  focused element's rect on mobile, so the adornments must overlay it, never
95
109
  sit beside a narrower field. The field reads --ig-leading-w /
96
- --ig-trailing-w to keep its text clear of them. */
110
+ --ig-trailing-w to keep its text clear of them. Once a textarea is taller
111
+ than one row it reports `bar`: the adornments then sit in a band under
112
+ the text (::before continues the field's border), so every line runs
113
+ full width and the scrollbar ends above the controls. */
97
114
  .ig {
98
115
  --ig-h: var(--control-height-default);
99
116
  --ig-pad-x: var(--sp-3);
@@ -141,6 +158,27 @@
141
158
  top: 0;
142
159
  height: auto;
143
160
  }
161
+ .ig-bar .ig-field {
162
+ padding-bottom: var(--ig-h);
163
+ }
164
+ .ig-bar::before {
165
+ content: '';
166
+ position: absolute;
167
+ inset: auto 0 0 0;
168
+ height: var(--ig-h);
169
+ background: var(--textarea-bg, var(--bg));
170
+ border: 1px solid var(--textarea-border, var(--border-strong));
171
+ border-top: 0;
172
+ border-radius: 0 0 var(--ig-radius) var(--ig-radius);
173
+ transition: border-color 0.12s var(--ease);
174
+ pointer-events: none;
175
+ }
176
+ .ig-bar:has(.ig-field :global(:focus))::before {
177
+ border-color: var(--accent);
178
+ }
179
+ .ig-bar:has(.ig-field :global([aria-invalid='true']))::before {
180
+ border-color: var(--danger);
181
+ }
144
182
  .ig:has(:focus-visible)::after {
145
183
  content: '';
146
184
  position: absolute;
@@ -103,16 +103,6 @@
103
103
 
104
104
  let listEl = $state<HTMLDivElement | null>(null);
105
105
 
106
- $effect(() => {
107
- const pop = listEl?.closest<HTMLElement>('[popover]');
108
- if (!pop) return;
109
- const isOpen = pop.matches(':popover-open');
110
- try {
111
- if (open && !isOpen) pop.showPopover();
112
- else if (!open && isOpen) pop.hidePopover();
113
- } catch {}
114
- });
115
-
116
106
  function buttons(): HTMLButtonElement[] {
117
107
  return listEl
118
108
  ? Array.from(listEl.querySelectorAll<HTMLButtonElement>('[role="menuitem"]:not(:disabled), [role="menuitemcheckbox"]:not(:disabled)'))
@@ -167,12 +157,9 @@
167
157
  {gap}
168
158
  role="menu"
169
159
  haspopup="menu"
170
- onclose={() => {
171
- open = false;
172
- onclose?.();
173
- }}
160
+ bind:open
161
+ {onclose}
174
162
  onopen={() => {
175
- open = true;
176
163
  queueMicrotask(() => focusAt(0));
177
164
  onopen?.();
178
165
  }}
@@ -76,6 +76,8 @@
76
76
  role?: PanelRole;
77
77
  /** `aria-haspopup` announced on the trigger. */
78
78
  haspopup?: HasPopup;
79
+ /** Bindable open state; setting it opens or closes the panel, even before its first open. */
80
+ open?: boolean;
79
81
  onopen?: () => void;
80
82
  onclose?: () => void;
81
83
  class?: string;
@@ -110,6 +112,7 @@
110
112
  href,
111
113
  role = 'dialog',
112
114
  haspopup = 'dialog',
115
+ open = $bindable(false),
113
116
  onopen,
114
117
  onclose,
115
118
  class: klass = '',
@@ -133,7 +136,14 @@
133
136
  // reopening is instant. The panel element itself always renders — the native
134
137
  // `popovertarget` wiring needs its id present in the DOM at all times.
135
138
  let opened = $state(false);
136
- let open = $state(false);
139
+
140
+ let shown = false;
141
+
142
+ $effect(() => {
143
+ if (!panelEl) return;
144
+ if (open && !shown) show();
145
+ else if (!open && shown) close();
146
+ });
137
147
 
138
148
  function close() {
139
149
  try {
@@ -210,6 +220,7 @@
210
220
  }
211
221
 
212
222
  async function onToggle(e: ToggleEvent) {
223
+ shown = e.newState === 'open';
213
224
  if (e.newState === 'open') {
214
225
  const firstOpen = !opened;
215
226
  opened = true;
@@ -62,6 +62,8 @@ type Own = {
62
62
  role?: PanelRole;
63
63
  /** `aria-haspopup` announced on the trigger. */
64
64
  haspopup?: HasPopup;
65
+ /** Bindable open state; setting it opens or closes the panel, even before its first open. */
66
+ open?: boolean;
65
67
  onopen?: () => void;
66
68
  onclose?: () => void;
67
69
  class?: string;
@@ -71,6 +73,6 @@ type Own = {
71
73
  panelStyle?: string;
72
74
  };
73
75
  type $$ComponentProps = Omit<HTMLAttributes<HTMLElement>, keyof Own> & Own;
74
- declare const Popover: import("svelte").Component<$$ComponentProps, {}, "">;
76
+ declare const Popover: import("svelte").Component<$$ComponentProps, {}, "open">;
75
77
  type Popover = ReturnType<typeof Popover>;
76
78
  export default Popover;
@@ -78,16 +78,10 @@
78
78
  const height = $derived(control ? 'var(--control-height)' : HEIGHT[size]);
79
79
  const caretDisabled = $derived(disabled || menuDisabled);
80
80
 
81
- let caretEl = $state<HTMLSpanElement | null>(null);
82
-
83
- function openMenu() {
84
- caretEl?.querySelector<HTMLButtonElement>('[popovertarget]')?.click();
85
- }
86
-
87
81
  function onPrimaryKeydown(e: KeyboardEvent) {
88
82
  if (e.key !== 'ArrowDown' || caretDisabled) return;
89
83
  e.preventDefault();
90
- openMenu();
84
+ open = true;
91
85
  }
92
86
  </script>
93
87
 
@@ -114,7 +108,7 @@
114
108
  {@render children()}
115
109
  </Button>
116
110
  </span>
117
- <span class="split-caret" bind:this={caretEl}>
111
+ <span class="split-caret">
118
112
  <Menu
119
113
  {label}
120
114
  {items}
package/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ export { default as FileButton } from './components/molecules/FileButton.svelte'
66
66
  export { default as FilterInput, type FilterInputContext, } from './components/molecules/FilterInput.svelte';
67
67
  export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
68
68
  export { type DiffStats, default as GitRef, type PrState, type PullRequest, } from './components/molecules/GitRef.svelte';
69
+ export { default as GlyphStack, type GlyphStackItem, } from './components/molecules/GlyphStack.svelte';
69
70
  export { default as IconButton } from './components/molecules/IconButton.svelte';
70
71
  export { default as InputGroup } from './components/molecules/InputGroup.svelte';
71
72
  export { default as KeyValue, type KeyValueRow, type KeyValueTone, } from './components/molecules/KeyValue.svelte';
package/dist/index.js CHANGED
@@ -72,6 +72,7 @@ export { default as FileButton } from './components/molecules/FileButton.svelte'
72
72
  export { default as FilterInput, } from './components/molecules/FilterInput.svelte';
73
73
  export { default as FontScalePicker } from './components/molecules/FontScalePicker.svelte';
74
74
  export { default as GitRef, } from './components/molecules/GitRef.svelte';
75
+ export { default as GlyphStack, } from './components/molecules/GlyphStack.svelte';
75
76
  export { default as IconButton } from './components/molecules/IconButton.svelte';
76
77
  export { default as InputGroup } from './components/molecules/InputGroup.svelte';
77
78
  export { default as KeyValue, } from './components/molecules/KeyValue.svelte';
@@ -5,6 +5,12 @@ export type InputGroupContext = {
5
5
  readonly size: ControlSize;
6
6
  readonly disabled: boolean;
7
7
  readonly invalid: boolean;
8
+ readonly leadingW: number;
9
+ readonly trailingW: number;
10
+ /** True once the field is taller than one row: the adornments move to a
11
+ * bar under the text instead of overlaying its last line. */
12
+ readonly bar: boolean;
13
+ setBar(bar: boolean): void;
8
14
  };
9
15
  export declare function setInputGroupContext(ctx: InputGroupContext): InputGroupContext;
10
16
  export declare function getInputGroupContext(): InputGroupContext | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.58.0",
3
+ "version": "0.59.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",