@dorsk/tsumikit 0.76.0 → 0.78.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.
@@ -100,8 +100,9 @@
100
100
  background: var(--divider-color);
101
101
  }
102
102
  .divider-label {
103
+ min-width: 0;
103
104
  color: var(--text-muted);
104
105
  font-size: var(--fs-sm);
105
- white-space: nowrap;
106
+ text-align: center;
106
107
  }
107
108
  </style>
@@ -12,7 +12,7 @@
12
12
 
13
13
  // `size` shadows the native char-width attribute (unused in token-sized
14
14
  // layouts) to expose a height preset instead.
15
- type Props = Omit<HTMLInputAttributes, 'size'> & {
15
+ type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
16
16
  mono?: boolean;
17
17
  size?: ControlSize;
18
18
  /** Fill the available width of a flex/Cluster row (flex: 1) instead of
@@ -1,7 +1,7 @@
1
1
  import type { ControlSize } from '../../size';
2
2
  import type { HTMLInputAttributes } from 'svelte/elements';
3
3
  import { type IconName } from './Icon.svelte';
4
- type Props = Omit<HTMLInputAttributes, 'size'> & {
4
+ type Props = Omit<HTMLInputAttributes, 'size' | 'onsubmit'> & {
5
5
  mono?: boolean;
6
6
  size?: ControlSize;
7
7
  /** Fill the available width of a flex/Cluster row (flex: 1) instead of
@@ -199,6 +199,7 @@
199
199
  .select-wrap.w-auto {
200
200
  display: inline-block;
201
201
  width: auto;
202
+ max-width: 100%;
202
203
  }
203
204
  .select-wrap.select-grow {
204
205
  flex: 1 1 0;
@@ -253,6 +254,8 @@
253
254
  }
254
255
  .select.w-auto {
255
256
  width: auto;
257
+ max-width: 100%;
258
+ text-overflow: ellipsis;
256
259
  }
257
260
  .select.embedded {
258
261
  background: var(--select-bg, var(--bg-elevated-2));
@@ -23,7 +23,7 @@
23
23
  import { getFieldContext, warnUnlabelled } from '../../field-context';
24
24
  import { getInputGroupContext } from '../../input-group-context';
25
25
 
26
- type Props = HTMLTextareaAttributes & {
26
+ type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
27
27
  mono?: boolean;
28
28
  autoresize?: boolean;
29
29
  size?: ControlSize;
@@ -1,6 +1,6 @@
1
1
  import type { ControlSize } from '../../size';
2
2
  import type { HTMLTextareaAttributes } from 'svelte/elements';
3
- type Props = HTMLTextareaAttributes & {
3
+ type Props = Omit<HTMLTextareaAttributes, 'onsubmit'> & {
4
4
  mono?: boolean;
5
5
  autoresize?: boolean;
6
6
  size?: ControlSize;
@@ -289,6 +289,7 @@
289
289
  }
290
290
  .shell-menu-btn {
291
291
  display: inline-flex;
292
+ flex: none;
292
293
  align-items: center;
293
294
  }
294
295
  .shell-main {
@@ -311,6 +311,9 @@
311
311
  text-overflow: ellipsis;
312
312
  white-space: nowrap;
313
313
  }
314
+ .stacked .navbar-label {
315
+ max-width: 100%;
316
+ }
314
317
  .navbar-badge {
315
318
  position: absolute;
316
319
  top: -0.35rem;
@@ -90,19 +90,19 @@
90
90
  gap: var(--sp-3);
91
91
  padding: var(--sp-3);
92
92
  }
93
- :global(.callout-ok) {
93
+ :global(.card.callout-ok) {
94
94
  --callout-tone: var(--ok);
95
95
  }
96
- :global(.callout-warn) {
96
+ :global(.card.callout-warn) {
97
97
  --callout-tone: var(--warn);
98
98
  }
99
- :global(.callout-danger) {
99
+ :global(.card.callout-danger) {
100
100
  --callout-tone: var(--danger);
101
101
  }
102
- :global(.callout-info) {
102
+ :global(.card.callout-info) {
103
103
  --callout-tone: var(--info);
104
104
  }
105
- :global(.callout-accent) {
105
+ :global(.card.callout-accent) {
106
106
  --callout-tone: var(--accent);
107
107
  }
108
108
 
@@ -180,5 +180,9 @@
180
180
  -webkit-line-clamp: var(--clamp);
181
181
  line-clamp: var(--clamp);
182
182
  overflow: hidden;
183
+ padding: calc(var(--focus-ring-width) + var(--focus-ring-offset));
184
+ padding-block-end: 0;
185
+ margin: calc(-1 * (var(--focus-ring-width) + var(--focus-ring-offset)));
186
+ margin-block-end: 0;
183
187
  }
184
188
  </style>
@@ -101,8 +101,15 @@
101
101
  font-family: var(--font-mono);
102
102
  color: var(--text-muted);
103
103
  }
104
+ .cb-name {
105
+ min-width: 0;
106
+ overflow: hidden;
107
+ text-overflow: ellipsis;
108
+ white-space: nowrap;
109
+ }
104
110
  .cb-body {
105
111
  display: flex;
112
+ align-items: flex-start;
106
113
  overflow: auto;
107
114
  -webkit-overflow-scrolling: touch;
108
115
  }
@@ -60,7 +60,7 @@
60
60
  {variant}
61
61
  {box}
62
62
  {hitArea}
63
- class="copy-btn {klass}"
63
+ class="copy-btn {labelShown && box !== undefined ? 'copy-labelled' : ''} {klass}"
64
64
  sound={false}
65
65
  onclick={copy}
66
66
  aria-label={copied ? copiedLabel : label}
@@ -78,6 +78,10 @@
78
78
  /* Both glyphs share one grid cell so the swap never changes the width. The
79
79
  check springs in (the kit's one bounce: a rare confirmation); the copy
80
80
  glyph fades out fast and back in over the base duration on revert. */
81
+ :global(.copy-btn.btn-box.copy-labelled) {
82
+ width: auto;
83
+ padding-inline: var(--sp-2);
84
+ }
81
85
  .glyphs {
82
86
  display: inline-grid;
83
87
  place-items: center;
@@ -73,6 +73,7 @@
73
73
  display: flex;
74
74
  flex-direction: column;
75
75
  gap: var(--sp-1);
76
+ min-width: 0;
76
77
  }
77
78
  .field-grow {
78
79
  flex: 1 1 0;
@@ -93,7 +93,7 @@
93
93
  {:else if emoji}
94
94
  <span class="emoji" aria-hidden="true">{emoji}</span>
95
95
  {/if}
96
- <span class:sr-only={onlyIcon}>{label}</span>
96
+ <span class="text" class:sr-only={onlyIcon}>{label}</span>
97
97
  </label>
98
98
 
99
99
  <style>
@@ -114,6 +114,7 @@
114
114
  font-weight: var(--fw-medium);
115
115
  font-size: var(--control-fs-md);
116
116
  line-height: var(--lh-none);
117
+ max-width: 100%;
117
118
  white-space: nowrap;
118
119
  cursor: pointer;
119
120
  user-select: none;
@@ -179,6 +180,11 @@
179
180
  }
180
181
  }
