@dorsk/tsumikit 0.66.1 → 0.67.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.
package/README.md CHANGED
@@ -580,10 +580,17 @@ on a phone instead of four. `tiles` forces the tile mode (`false` forces
580
580
  chips); `removeLabel` names the popover button. Both variants are in the DOM
581
581
  and the container query displays one; the hidden one is `display: none`, so
582
582
  it is neither focusable nor announced. `Composer` inherits the behaviour.
583
+ Chips show a small thumbnail before an image's name and clip it in the middle
584
+ (the last 12 characters stay, so look-alike screenshot names remain telling).
585
+ `numbered` shows each file's 1-based position on the chip and the tile,
586
+ matching `[#N]` markers written in a prompt. `onopen(index)` makes the chip name a button and adds a
587
+ larger preview (images) plus an `openLabel` ("Open") action to the tile
588
+ popover; Remove stays separate.
583
589
 
584
590
  ```svelte
585
591
  <AttachmentList files={attachments} onremove={(i) => (attachments = attachments.toSpliced(i, 1))} />
586
592
  <AttachmentList tiles files={[{ name: 'cover.png', size: 1024, type: 'image/png', url }]} />
593
+ <AttachmentList numbered files={attachments} onopen={(i) => preview(attachments[i])} />
587
594
  ```
588
595
 
589
596
  ### ConversationPanel, ConversationFeed, StatusBar
@@ -845,10 +852,14 @@ glyphs (`size` stays the SVG px, emoji ×1.35).
845
852
  On `(pointer: coarse)` every icon-only/square control (and `Popover`'s default
846
853
  trigger) carries an invisible `::after` slab that extends its hit area to
847
854
  `--touch-target` (44px, WCAG 2.5.8) without moving layout; fine pointers are
848
- untouched. The slab grows on both block sides but only towards the inline
849
- *start*: inline-end overflow would count towards the scrollable overflow of
850
- every scroll ancestor, so a button near the right edge of an `overflow-y: auto`
851
- container would make it scroll sideways by a pixel or two.
855
+ untouched. The slab is the centred target with its inline-*end* half cut off:
856
+ 44px tall, and the box plus half the deficit wide (34px for a 24px icon).
857
+ Inline-end overflow would count towards the scrollable overflow of every scroll
858
+ ancestor, so a button near the right edge of an `overflow-y: auto` container
859
+ would make it scroll sideways; in a row that half lies under the next sibling's
860
+ slab anyway. Growing the whole deficit towards the start instead would let each
861
+ slab cover the previous sibling's centre, so taps would land one icon to the
862
+ right.
852
863
  `hitArea="compact"` opts a dense-table row out. `Button
853
864
  collapseLabel="mobile" | "container"` hides `<span data-label>` children below
854
865
  40rem viewport / 30rem container, drops to icon padding, and names the button
@@ -51,7 +51,7 @@
51
51
  // container (`container`) so the button becomes icon-only; the hidden text
52
52
  // becomes the aria-label unless one is set.
53
53
  collapseLabel?: 'never' | 'mobile' | 'container';
54
- // Icon-only/square buttons grow a 44px hit slab on coarse pointers without
54
+ // Icon-only/square buttons grow a touch hit slab on coarse pointers without
55
55
  // moving layout; `compact` opts out in dense tables.
56
56
  hitArea?: 'auto' | 'compact';
57
57
  // Async/busy state: shows a spinner, blocks clicks, sets aria-busy. Stays
@@ -507,12 +507,12 @@
507
507
  }
508
508
 
509
509
  /* Coarse pointers: icon-only and square buttons carry an invisible slab that
510
- extends the hit area to --touch-target without changing layout. Buttons
510
+ extends the hit area towards --touch-target without changing layout. Buttons
511
511
  already at or above the target keep their own box (inset never positive).
512
- The slab grows towards the inline start only: inline-end overflow counts
513
- towards the scrollable overflow of every scroll ancestor, so a button near
514
- the right edge of an `overflow-y: auto` container would make it scroll
515
- sideways. Start-side overflow is unscrollable, so the full target survives. */
512
+ The slab is the centred target with its inline-end half cut off: inline-end
513
+ overflow counts towards the scrollable overflow of every scroll ancestor, and
514
+ in a row that half lies under the next sibling anyway. Growing further towards
515
+ the start would let each slab cover the previous sibling's centre. */
516
516
  @media (pointer: coarse) {
517
517
  .btn-icon:not(.hit-compact),
518
518
  .btn-icon-inline:not(.hit-compact),
@@ -529,7 +529,7 @@
529
529
  content: '';
530
530
  position: absolute;
531
531
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
532
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
532
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
533
533
  inset-inline-end: 0;
534
534
  }
