@dorsk/tsumikit 0.80.1 → 0.80.3

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.
@@ -35,14 +35,14 @@
35
35
  {href}
36
36
  class="link tone-{tone} underline-{underline} align-{align} {klass}"
37
37
  data-tsu="Link"
38
- {...rest}>{@render children?.()}</a
38
+ {...rest}><span class="link-text"><span class="link-ink">{@render children?.()}</span></span></a
39
39
  >
40
40
  {:else}
41
41
  <button
42
42
  class="link tone-{tone} underline-{underline} align-{align} {klass}"
43
43
  data-tsu="Link"
44
44
  {...rest}
45
- type="button">{@render children?.()}</button
45
+ type="button"><span class="link-text"><span class="link-ink">{@render children?.()}</span></span></button
46
46
  >
47
47
  {/if}
48
48
 
@@ -102,44 +102,52 @@
102
102
  --link-hold: var(--dur-instant);
103
103
  }
104
104
 
105
- /* Sweep line: the underline fills in from the left and leaves to the right. */
105
+ /* Sweep line: the underline fills in from the left and leaves to the right. Both variants paint
106
+ on the inline .link-ink so they follow the text whatever box the app gives .link. */
106
107
  .link:not(.underline-none):not(:global([data-fx-link]:not([data-fx-link='sweep'])) *),
107
108
  :global([data-fx-link='sweep']) .link:not(.underline-none):not(:global([data-fx-link='sweep'] [data-fx-link]:not([data-fx-link='sweep'])) *) {
108
109
  --link-base: 0%;
109
110
  text-decoration: none;
110
- background-image:
111
- linear-gradient(currentColor, currentColor),
112
- linear-gradient(color-mix(in srgb, currentColor 30%, transparent), color-mix(in srgb, currentColor 30%, transparent));
113
- background-repeat: no-repeat;
114
- background-position: var(--link-on, 100%) 100%, 0 100%;
115
- background-size: var(--link-draw, 0%) 1.5px, var(--link-base) 1px;
116
- transition:
117
- color var(--dur-fast) ease,
118
- background-size var(--dur-fast) var(--ease-out);
119
111
  &.underline-always {
120
112
  --link-base: 100%;
121
113
  }
114
+ .link-ink {
115
+ background-image:
116
+ linear-gradient(currentColor, currentColor),
117
+ linear-gradient(color-mix(in srgb, currentColor 30%, transparent), color-mix(in srgb, currentColor 30%, transparent));
118
+ background-repeat: no-repeat;
119
+ background-position: var(--link-on, 100%) 100%, 0 100%;
120
+ background-size: var(--link-draw, 0%) 1.5px, var(--link-base) 1px;
121
+ transition: background-size var(--dur-fast) var(--ease-out);
122
+ }
122
123
  }
123
124
 
124
125
  /* Highlighter: a marker swipes in behind the words and fades on leave. */
125
126
  :global([data-fx-link='highlight']) .link:not(.underline-none):not(:global([data-fx-link='highlight'] [data-fx-link]:not([data-fx-link='highlight'])) *) {
126
127
  margin-inline: -0.18em;
127
128
  padding-inline: 0.18em;
128
- border-radius: 0.2em 0.35em 0.25em 0.4em;
129
- background-image: linear-gradient(100deg, var(--link-mark), color-mix(in srgb, var(--link-mark) 70%, transparent));
130
- background-repeat: no-repeat;
131
- background-size: var(--link-draw, 0%) 100%;
132
129
  text-decoration-color: var(--link-deco, color-mix(in srgb, currentColor 45%, transparent));
133
130
  text-underline-offset: 0.2em;
134
131
  transition:
135
132
  color var(--dur-fast) ease,
136
133
  text-decoration-color var(--dur-fast) ease,
137
- --link-mark var(--link-in, var(--dur-exit)) ease,
138
- background-size var(--link-grow, var(--dur-instant)) var(--ease-out) var(--link-hold, var(--dur-exit));
134
+ --link-mark var(--link-in, var(--dur-exit)) ease;
135
+ .link-text,
136
+ .link-ink {
137
+ margin-inline: -0.18em;
138
+ padding-inline: 0.18em;
139
+ }
140
+ .link-ink {
141
+ border-radius: 0.2em 0.35em 0.25em 0.4em;
142
+ background-image: linear-gradient(100deg, var(--link-mark), color-mix(in srgb, var(--link-mark) 70%, transparent));
143
+ background-repeat: no-repeat;
144
+ background-size: var(--link-draw, 0%) 100%;
145
+ transition: background-size var(--link-grow, var(--dur-instant)) var(--ease-out) var(--link-hold, var(--dur-exit));
146
+ }
139
147
  }