181
182
  /* An emoji reads small next to an SVG glyph of the same size: one icon tier up. */
183
+ .text:not(.sr-only) {
184
+ min-width: 0;
185
+ overflow: hidden;
186
+ text-overflow: ellipsis;
187
+ }
182
188
  .emoji {
183
189
  display: inline-flex;
184
190
  font-size: var(--icon-lg);
@@ -467,6 +467,7 @@
467
467
  .fi__bar {
468
468
  --fi-icon: var(--control-icon-md);
469
469
  display: flex;
470
+ flex-wrap: wrap;
470
471
  align-items: center;
471
472
  gap: var(--sp-2);
472
473
  padding: 0 var(--control-px-md);
@@ -522,7 +523,7 @@
522
523
  position: relative;
523
524
  display: flex;
524
525
  flex: 1;
525
- min-width: 0;
526
+ min-width: min(100%, 6rem);
526
527
  }
527
528
  .fi__display {
528
529
  position: absolute;
@@ -219,6 +219,7 @@
219
219
 
220
220
  .rows {
221
221
  gap: var(--sp-1);
222
+ min-width: 0;
222
223
  }
223
224
  .row-wrap {
224
225
  min-width: 0;
@@ -250,8 +251,9 @@
250
251
  }
251
252
  .label-line {
252
253
  display: flex;
254
+ flex-wrap: wrap;
253
255
  align-items: baseline;
254
- gap: var(--sp-2);
256
+ gap: var(--sp-px) var(--sp-2);
255
257
  min-width: 0;
256
258
  }
257
259
  .row .label-text {
@@ -260,7 +262,6 @@
260
262
  .note {
261
263
  font-size: var(--fs-xs);
262
264
  color: var(--text-faint);
263
- white-space: nowrap;
264
265
  }
265
266
  .description {
266
267
  font-size: var(--fs-xs);
@@ -119,11 +119,21 @@
119
119
  });
120
120
 
121
121
  $effect(() => {
122
- if (!open || active < 0) return;
123
- const el = root?.ownerDocument.getElementById(optionId(active));
124
- el?.scrollIntoView?.({ block: 'nearest' });
122
+ if (open && active >= 0) revealActive();
125
123
  });
126
124
 
125
+ // Scrolls the listbox only: `scrollIntoView` on a top-layer panel also
126
+ // scrolls the document in Chromium.
127
+ function revealActive() {
128
+ const list = root?.querySelector<HTMLElement>('.rs-list');
129
+ const row = rowEls()[active];
130
+ if (!list || !row) return;
131
+ const top = row.offsetTop;
132
+ const bottom = top + row.offsetHeight;
133
+ if (top < list.scrollTop) list.scrollTop = top;
134
+ else if (bottom > list.scrollTop + list.clientHeight) list.scrollTop = bottom - list.clientHeight;
135
+ }
136
+
127
137
  function close() {
128
138
  open = false;
129
139
  }
@@ -303,8 +313,7 @@
303
313
  async function onOpen() {
304
314
  typeahead.reset();
305
315
  await tick();
306
- const el = active >= 0 ? root?.ownerDocument.getElementById(optionId(active)) : null;
307
- el?.scrollIntoView?.({ block: 'nearest' });
316
+ if (active >= 0) revealActive();
308
317
  }
309
318
 
310
319
  function onClose() {
@@ -459,7 +468,6 @@
459
468
  }
460
469
  .rs-placeholder {
461
470
  display: block;
462
- padding-inline: var(--sp-1);
463
471
  color: var(--text-muted);
464
472
  font-size: var(--control-fs-md);
465
473
  line-height: var(--lh-tight);
@@ -486,6 +494,7 @@
486
494
  padding: var(--sp-1);
487
495
  }
488
496
  .rs-list {
497
+ position: relative;
489
498
  display: flex;
490
499
  flex-direction: column;
491
500
  gap: var(--sp-0-5);
@@ -30,6 +30,7 @@
30
30
  shrink = true,
31
31
  block = false,
32
32
  sound = true,
33
+ disabled = false,
33
34
  class: klass = '',
34
35
  ...rest
35
36
  }: {
@@ -64,6 +65,7 @@
64
65
  hitArea?: 'auto' | 'compact';
65
66
  /** Play UI sounds when enabled globally; false silences this instance. */
66
67
  sound?: boolean;
68
+ disabled?: boolean;
67
69
  class?: string;
68
70
  [key: string]: unknown;
69
71
  } = $props();
@@ -81,6 +83,7 @@
81
83
  class="select-button {klass}"
82
84
  class:row={variant === 'row'}
83
85
  class:active
86
+ class:disabled
84
87
  data-tsu="SelectButton"
85
88
  {...rest}
86
89
  class:grow={grow}
@@ -98,6 +101,7 @@
98
101
  aria-label={label}
99
102
  {title}
100
103
  {value}
104
+ {disabled}
101
105
  onchange={change}
102
106
  >
103
107
  {#if groups}
@@ -138,18 +142,25 @@
138
142
  .select-button.active {
139
143
  color: var(--accent);
140
144
  }
145
+ .select-button.disabled {
146
+ opacity: var(--disabled-opacity);
147
+ cursor: not-allowed;
148
+ }
141
149
  /* The inert face takes no pointer events: mirror ghost hover, press and the
142
150
  focus ring from the select overlaying it. */
143
151
  .select-button:active :global(.btn-ghost) {
144
152
  --btn-scale-dur: var(--dur-press);
145
153
  scale: var(--press-scale-sm);
146
154
  }
155
+ .select-button.disabled:active :global(.btn-ghost) {
156
+ scale: none;
157
+ }
147
158
  .select-button:has(:focus-visible) {
148
159
  outline: var(--focus-ring);
149
160
  outline-offset: var(--focus-ring-offset);
150
161
  }
151
162
  @media (hover: hover) and (pointer: fine) {
152
- .select-button:hover :global(.btn-ghost) {
163
+ .select-button:not(.disabled):hover :global(.btn-ghost) {
153
164
  background: var(--bg-elevated-2);
154
165
  }
155
166
  }
@@ -34,6 +34,7 @@ type $$ComponentProps = {
34
34
  hitArea?: 'auto' | 'compact';
35
35
  /** Play UI sounds when enabled globally; false silences this instance. */
36
36
  sound?: boolean;
37
+ disabled?: boolean;
37
38
  class?: string;
38
39
  [key: string]: unknown;
39
40
  };
@@ -74,6 +74,8 @@
74
74
  .status-label {
75
75
  flex: 1;
76
76
  min-width: 0;
77
+ padding-block: calc(var(--focus-ring-width) + var(--focus-ring-offset));
78
+ margin-block: calc(-1 * (var(--focus-ring-width) + var(--focus-ring-offset)));
77
79
  overflow: hidden;
78
80
  text-overflow: ellipsis;
79
81
  white-space: nowrap;
@@ -63,7 +63,7 @@
63
63
  class:on={pressed}
64
64
  aria-pressed={pressed}
65
65
  >
66
- {@render children?.()}
66
+ <span class="label">{@render children?.()}</span>
67
67
  </button>
68
68
 
69
69
  <style>
@@ -106,10 +106,20 @@
106
106
  font-size: var(--control-fs-md);
107
107
  --icon-size: var(--control-icon-md);
108
108
  }
109
+ .label {
110
+ display: contents;
111
+ }
109
112
  .toggle.grow {
110
113
  flex: 1 1 0;
111
114
  min-width: 0;
112
115
  }
116
+ .toggle.grow .label {
117
+ display: block;
118
+ min-width: 0;
119
+ overflow: hidden;
120
+ text-overflow: ellipsis;
121
+ text-align: center;
122
+ }
113
123
  .toggle.no-shrink {
114
124
  flex: none;
115
125
  }
@@ -9,6 +9,7 @@
9
9
  // observed and the longest candidate that fits is shown (mode `path`
10
10
  // abbreviates fish-style). `copyable` copies the full text on click.
11
11
  import { copyToClipboard } from '../../clipboard';
12
+ import { untrack } from 'svelte';
12
13
  import { pathCandidates, truncate, type TruncateMode } from '../../truncate';
13
14
  import Tooltip from './Tooltip.svelte';
14
15
 
@@ -72,15 +73,18 @@
72
73
  let probe = $state<HTMLSpanElement | null>(null);
73
74
  let avail = $state(Number.POSITIVE_INFINITY);
74
75
  let chPx = $state(8);
76
+ let cap = $state(Number.POSITIVE_INFINITY);
75
77
 
76
78
  $effect(() => {
77
79
  if (!fit || !rail) return;
78
80
  const el = rail;
79
81
  const measure = () => {
80
- avail = el.clientWidth;
82
+ const w = el.clientWidth;
83
+ if (w > avail) cap = Number.POSITIVE_INFINITY;
84
+ avail = w;
81
85
  if (probe) chPx = probe.getBoundingClientRect().width / 10 || chPx;
82
86
  };
83
- measure();
87
+ untrack(measure);
84
88
  const ro = new ResizeObserver(measure);
85
89
  ro.observe(el);
86
90
  return () => ro.disconnect();
@@ -88,7 +92,7 @@
88
92
 
89
93
  const shown = $derived.by(() => {
90
94
  if (fit) {
91
- const budget = Math.max(0, avail - 1);
95
+ const budget = Math.max(0, Math.min(avail - 1, cap));
92
96
  return candidates.find((c) => [...c].length * chPx <= budget) ?? candidates[candidates.length - 1];
93
97
  }
94
98
  if (max === undefined) return text;
@@ -96,6 +100,17 @@
96
100
  });
97
101
  const isTruncated = $derived(shown !== text);
98
102
 
103
+ // The per-character estimate can undershoot (glyph fallbacks, late fonts): cap the budget below the spilling candidate.
104
+ $effect(() => {
105
+ if (!fit || !rail || shown === candidates[candidates.length - 1]) return;
106
+ const el = rail;
107
+ const id = requestAnimationFrame(() => {
108
+ const over = el.scrollWidth - el.clientWidth;
109
+ if (over > 0) cap = Math.min(untrack(() => cap), [...shown].length * chPx - over);
110
+ });
111
+ return () => cancelAnimationFrame(id);
112
+ });
113
+
99
114
  let status = $state('');
100
115
  let timer: ReturnType<typeof setTimeout> | undefined;
101
116
  async function copy(e: MouseEvent) {
@@ -173,6 +188,9 @@
173
188
  max-width: 100%;
174
189
  overflow: hidden;
175
190
  }
191
+ .rail .trunc:focus-visible {
192
+ outline-offset: var(--focus-ring-offset-inset);
193
+ }
176
194
  .grow {
177
195
  flex: 1 1 0;
178
196
  }
@@ -154,6 +154,7 @@
154
154
  min-height: 0;
155
155
  }
156
156
  .feed {
157
+ position: relative;
157
158
  flex: 1;
158
159
  min-height: 0;
159
160
  overflow: auto;
@@ -97,14 +97,20 @@
97
97
  {#if showChips && (chips.length || text)}
98
98
  <div class="fsb__chips">
99
99
  {#each chips as f (f.span[0])}
100
- <Badge tone="info" removable onremove={() => remove(f.span)}>
100
+ <Badge
101
+ tone="info"
102
+ truncate
103
+ title="{labelFor(f.field)} {operatorById(f.op)?.label} {f.values.join(', ') || '∅'}"
104
+ removable
105
+ onremove={() => remove(f.span)}
106
+ >
101
107
  <span class="fsb__chip-field">{labelFor(f.field)}</span>
102
108
  <span class="fsb__chip-op">{operatorById(f.op)?.label}</span>
103
109
  <span class="fsb__chip-val">{f.values.join(', ') || '∅'}</span>
104
110
  </Badge>
105
111
  {/each}
106
112
  {#if text}
107
- <Badge tone="neutral">
113
+ <Badge tone="neutral" truncate title="text {text}">
108
114
  <span class="fsb__chip-op">text</span>
109
115
  <span class="fsb__chip-val">{text}</span>
110
116
  </Badge>
package/dist/floating.js CHANGED
@@ -20,9 +20,15 @@ function alignPos(align, start, size, fSize) {
20
20
  export function place(trigger, floating, placement = 'bottom-start', gap = 6) {
21
21
  placeAt(trigger.getBoundingClientRect(), floating, placement, gap);
22
22
  }
23
+ // The enter transition starts the panel at `scale: var(--enter-scale)`, so a
24
+ // client rect taken on open is ~4% short; offset sizes ignore transforms.
25
+ function layoutSize(el) {
26
+ const { width, height } = el.getBoundingClientRect();
27
+ return { width: el.offsetWidth || width, height: el.offsetHeight || height };
28
+ }
23
29
  /** `place` against a viewport rect instead of an element. */
24
30
  export function placeAt(t, floating, placement = 'bottom-start', gap = 6) {
25
- const f = floating.getBoundingClientRect();
31
+ const f = layoutSize(floating);
26
32
  const vw = document.documentElement.clientWidth;
27
33
  const vh = document.documentElement.clientHeight;
28
34
  const [side, align] = placement.split('-');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.76.0",
3
+ "version": "0.78.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",