@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 +50 -2
- package/dist/autoresize.d.ts +3 -1
- package/dist/autoresize.js +7 -5
- package/dist/components/atoms/Textarea.svelte +49 -1
- package/dist/components/molecules/AttachmentList.svelte +129 -15
- package/dist/components/molecules/AttachmentList.svelte.d.ts +5 -0
- package/dist/components/molecules/Composer.svelte +2 -2
- package/dist/components/molecules/GlyphStack.svelte +136 -0
- package/dist/components/molecules/GlyphStack.svelte.d.ts +26 -0
- package/dist/components/molecules/InputGroup.svelte +39 -1
- package/dist/components/molecules/Menu.svelte +2 -15
- package/dist/components/molecules/Popover.svelte +12 -1
- package/dist/components/molecules/Popover.svelte.d.ts +3 -1
- package/dist/components/molecules/SplitButton.svelte +2 -8
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/input-group-context.d.ts +6 -0
- package/package.json +1 -1
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).
|
|
524
|
-
|
|
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`:
|
package/dist/autoresize.d.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage: <textarea use:autoresize={value} ...></textarea>
|
|
7
7
|
*/
|
|
8
|
-
/** Height `rows` lines
|
|
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;
|
package/dist/autoresize.js
CHANGED
|
@@ -5,11 +5,8 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage: <textarea use:autoresize={value} ...></textarea>
|
|
7
7
|
*/
|
|
8
|
-
/** Height `rows` lines
|
|
9
|
-
export function
|
|
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
|
|
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
|
|
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
|
-
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
5
|
-
//
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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