@lyeve-labs/ui-kit 0.38.0 → 0.38.2

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.
@@ -90,7 +90,7 @@
90
90
  <div class="text-sm">
91
91
  <span class="{error ? 'text-danger' : 'text-brand'} font-medium">Click to upload</span>
92
92
  <span class="text-muted"> or drag & drop</span>
93
- {#if accept}<p class="mt-0.5 text-xs text-faint">{accept}</p>{/if}
93
+ {#if accept}<p class="mt-0.5 text-xs text-faint [overflow-wrap:anywhere]">{accept}</p>{/if}
94
94
  </div>
95
95
  <input
96
96
  type="file"
@@ -135,6 +135,10 @@
135
135
  page. The row carries the field marker while it reports a message, so a
136
136
  panel sizing itself to its form counts it as the one field it then is.
137
137
 
138
+ Under a finger each chip draws a 44px hit box round a 26px chip, 9px past
139
+ its top and bottom. Wrapped rows sit 18px apart there, so the boxes of two
140
+ rows meet without one row's taps landing on the row above.
141
+
138
142
  The row carries no aria-invalid. ARIA does not allow it on a group, or on
139
143
  the buttons and links inside one, so an error reaches a reader through
140
144
  aria-describedby, which reads the message itself.
@@ -142,7 +146,7 @@
142
146
  <div
143
147
  data-field={error || hint ? '' : undefined}
144
148
  {id}
145
- class={cn('flex flex-wrap gap-1.5', klass)}
149
+ class={cn('flex flex-wrap gap-1.5 coarse:gap-y-4.5', klass)}
146
150
  role="group"
147
151
  aria-label={label}
148
152
  aria-describedby={describedByIds}
@@ -62,12 +62,16 @@
62
62
  Each size token names its own leading and tracking. A `text-*` token
63
63
  built from `--text-*` alone sets font-size and leaves line-height to
64
64
  whatever it inherits, which is not the ratio the ramp specifies.
65
+
66
+ A title can be a user value with no spaces, such as a tenant slug. It
67
+ breaks anywhere only when a word does not fit the line, so a title with
68
+ spaces still wraps at them, and a long slug no longer widens the page.
65
69
  -->
66
70
  <h1
67
71
  data-testid="page-title"
68
- class={compact
72
+ class="[overflow-wrap:anywhere] {compact
69
73
  ? 'text-sm font-semibold text-fg'
70
- : 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}
74
+ : 'text-h2 leading-h2 tracking-h2 font-bold text-fg'}"
71
75
  >
72
76
  {title}
73
77
  </h1>
@@ -130,15 +130,60 @@
130
130
  }
131
131
 
132
132
  /**
133
- * A page number is unique in the list, so it is its own key. Both gaps are
134
- * spelled 'gap', so they take their position as well. Keying on the index
135
- * alone made every slot change identity when a page was inserted ahead of it.
133
+ * The pages a phone held by a finger shows: the first, the last and the
134
+ * current one, with a gap for each run between them. Five slots at most, so
135
+ * Previous, Next and the five each take a 44px box on one row of a phone.
136
+ * Every page in it is also in the list above, which keeps one element per
137
+ * page when the two lists are drawn together.
136
138
  */