140
148
  @property --link-mark {
141
149
  syntax: '<color>';
142
- inherits: false;
150
+ inherits: true;
143
151
  initial-value: transparent;
144
152
  }
145
153
  @media (pointer: coarse) {
@@ -38,6 +38,7 @@
38
38
  placement = 'inline',
39
39
  orientation = 'stacked',
40
40
  variant = 'flush',
41
+ labels = 'fit',
41
42
  maxWidth,
42
43
  class: klass = '',
43
44
  style: styleProp = '',
@@ -57,6 +58,13 @@
57
58
  orientation?: 'stacked' | 'inline';
58
59
  /** 'floating' = rounded elevated card inset from the edges. */
59
60
  variant?: 'flush' | 'floating';
61
+ /**
62
+ * Stacked labels that run out of room. 'fit' shrinks the font down to
63
+ * `--navbar-label-min` (10px) so the longest word fits, then wraps to two
64
+ * lines, then ellipsis. 'truncate' = one line with ellipsis at full size.
65
+ * 'full' never clips: labels spill past their item.
66
+ */
67
+ labels?: 'fit' | 'truncate' | 'full';
60
68
  /** Caps and centres the row of items. */
61
69
  maxWidth?: string;
62
70
  class?: string;
@@ -64,6 +72,23 @@
64
72
  [key: string]: unknown;
65
73
  } = $props();
66
74
 