535
535
  }
@@ -15,16 +15,24 @@
15
15
  let {
16
16
  files,
17
17
  onremove,
18
+ onopen,
18
19
  tiles = 'auto',
20
+ numbered = false,
19
21
  removeLabel = 'Remove',
22
+ openLabel = 'Open',
20
23
  class: klass = '',
21
24
  style: styleProp = ''
22
25
  }: {
23
26
  files: (File | Attachment)[];
24
27
  onremove?: (index: number) => void;
28
+ /** Makes the chip name a button and adds a preview + Open action to the tile popover. */
29
+ onopen?: (index: number) => void;
25
30
  /** `auto` switches to tiles when the list is at most 30rem wide. */
26
31
  tiles?: boolean | 'auto';
32
+ /** Shows each file's 1-based position, matching `[#N]` markers in a prompt. */
33
+ numbered?: boolean;
27
34
  removeLabel?: string;
35
+ openLabel?: string;
28
36
  class?: string;
29
37
  style?: string;
30
38
  } = $props();
@@ -57,6 +65,16 @@
57
65
  for (const url of untrack(() => objectUrls).values()) URL.revokeObjectURL(url);
58
66
  });
59
67
 
68
+ const TAIL = 12;
69
+
70
+ /** Chips clip the head and keep the tail, where look-alike names (macOS
71
+ * screenshots) differ. */
72
+ function split(name: string): [string, string] {
73
+ const chars = [...name];
74
+ if (chars.length <= TAIL + 4) return [name, ''];
75
+ return [chars.slice(0, -TAIL).join(''), chars.slice(-TAIL).join('')];
76
+ }
77
+
60
78
  function thumb(f: File | Attachment): string | undefined {
61
79
  if (!isImage(f)) return undefined;
62
80
  return f instanceof File ? objectUrls.get(f) : f.url;
@@ -72,6 +90,11 @@
72
90
  style={styleProp}
73
91
  >
74
92
  {#each files as f, i (`${f.name}-${i}`)}
93
+ {#snippet label(f: File | Attachment)}
94
+ {@const [head, tail] = split(f.name)}
95
+ <span class="head">{head}</span>{#if tail}<span class="tail">{tail}</span>{/if}
96
+ {#if f.size !== undefined}<span class="size">{fmt(f.size)}</span>{/if}
97
+ {/snippet}
75
98
  {#if tiles !== true}
76
99
  <li class="chip">
77
100
  <Badge
@@ -82,20 +105,32 @@
82
105
  onremove={() => onremove?.(i)}
83
106
  title={f.name}
84
107
  >
85
- {f.name}{#if f.size !== undefined}<span class="size">{fmt(f.size)}</span>{/if}
108
+ {@const src = thumb(f)}
109
+ {#if numbered}<span class="num">{i + 1}</span>{/if}
110
+ {#if src}<img class="mini" {src} alt="" />{/if}
111
+ {#if onopen}
112
+ <button type="button" class="open" title={openLabel} onclick={() => onopen?.(i)}>
113
+ {@render label(f)}
114
+ </button>
115
+ {:else}
116
+ <span class="name">{@render label(f)}</span>
117
+ {/if}
86
118
  </Badge>
87
119
  </li>
88
120
  {/if}
89
121
  {#if tiles !== false}
90
122
  <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)">
123
+ <Popover label={numbered ? `${i + 1}. ${f.name}` : f.name} box="lg" placement="top-start" style="--pop-trigger-pad: 0; --pop-trigger-border: var(--border-strong); --pop-trigger-bg: var(--bg)">
92
124
  {#snippet trigger()}
93
125
  {@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}
126
+ <span class="face">
127
+ {#if src}
128
+ <img class="thumb" {src} alt="" />
129
+ {:else}
130
+ <Icon name={isImage(f) ? 'image' : 'file'} size={18} />
131
+ {/if}
132
+ {#if numbered}<span class="num tile-num">{i + 1}</span>{/if}
133
+ </span>
99
134
  {/snippet}
100
135
  {#snippet children({ close })}
101
136
  <div class="detail">
@@ -118,6 +153,31 @@
118
153
  {/if}
119
154
  </div>
120
155
  {#if f.size !== undefined}<Text size="xs" tone="faint">{fmt(f.size)}</Text>{/if}
156
+ {#if onopen}
157
+ {@const src = thumb(f)}
158
+ {#if src}
159
+ <button
160
+ type="button"
161
+ class="preview"
162
+ title={openLabel}
163
+ onclick={() => {
164
+ close();
165
+ onopen?.(i);
166
+ }}
167
+ >
168
+ <img {src} alt="" />
169
+ </button>
170
+ {/if}
171
+ <Button
172
+ size="sm"
173
+ onclick={() => {
174
+ close();
175
+ onopen?.(i);
176
+ }}
177
+ >
178
+ {openLabel}
179
+ </Button>
180
+ {/if}
121
181
  </div>
122
182
  {/snippet}
123
183
  </Popover>
@@ -188,4 +248,95 @@
188
248
  .chip :global(.action) {
189
249
  font-size: 1.4em;
190
250
  }
251
+ .chip :global(.clip) {
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: var(--sp-1);
255
+ }
256
+ .name,
257
+ .open {
258
+ display: inline-flex;
259
+ align-items: baseline;
260
+ min-width: 0;
261
+ white-space: nowrap;
262
+ }
263
+ .head {
264
+ min-width: 0;
265
+ overflow: hidden;
266
+ text-overflow: ellipsis;
267
+ }
268
+ .tail,
269
+ .size {
270
+ flex: none;
271
+ }
272
+ .open {
273
+ padding: 0;
274
+ border: 0;
275
+ background: none;
276
+ font: inherit;
277
+ color: inherit;
278
+ cursor: pointer;
279
+ }
280
+ .open:hover {
281
+ text-decoration: underline;
282
+ }
283
+ .open:focus-visible {
284
+ outline: var(--focus-ring);
285
+ outline-offset: var(--focus-ring-offset);
286
+ border-radius: var(--r-sm);
287
+ }
288
+ .mini {
289
+ flex: none;
290
+ width: 1.25em;
291
+ height: 1.25em;
292
+ object-fit: cover;
293
+ border-radius: var(--r-sm);
294
+ }
295
+ .num {
296
+ flex: none;
297
+ font-size: 0.8em;
298
+ font-weight: 600;
299
+ line-height: 1;
300
+ color: var(--text-muted);
301
+ }
302
+ .face {
303
+ position: relative;
304
+ display: flex;
305
+ align-items: center;
306
+ justify-content: center;
307
+ width: 100%;
308
+ height: 100%;
309
+ }
310
+ .tile-num {
311
+ position: absolute;
312
+ top: -0.35em;
313
+ right: -0.35em;
314
+ min-width: 1.4em;
315
+ padding: 0.1em 0.3em;
316
+ border: 1px solid var(--border-strong);
317
+ border-radius: var(--r-pill);
318
+ background: var(--bg);
319
+ color: var(--text);
320
+ text-align: center;
321
+ }
322
+ .preview {
323
+ display: block;
324
+ padding: 0;
325
+ border: 1px solid var(--border);
326
+ border-radius: var(--r-md);
327
+ background: none;
328
+ cursor: pointer;
329
+ overflow: hidden;
330
+ line-height: 0;
331
+ }
332
+ .preview:focus-visible {
333
+ outline: var(--focus-ring);
334
+ outline-offset: var(--focus-ring-offset);
335
+ }
336
+ .preview img {
337
+ display: block;
338
+ max-width: 16rem;
339
+ max-height: 16rem;
340
+ object-fit: contain;
341
+ }
191
342
  </style>
@@ -7,9 +7,14 @@ export type Attachment = {
7
7
  type $$ComponentProps = {
8
8
  files: (File | Attachment)[];
9
9
  onremove?: (index: number) => void;
10
+ /** Makes the chip name a button and adds a preview + Open action to the tile popover. */
11
+ onopen?: (index: number) => void;
10
12
  /** `auto` switches to tiles when the list is at most 30rem wide. */
11
13
  tiles?: boolean | 'auto';
14
+ /** Shows each file's 1-based position, matching `[#N]` markers in a prompt. */
15
+ numbered?: boolean;
12
16
  removeLabel?: string;
17
+ openLabel?: string;
13
18
  class?: string;
14
19
  style?: string;
15
20
  };
@@ -45,7 +45,7 @@
45
45
  control?: boolean;
46
46
  /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
47
47
  box?: 'xs' | 'sm' | 'md' | 'lg';
48
- /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
48
+ /** Icon-only buttons grow a touch hit slab on coarse pointers; `compact` opts out. */
49
49
  hitArea?: 'auto' | 'compact';
50
50
  class?: string;
51
51
  } = $props();
@@ -158,7 +158,7 @@
158
158
  content: '';
159
159
  position: absolute;
160
160
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
161
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
161
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
162
162
  inset-inline-end: 0;
163
163
  }
164
164
  }
@@ -24,7 +24,7 @@ type $$ComponentProps = {
24
24
  control?: boolean;
25
25
  /** Shared square box scale (`--box-xs/sm/md/lg`); implies `iconOnly`. */
26
26
  box?: 'xs' | 'sm' | 'md' | 'lg';
27
- /** Icon-only buttons grow a 44px hit slab on coarse pointers; `compact` opts out. */
27
+ /** Icon-only buttons grow a touch hit slab on coarse pointers; `compact` opts out. */
28
28
  hitArea?: 'auto' | 'compact';
29
29
  class?: string;
30
30
  };
@@ -34,7 +34,7 @@
34
34
  // Exact glyph size for SVG *and* text glyphs (px number or any CSS length),
35
35
  // no emoji multiplier. Wins over `size`.
36
36
  glyphSize?: number | string;
37
- // Opt out of the 44px coarse-pointer hit slab in dense rows.
37
+ // Opt out of the coarse-pointer hit slab in dense rows.
38
38
  hitArea?: 'auto' | 'compact';
39
39
  // Borderless, compact icon affordance (chip-remove ✕, inline edit ✎) —
40
40
  // no square box; just a muted glyph that brightens on hover. Pair with
@@ -61,7 +61,7 @@
61
61
  /** Use the shared `--control-height` toolbar/composer contract. */
62
62
  control?: boolean;
63
63
  block?: boolean;
64
- /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
64
+ /** Icon-only triggers grow a touch hit slab on coarse pointers; `compact` opts out. */
65
65
  hitArea?: 'auto' | 'compact';
66
66
  /** Corner count indicator on the trigger, as on `Button`: hidden at
67
67
  * 0/undefined, capped at `countMax+`, the exact number joins `label`. */
@@ -449,10 +449,9 @@
449
449
  padding: var(--pop-trigger-pad, 0);
450
450
  flex: none;
451
451
  }
452
- /* Coarse pointers: icon-only triggers extend their hit area to --touch-target
453
- via an invisible slab; layout does not move. The slab grows towards the
454
- inline start only, so it never adds scrollable overflow to a scroll ancestor
455
- (see Button.svelte). */
452
+ /* Coarse pointers: icon-only triggers extend their hit area towards --touch-target
453
+ via an invisible slab; layout does not move. Same geometry as Button.svelte:
454
+ no inline-end growth, half the deficit towards the inline start. */
456
455
  @media (pointer: coarse) {
457
456
  .pop-trigger:not(.canonical, .hit-compact) {
458
457
  position: relative;
@@ -461,7 +460,7 @@
461
460
  content: '';
462
461
  position: absolute;
463
462
  inset-block: min(0px, calc((100% - var(--touch-target)) / 2));
464
- inset-inline-start: min(0px, calc(100% - var(--touch-target)));
463
+ inset-inline-start: min(0px, calc((100% - var(--touch-target)) / 2));
465
464
  inset-inline-end: 0;
466
465
  }
467
466
  }
@@ -46,7 +46,7 @@ type Own = {
46
46
  /** Use the shared `--control-height` toolbar/composer contract. */
47
47
  control?: boolean;
48
48
  block?: boolean;
49
- /** Icon-only triggers grow a 44px hit slab on coarse pointers; `compact` opts out. */
49
+ /** Icon-only triggers grow a touch hit slab on coarse pointers; `compact` opts out. */
50
50
  hitArea?: 'auto' | 'compact';
51
51
  /** Corner count indicator on the trigger, as on `Button`: hidden at
52
52
  * 0/undefined, capped at `countMax+`, the exact number joins `label`. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.66.1",
3
+ "version": "0.67.1",
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",