137
- function slotKey(slot: PageSlot, index: number): string {
138
- return slot === 'gap' ? `gap-${index}` : `page-${slot}`;
139
+ function touchPageNumbers(current: number, last: number): Set<number> {
140
+ if (last <= 5) return new Set(Array.from({ length: last }, (_, i) => i + 1));
141
+ return new Set([1, current, last]);
139
142
  }
140
143
 
141
- let nums = $derived(counted ? pageNumbers(safePage, totalPages) : []);
144
+ /**
145
+ * A slot is a page or a gap, and `only` names the one layout that draws it:
146
+ * `fine` for a page or gap the touch layout folds away, `touch` for a gap
147
+ * only the touch layout draws. Unset means both draw it.
148
+ *
149
+ * Both layouts are in the markup and the stylesheet picks one, because the
150
+ * pointer is a media query: a list chosen by script would draw the mouse
151
+ * layout on the server and switch after hydration. A gap stands where the
152
+ * run it folds starts, so a gap both layouts draw is one element, and a page
153
+ * never disappears without a gap in its place.
154
+ */
155
+ type Slot = { key: string; page: number | null; only?: 'fine' | 'touch' };
156
+
157
+ function pageSlots(current: number, last: number): Slot[] {
158
+ const fine = new Set(pageNumbers(current, last).filter((s): s is number => s !== 'gap'));
159
+ const touch = touchPageNumbers(current, last);
160
+ const starts = new Set<number>();
161
+ for (const p of fine) starts.add(p).add(p + 1);
162
+ const slots: Slot[] = [];
163
+ for (const p of [...starts].filter((p) => p <= last).sort((a, b) => a - b)) {
164
+ const fineGap = !fine.has(p) && fine.has(p - 1);
165
+ const touchGap = !touch.has(p) && touch.has(p - 1);
166
+ if (fineGap || touchGap) {
167
+ const only = fineGap && touchGap ? undefined : fineGap ? 'fine' : 'touch';
168
+ slots.push({ key: `gap-${p}`, page: null, only });
169
+ }
170
+ if (fine.has(p))
171
+ slots.push({ key: `page-${p}`, page: p, only: touch.has(p) ? undefined : 'fine' });
172
+ }
173
+ return slots;
174
+ }
175
+
176
+ /**
177
+ * The display of a slot. A touch-only gap carries no base `inline-flex`,
178
+ * which would tie with `hidden` and leave the outcome to stylesheet order.
179
+ */
180
+ function display(only: Slot['only']): string {
181
+ if (only === 'fine') return 'inline-flex max-sm:coarse:hidden';
182
+ if (only === 'touch') return 'hidden max-sm:coarse:inline-flex';
183
+ return 'inline-flex';
184
+ }
185
+
186
+ let slots = $derived(counted ? pageSlots(safePage, totalPages) : []);
142
187
 
143
188
  /**
144
189
  * The noun as it will be read.
@@ -181,9 +226,12 @@
181
226
  }
182
227
 
183
228
  /**
184
- * 28px drawn, 44px under a finger. The steps sit 2px apart, so growing the
185
- * visual would put a seven-page pager past a phone's width. The hit box
186
- * grows instead and the pager keeps its shape.
229
+ * 28px drawn, 44px under a finger. Growing the visual would push the
230
+ * neighbors apart, so the hit box grows instead. Under a finger the steps
231
+ * sit 16px apart, so each 44px box meets its neighbor's without covering
232
+ * it. Nine steps that far apart are 380px, wider than a phone, so a phone
233
+ * held by a finger shows the touch list of five pages and gaps instead, and
234
+ * from the sm width up the full list fits.
187
235
  */
188
236
  const btnBase = `${HIT_AREA} inline-flex items-center justify-center w-7 h-7 rounded text-xs font-medium transition-colors`;
189
237
 
@@ -266,15 +314,19 @@
266
314
  <span class="shrink-0 text-xs text-faint">{summary}</span>
267
315
 
268
316
  {#if hasControls}
269
- <div class="ms-auto flex flex-wrap items-center gap-0.5">
317
+ <div class="ms-auto flex flex-wrap items-center gap-0.5 coarse:gap-4">
270
318
  {@render step(safePage - 1, 'Previous page', canPrev, 'M15 18l-6-6 6-6')}
271
319
 
272
- {#each nums as n, i (slotKey(n, i))}
273
- {#if n === 'gap'}
320
+ {#each slots as slot (slot.key)}
321
+ {@const n = slot.page}
322
+ {#if n === null}
274
323
  <!-- Three drawn dots, not the ellipsis character: a font glyph lands
275
324
  at a different optical weight from every other icon here, and the
276
325
  character is what the screen reader was speaking. -->
277
- <span class="inline-flex w-7 items-center justify-center text-faint" aria-hidden="true">
326
+ <span
327
+ class="{display(slot.only)} w-7 items-center justify-center text-faint"
328
+ aria-hidden="true"
329
+ >
278
330
  <svg
279
331
  width="14"
280
332
  height="14"
@@ -292,16 +344,20 @@
292
344
  <a
293
345
  href={linkTo(n, true)}
294
346
  aria-current={safePage === n ? 'page' : undefined}
295
- class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
347
+ class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
348
+ ? btnCurrent
349
+ : btnRest}"
296
350
  >
297
351
  {n}
298
352
  </a>
299
353
  {:else}
300
354
  <button
301
355
  type="button"
302
- onclick={() => onchange?.(n as number)}
356
+ onclick={() => onchange?.(n)}
303
357
  aria-current={safePage === n ? 'page' : undefined}
304
- class="{btnBase} {safePage === n ? btnCurrent : btnRest}"
358
+ class="{btnBase} {slot.only ? 'max-sm:coarse:hidden' : ''} {safePage === n
359
+ ? btnCurrent
360
+ : btnRest}"
305
361
  >
306
362
  {n}
307
363
  </button>
@@ -99,6 +99,10 @@
99
99
  * Button does, and each segment keeps its visual inside the track and takes
100
100
  * a hit box of the control size. A segment that grew instead would stand
101
101
  * the track six pixels proud of the Input beside it.
102
+ *
103
+ * A segment is never narrower than the control size under a finger, so a
104
+ * short label such as "6h" takes a 44px target across as well, and the hit
105
+ * boxes of two neighbors do not overlap.
102
106
  */
103
107
  const SIZES: Record<Size, { row: string; segment: string; icon: number }> = {
104
108
  sm: { row: 'h-8 coarse:min-h-control', segment: 'gap-1.5 px-2.5 text-xs', icon: 14 },
@@ -228,7 +232,16 @@
228
232
  carries no aria-checked, and what marks the current one is aria-current,
229
233
  the attribute a reader already knows from navigation. ARIA allows
230
234
  aria-invalid on a radiogroup and not on a group, so a row of links
231
- reports an error through aria-describedby alone. -->
235
+ reports an error through aria-describedby alone.
236
+
237
+ Under a finger the track draws its frame as an inset outline with no
238
+ border, and its padding grows by the border's pixel. A caller that
239
+ lets a long row scroll clips the segments at the track's padding box,
240
+ which a border keeps 2px short of the outer edge, so each segment's
241
+ 44px hit box lost a pixel at the top and the bottom. The track's own
242
+ overflow does not clip its outline, and forced colors keep an outline
243
+ where they drop a shadow. The drawn frame and the segments stay where
244
+ they were. -->
232
245
  <div
233
246
  bind:this={rootEl}
234
247
  {id}
@@ -238,14 +251,16 @@
238
251
  aria-describedby={describedByIds}
239
252
  onfocusout={linked ? onGroupFocusOut : undefined}
240
253
  class="inline-flex items-stretch rounded-lg border {error
241
- ? 'border-danger'
242
- : 'border-line-strong'} bg-surface-2 p-0.5 {SIZES[size].row} {disabled ? 'opacity-50' : ''}"
254
+ ? 'border-danger coarse:outline-danger'
255
+ : 'border-line-strong coarse:outline-line-strong'} bg-surface-2 p-0.5 coarse:border-0 coarse:p-0.75 coarse:outline coarse:outline-1 coarse:-outline-offset-1 {SIZES[
256
+ size
257
+ ].row} {disabled ? 'opacity-50' : ''}"
243
258
  >
244
259
  {#each options as option, i (option.value)}
245
260
  {@const selected = option.value === value}
246
261
  {@const Icon = option.icon}
247
262
  {@const segment =
248
- `${HIT_AREA} inline-flex items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
263
+ `${HIT_AREA} inline-flex coarse:min-w-control items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand disabled:cursor-not-allowed ` +
249
264
  SIZES[size].segment +
250
265
  (selected
251
266
  ? ' bg-surface text-brand shadow-sm ring-1 ring-inset ring-brand/40'
package/dist/index.d.ts CHANGED
@@ -103,4 +103,4 @@ export { cn, type ClassValue } from './utils/cn.js';
103
103
  export { getTheme, getThemePreference, nextThemePreference, resolveTheme, setTheme, setThemePreference, systemTheme, themeBootScript, watchThemePreference, toggleTheme, watchSystemTheme, THEME_PREFERENCES, type Theme, type ThemePreference, } from './utils/theme.js';
104
104
  export * as motion from './motion.js';
105
105
  export type { Rung as MotionRung, Curve as MotionCurve } from './motion.js';
106
- export declare const VERSION = "0.38.0";
106
+ export declare const VERSION = "0.38.2";
package/dist/index.js CHANGED
@@ -116,4 +116,4 @@ export * as motion from './motion.js';
116
116
  // ── Version ────────────────────────────────────────────────────────────────
117
117
  // Generated from package.json by `pnpm version:sync`. Bump package.json, never
118
118
  // this line. The build and the test suite fail when the two disagree.
119
- export const VERSION = '0.38.0';
119
+ export const VERSION = '0.38.2';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "description": "A clean, accessible, palette-aware Svelte 5 component library. The design system behind LyEve.",
5
5
  "license": "MIT",
6
6
  "author": "LyEve Labs <hello@lyeve.com>",