75
+ // Widest word across the labels in em, rounded up from typical UI sans metrics.
76
+ const charEm = (c: string) =>
77
+ /[ijlI.,:;!|']/.test(c) ? 0.28
78
+ : /[frtJ]/.test(c) ? 0.4
79
+ : /[mwMW]/.test(c) ? 0.94
80
+ : /[A-Z]/.test(c) ? 0.72
81
+ : /[0-9]/.test(c) ? 0.58
82
+ : /[a-z]/.test(c) ? 0.6
83
+ : c >= '\u2e80' ? 1
84
+ : 0.62;
85
+ const labelEm = $derived(
86
+ Math.max(
87
+ 1,
88
+ ...items.flatMap((item) => item.label.split(/\s+/).map((w) => [...w].reduce((sum, c) => sum + charEm(c), 0)))
89
+ ).toFixed(2)
90
+ );
91
+
67
92
  const hasBadge = (badge: NavBarItem['badge']) =>
68
93
  badge !== undefined && badge !== null && badge !== '' && badge !== 0;
69
94
 
@@ -151,11 +176,11 @@
151
176
  <nav
152
177
  data-tsu="NavBar"
153
178
  aria-label={label}
154
- class="navbar {klass}"
179
+ class="navbar labels-{labels} {klass}"
155
180
  class:bar={placement === 'bar'}
156
181
  class:floating={variant === 'floating'}
157
182
  class:stacked={orientation === 'stacked'}
158
- style="{maxWidth ? `--navbar-max: ${maxWidth};` : ''}{styleProp}"
183
+ style="--navbar-label-em: {labelEm};{maxWidth ? ` --navbar-max: ${maxWidth};` : ''}{styleProp}"
159
184
  {...rest}
160
185
  >
161
186
  <div class="navbar-row">
@@ -314,6 +339,28 @@
314
339
  .stacked .navbar-label {
315
340
  max-width: 100%;
316
341
  }
342
+ .stacked.labels-fit :is(.navbar-inner, .navbar-side) {
343
+ gap: 0;
344
+ }
345
+ .stacked.labels-fit .navbar-link {
346
+ container-type: inline-size;
347
+ contain-intrinsic-inline-size: 4rem;
348
+ padding-inline: 0;
349
+ }
350
+ .stacked.labels-fit .navbar-label {
351
+ display: -webkit-box;
352
+ -webkit-box-orient: vertical;
353
+ -webkit-line-clamp: 2;
354
+ line-clamp: 2;
355
+ font-size: clamp(var(--navbar-label-min, calc(0.625rem * var(--fs-scale))), 100cqi / var(--navbar-label-em), 1em);
356
+ line-height: var(--lh-tight);
357
+ text-align: center;
358
+ white-space: normal;
359
+ }
360
+ .labels-full .navbar-label {
361
+ max-width: none;
362
+ overflow: visible;
363
+ }
317
364
  .navbar-badge {
318
365
  position: absolute;
319
366
  top: -0.35rem;
@@ -33,6 +33,13 @@ type $$ComponentProps = {
33
33
  orientation?: 'stacked' | 'inline';
34
34
  /** 'floating' = rounded elevated card inset from the edges. */
35
35
  variant?: 'flush' | 'floating';
36
+ /**
37
+ * Stacked labels that run out of room. 'fit' shrinks the font down to
38
+ * `--navbar-label-min` (10px) so the longest word fits, then wraps to two
39
+ * lines, then ellipsis. 'truncate' = one line with ellipsis at full size.
40
+ * 'full' never clips: labels spill past their item.
41
+ */
42
+ labels?: 'fit' | 'truncate' | 'full';
36
43
  /** Caps and centres the row of items. */
37
44
  maxWidth?: string;
38
45
  class?: string;
@@ -239,13 +239,18 @@
239
239
  const pos = el.selectionStart ?? value.length;
240
240
  const id = ++reqId;
241
241
  const next = key ? await suggestSingle(schema, key, value) : await suggest(schema, value, pos);
242
- if (id !== reqId) return; // a newer keystroke won
242
+ if (id !== reqId || document.activeElement !== el) return;
243
243
  menu = next;
244
244
  active = 0;
245
245
  keyboardNavigation = false;
246
246
  open = !!next && next.items.length > 0;
247
247
  }
248
248
 
249
+ function close() {
250
+ ++reqId;
251
+ open = false;
252
+ }
253
+
249
254
  function accept(i: number) {
250
255
  if (!menu || !el) return;
251
256
  const s = menu.items[i];
@@ -269,7 +274,7 @@
269
274
  el.focus();
270
275
  el.setSelectionRange(caret, caret);
271
276
  if (s.advance) refresh();
272
- else open = false;
277
+ else close();
273
278
  });
274
279
  }
275
280
 
@@ -282,7 +287,7 @@
282
287
  const exit = closingQuoteExit(value, pos);
283
288
  if (exit !== null) {
284
289
  e.preventDefault();
285
- open = false;
290
+ close();
286
291
  setCaret(exit);
287
292
  return;
288
293
  }
@@ -299,6 +304,7 @@
299
304
  }
300
305
  if (!open || !menu) {
301
306
  if (e.key === 'Enter') submit();
307
+ else if (e.key === 'Escape') close();
302
308
  return;
303
309
  }
304
310
  switch (e.key) {
@@ -319,13 +325,13 @@
319
325
  break;
320
326
  case 'Escape':
321
327
  e.preventDefault();
322
- open = false;
328
+ close();
323
329
  break;
324
330
  }
325
331
  }
326
332
 
327
333
  function submit() {
328
- open = false;
334
+ close();
329
335
  onsubmit?.(value);
330
336
  }
331
337
 
@@ -399,7 +405,9 @@
399
405
  }}
400
406
  onblur={() => {
401
407
  focused = false;
402
- setTimeout(() => (open = false), 120);
408
+ setTimeout(() => {
409
+ if (document.activeElement !== el) close();
410
+ }, 120);
403
411
  }}
404
412
  {onkeydown}
405
413
  />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dorsk/tsumikit",
3
- "version": "0.80.1",
3
+ "version": "0.80.3",
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",