@lyeve-labs/ui-kit 0.38.5 → 0.39.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.
@@ -316,7 +316,7 @@
316
316
  aria-expanded={!collapsed}
317
317
  onclick={() => (collapsed = !collapsed)}
318
318
  >
319
- {#if collapsed}<PanelLeft size={18} />{:else}<PanelLeftClose size={18} />{/if}
319
+ {#if collapsed}<PanelLeft size={20} />{:else}<PanelLeftClose size={20} />{/if}
320
320
  </button>
321
321
  {/if}
322
322
  {#if section}
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { HIT_AREA } from '../internal/touch.js';
3
4
  import { cn } from '../utils/cn.js';
4
5
  import Card from './Card.svelte';
5
6
  import Logo from './Logo.svelte';
@@ -67,9 +68,12 @@
67
68
 
68
69
  <div class="mb-8 text-center">
69
70
  {#if href}
71
+ <!-- The lockup is 36px tall. Under a finger the link takes a 44px
72
+ hit box around it rather than growing, so a mouse sees the
73
+ heading where it was. -->
70
74
  <a
71
75
  {href}
72
- class="mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
76
+ class="{HIT_AREA} mb-5 inline-flex rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-brand"
73
77
  >
74
78
  {#if lockup}
75
79
  {@render lockup()}
@@ -99,9 +99,14 @@
99
99
  inside it, so the caller's actions, the reveal and the copy all fit
100
100
  without the value running under them and without measuring anything.
101
101
  The frame takes the focus ring and the brand border from the input or
102
- the textarea inside it. A focused button draws its own ring and not the frame's. -->
102
+ the textarea inside it. A focused button draws its own ring and not the frame's.
103
+ Under a finger each 27px button takes a 44px box centered on it, so the
104
+ row opens 17px between its items: neighboring boxes meet instead of
105
+ overlapping, and the first box stops short of the value. The buttons
106
+ hold their 27px around a 14px icon, the rung of the text beside it, so
107
+ those boxes stay where they are. -->
103
108
  <div
104
- class="flex w-full gap-0.5 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
109
+ class="flex w-full gap-0.5 coarse:gap-4.25 rounded-lg border bg-surface-2 pe-1 transition-colors border-line-strong {wrapped
105
110
  ? 'min-h-control items-start py-1 has-[textarea:focus]:border-brand has-[textarea:focus-visible]:ring-2 has-[textarea:focus-visible]:ring-inset has-[textarea:focus-visible]:ring-brand'
106
111
  : 'h-control items-center has-[input:focus]:border-brand has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-inset has-[input:focus-visible]:ring-brand'}"
107
112
  >
@@ -154,12 +159,12 @@
154
159
  aria-pressed={revealed}
155
160
  aria-controls={fieldId}
156
161
  aria-label={revealed ? 'Hide value' : 'Show value'}
157
- class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
162
+ class="{HIT_AREA} flex size-6.75 shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
158
163
  >
159
164
  {#if revealed}
160
- <EyeOff size={15} aria-hidden="true" />
165
+ <EyeOff size={14} aria-hidden="true" />
161
166
  {:else}
162
- <Eye size={15} aria-hidden="true" />
167
+ <Eye size={14} aria-hidden="true" />
163
168
  {/if}
164
169
  </button>
165
170
  {/if}
@@ -167,9 +172,9 @@
167
172
  type="button"
168
173
  aria-label={buttonName}
169
174
  onclick={() => copier.copy(value)}
170
- class="{HIT_AREA} flex shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
175
+ class="{HIT_AREA} flex size-6.75 shrink-0 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
171
176
  >
172
- <CopyGlyph copied={copier.status === 'copied'} size={15} />
177
+ <CopyGlyph copied={copier.status === 'copied'} size={14} />
173
178
  </button>
174
179
  </div>
175
180
 
@@ -246,8 +246,8 @@
246
246
  the part of the scroll region nobody could reach and was cut below the
247
247
  weekday row. The width stays here, sized to seven columns. The region
248
248
  inside is capped by the room and not by the list token, which is shorter
249
- than six weeks of days, so a short modal scrolls the calendar rather
250
- than cutting it.
249
+ than six weeks of days, and never below the whole grid: in a modal body
250
+ too short for it on either side, placePanel makes room under the field.
251
251
 
252
252
  Under a finger each day keeps its 32px face and a 44px hit box, so the
253
253
  days have to sit 44px apart or each box covers a third of its
@@ -21,6 +21,11 @@
21
21
  describedBy: string | undefined;
22
22
  /** True while an error is showing, for the control's `aria-invalid`. */
23
23
  invalid: boolean;
24
+ /**
25
+ * The id of the caption, for a group's `aria-labelledby`. Set only when
26
+ * the field is a `group` with a label.
27
+ */
28
+ labeledBy: string | undefined;
24
29
  }
25
30
  </script>
26
31
 
@@ -40,6 +45,13 @@
40
45
  label?: string;
41
46
  /** Screen-reader-only label, for a control whose visible name is a table column header. */
42
47
  labelHidden?: boolean;
48
+ /**
49
+ * The control is a group, a `SegmentedControl`, `FilterChips` or a row of
50
+ * your own. A label's `for` reaches one form control and never a group, so
51
+ * the caption is drawn as text with an id instead, and the group takes its
52
+ * name from it through `labeledBy`.
53
+ */
54
+ group?: boolean;
43
55
  hint?: string;
44
56
  /** Replaces the hint while it is set, and marks the wiring invalid. */
45
57
  error?: string;
@@ -58,6 +70,7 @@
58
70
  id = undefined,
59
71
  label = undefined,
60
72
  labelHidden = false,
73
+ group = false,
61
74
  hint = undefined,
62
75
  error = undefined,
63
76
  required = false,
@@ -78,6 +91,7 @@
78
91
  id: fieldId,
79
92
  describedBy: describedBy(fieldId, error, hint),
80
93
  invalid: !!error,
94
+ labeledBy: group && label ? `${fieldId}-label` : undefined,
81
95
  });
82
96
  </script>
83
97
 
@@ -89,9 +103,15 @@
89
103
  the reading order, so a reader moving through a row of them would meet
90
104
  the controls with nothing ahead of them saying what each one is.
91
105
  -->
92
- <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
93
- {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
94
- </label>
106
+ {#if group}
107
+ <span id="{fieldId}-label" class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
108
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
109
+ </span>
110
+ {:else}
111
+ <label for={fieldId} class="{FIELD_LABEL} {labelHidden ? 'sr-only' : ''}">
112
+ {label}{#if required}<span class="ms-0.5 text-danger" aria-hidden="true">*</span>{/if}
113
+ </label>
114
+ {/if}
95
115
  {/if}
96
116
 
97
117
  {@render children(wiring)}
@@ -20,6 +20,11 @@ export interface FieldWiring {
20
20
  describedBy: string | undefined;
21
21
  /** True while an error is showing, for the control's `aria-invalid`. */
22
22
  invalid: boolean;
23
+ /**
24
+ * The id of the caption, for a group's `aria-labelledby`. Set only when
25
+ * the field is a `group` with a label.
26
+ */
27
+ labeledBy: string | undefined;
23
28
  }
24
29
  import type { Snippet } from 'svelte';
25
30
  interface Props {
@@ -28,6 +33,13 @@ interface Props {
28
33
  label?: string;
29
34
  /** Screen-reader-only label, for a control whose visible name is a table column header. */
30
35
  labelHidden?: boolean;
36
+ /**
37
+ * The control is a group, a `SegmentedControl`, `FilterChips` or a row of
38
+ * your own. A label's `for` reaches one form control and never a group, so
39
+ * the caption is drawn as text with an id instead, and the group takes its
40
+ * name from it through `labeledBy`.
41
+ */
42
+ group?: boolean;
31
43
  hint?: string;
32
44
  /** Replaces the hint while it is set, and marks the wiring invalid. */
33
45
  error?: string;
@@ -63,7 +63,11 @@
63
63
  ...rest
64
64
  }: Props = $props();
65
65
 
66
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
66
+ // $props.id() and not the label: two fields under one label on a page took
67
+ // one id, and both labels named the first control. It also matches between
68
+ // the server render and hydration, which a random string would not.
69
+ const uid = $props.id();
70
+ const fieldId = $derived(id ?? uid);
67
71
  </script>
68
72
 
69
73
  <div data-field class="{FIELD_WRAP} {klass}">
@@ -87,8 +91,8 @@
87
91
  />
88
92
 
89
93
  {#if error}
90
- <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
94
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
91
95
  {:else if hint}
92
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
96
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
93
97
  {/if}
94
98
  </div>
@@ -183,13 +183,15 @@
183
183
  reaches 12px above and below the chip and 8px past its end. The chips
184
184
  sit 8px apart there and their rows 24px apart, so no box covers the
185
185
  next chip or the row below, and a tap meant to open the list lands on
186
- the field.
186
+ the field. The frame pads its chips 11px from its border there, so the
187
+ boxes of the top and bottom rows stay inside the field, and one row
188
+ still fills the 44px control height.
187
189
  -->
188
190
  <div
189
191
  role="presentation"
190
192
  onclick={onFrameClick}
191
193
  class="relative flex min-h-control w-full cursor-pointer flex-wrap items-center gap-1.5 coarse:gap-x-2 coarse:gap-y-6 rounded-lg
192
- border bg-surface-2 py-1.5 ps-2.5 pe-7 text-start text-sm transition-colors
194
+ border bg-surface-2 py-1.5 coarse:py-2.75 ps-2.5 pe-7 text-start text-sm transition-colors
193
195
  has-[[role=combobox]:focus-visible]:ring-2 has-[[role=combobox]:focus-visible]:ring-inset
194
196
  has-[[role=combobox]:focus-visible]:ring-brand
195
197
  {disabled ? 'cursor-not-allowed opacity-50' : ''}
@@ -41,7 +41,11 @@
41
41
  onchange = undefined,
42
42
  }: Props = $props();
43
43
 
44
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
44
+ // $props.id() and not the label: two fields under one label on a page took
45
+ // one id, and both labels named the first control. It also matches between
46
+ // the server render and hydration, which a random string would not.
47
+ const uid = $props.id();
48
+ const fieldId = $derived(id ?? uid);
45
49
 
46
50
  function dec() {
47
51
  const next = value - step;
@@ -75,11 +79,15 @@
75
79
  // Under a finger the control token grows to 44px, and steppers as wide as the
76
80
  // token left the number 40px inside a 128px field. They narrow instead and
77
81
  // take a hit box of the control size, so the number keeps its room and each
78
- // stepper is still a 44px target.
82
+ // stepper is still a 44px target. A box centered on a 32px stepper reaches
83
+ // 6px past each side, into the number on one of them, so each box is pinned
84
+ // to the edge the stepper shares with the number and grows outward instead.
85
+ // The sides are logical, so a right-to-left page mirrors them.
79
86
  const step_ =
80
87
  `${HIT_AREA} flex w-control shrink-0 items-center justify-center border-line-strong bg-surface-2 text-muted coarse:w-8 ` +
81
88
  'transition-colors hover:bg-line hover:text-fg ' +
82
- 'disabled:opacity-50 disabled:cursor-not-allowed';
89
+ 'disabled:opacity-50 disabled:cursor-not-allowed ' +
90
+ 'coarse:before:translate-x-0 coarse:before:-translate-y-1/2';
83
91
  </script>
84
92
 
85
93
  <div data-field class="{FIELD_WRAP} {cls}">
@@ -95,7 +103,7 @@
95
103
  onclick={dec}
96
104
  disabled={!canDec}
97
105
  aria-label="Decrease"
98
- class="{step_} rounded-s-lg border border-e-0"
106
+ class="{step_} rounded-s-lg border border-e-0 coarse:before:start-auto coarse:before:end-0"
99
107
  >
100
108
  <svg
101
109
  width="14"
@@ -139,7 +147,7 @@
139
147
  onclick={inc}
140
148
  disabled={!canInc}
141
149
  aria-label="Increase"
142
- class="{step_} rounded-e-lg border border-s-0"
150
+ class="{step_} rounded-e-lg border border-s-0 coarse:before:start-0 coarse:before:end-auto"
143
151
  >
144
152
  <svg
145
153
  width="14"
@@ -157,8 +165,8 @@
157
165
  </div>
158
166
 
159
167
  {#if error}
160
- <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
168
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
161
169
  {:else if hint}
162
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
170
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
163
171
  {/if}
164
172
  </div>
@@ -117,19 +117,21 @@
117
117
  {#if showToggle}
118
118
  <!-- type="button" is load bearing. A button inside a form defaults to
119
119
  type="submit", so a reveal control on a login form posted the form
120
- on the first click and the user never saw what they had typed. -->
120
+ on the first click and the user never saw what they had typed.
121
+ The button holds its 27px around a 14px icon, the rung of the
122
+ text it sits on, so its touch target stays where it was. -->
121
123
  <button
122
124
  type="button"
123
125
  onclick={() => (revealed = !revealed)}
124
126
  aria-pressed={revealed}
125
127
  aria-controls={fieldId}
126
128
  aria-label={revealed ? 'Hide password' : 'Show password'}
127
- class="{HIT_AREA_POSITIONED} absolute top-1/2 end-1 flex -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
129
+ class="{HIT_AREA_POSITIONED} absolute top-1/2 end-1 flex size-6.75 -translate-y-1/2 items-center justify-center rounded-md p-1.5 text-faint outline-none transition-colors hover:text-fg focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand"
128
130
  >
129
131
  {#if revealed}
130
- <EyeOff size={15} aria-hidden="true" />
132
+ <EyeOff size={14} aria-hidden="true" />
131
133
  {:else}
132
- <Eye size={15} aria-hidden="true" />
134
+ <Eye size={14} aria-hidden="true" />
133
135
  {/if}
134
136
  </button>
135
137
  {/if}
@@ -24,7 +24,11 @@
24
24
  oninput = undefined,
25
25
  }: Props = $props();
26
26
 
27
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
27
+ // $props.id() and not the label: two fields under one label on a page took
28
+ // one id, and both labels named the first control. It also matches between
29
+ // the server render and hydration, which a random string would not.
30
+ const uid = $props.id();
31
+ const fieldId = $derived(id ?? uid);
28
32
 
29
33
  // bind:value keeps what was typed before hydration. A one-way value
30
34
  // cleared it. The binding has updated value by the time this runs.
@@ -64,6 +64,11 @@
64
64
  * own hint or error.
65
65
  */
66
66
  describedBy?: string;
67
+ /**
68
+ * The id of a caption drawn outside the row, a `Field` one for instance.
69
+ * The group takes its name from it in place of `label`.
70
+ */
71
+ labeledBy?: string;
67
72
  /** Submits the value with the surrounding form, through a hidden input. */
68
73
  name?: string;
69
74
  size?: Size;
@@ -82,6 +87,7 @@
82
87
  hint = undefined,
83
88
  error = undefined,
84
89
  describedBy: callerIds = undefined,
90
+ labeledBy = undefined,
85
91
  name = undefined,
86
92
  size = 'md',
87
93
  disabled = false,
@@ -222,9 +228,9 @@
222
228
 
223
229
  <div data-field class="{FIELD_WRAP} items-start {klass}">
224
230
  {#if !labelHidden}
225
- <!-- A caption, not a label element. The group is named by aria-label, and a
226
- label has nothing to point at here: role="radiogroup" is not a form
227
- control that `for` can name. -->
231
+ <!-- A caption, not a label element. The group is named by aria-label, or
232
+ by the caption it is handed, and a label has nothing to point at
233
+ here: role="radiogroup" is not a form control that `for` can name. -->
228
234
  <span class={FIELD_LABEL}>{label}</span>
229
235
  {/if}
230
236
 
@@ -246,7 +252,8 @@
246
252
  bind:this={rootEl}
247
253
  {id}
248
254
  role={linked ? 'group' : 'radiogroup'}
249
- aria-label={label}
255
+ aria-label={labeledBy ? undefined : label}
256
+ aria-labelledby={labeledBy}
250
257
  aria-invalid={error && !linked ? 'true' : undefined}
251
258
  aria-describedby={describedByIds}
252
259
  onfocusout={linked ? onGroupFocusOut : undefined}
@@ -50,6 +50,11 @@ declare function $$render<T extends string>(): {
50
50
  * own hint or error.
51
51
  */
52
52
  describedBy?: string;
53
+ /**
54
+ * The id of a caption drawn outside the row, a `Field` one for instance.
55
+ * The group takes its name from it in place of `label`.
56
+ */
57
+ labeledBy?: string;
53
58
  /** Submits the value with the surrounding form, through a hidden input. */
54
59
  name?: string;
55
60
  size?: "sm" | "md";
@@ -124,14 +124,11 @@
124
124
  * render and hydration, and both the label association and every idref the
125
125
  * listbox emits are built from this one.
126
126
  *
127
- * The label-derived id stays for a control that has a label, because call
128
- * sites point their own markup at it. The fallback used to be undefined,
129
- * which left an error message with no id and the control with no
130
- * aria-describedby, so an unlabeled field announced its value and never the
131
- * reason it was rejected.
127
+ * The id is never made from the label: two fields under one label on a
128
+ * page took one id, and both labels named the first control.
132
129
  */
133
130
  const uid = $props.id();
134
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : uid));
131
+ const fieldId = $derived(id ?? uid);
135
132
 
136
133
  let query = $state('');
137
134
  let triggerEl = $state<HTMLButtonElement | undefined>();
@@ -55,7 +55,11 @@
55
55
  onblur?: (e: FocusEvent & { currentTarget: TA }) => void;
56
56
  } = $props();
57
57
 
58
- const fieldId = $derived(id ?? (label ? label.toLowerCase().replace(/\s+/g, '-') : undefined));
58
+ // $props.id() and not the label: two fields under one label on a page took
59
+ // one id, and both labels named the first control. It also matches between
60
+ // the server render and hydration, which a random string would not.
61
+ const uid = $props.id();
62
+ const fieldId = $derived(id ?? uid);
59
63
  </script>
60
64
 
61
65
  <div data-field class="{FIELD_WRAP} {cls}">
@@ -85,8 +89,8 @@
85
89
  ></textarea>
86
90
 
87
91
  {#if error}
88
- <p id={fieldId ? `${fieldId}-error` : undefined} class={FIELD_ERROR}>{error}</p>
92
+ <p id="{fieldId}-error" class={FIELD_ERROR}>{error}</p>
89
93
  {:else if hint}
90
- <p id={fieldId ? `${fieldId}-hint` : undefined} class={FIELD_HINT}>{hint}</p>
94
+ <p id="{fieldId}-hint" class={FIELD_HINT}>{hint}</p>
91
95
  {/if}
92
96
  </div>
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.5";
106
+ export declare const VERSION = "0.39.1";
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.5';
119
+ export const VERSION = '0.39.1';
@@ -131,8 +131,8 @@ export declare const PANEL_LIST_ATTR = "data-panel-list";
131
131
  *
132
132
  * The token cap is sized for a list of rows, and a calendar is not one: six
133
133
  * weeks of days stand taller than the token, so under it every month would
134
- * scroll by a few rows. Such a region still takes the room as its cap, so
135
- * inside a short modal it scrolls rather than being cut.
134
+ * scroll by a few rows. Such a region takes the room as its cap, and never
135
+ * less than its whole height: a calendar that scrolls is one nobody can read.
136
136
  */
137
137
  export declare const PANEL_LIST_UNCAPPED = "uncapped";
138
138
  /** The classes that put the surface under its anchor. */
@@ -158,6 +158,14 @@ export declare const PANEL_ABOVE = "bottom-full mb-1 origin-bottom";
158
158
  * the cap without this code knowing its value. A region marked uncapped takes
159
159
  * the room alone.
160
160
  *
161
+ * Neither side may hold even that: a one-field modal body is about 100px
162
+ * tall, and capped to it a calendar showed its month row and a list no whole
163
+ * row. When neither side has room for the region's least height, the panel
164
+ * opens below at its own height and the anchor takes a bottom margin of that
165
+ * height, so the container grows to hold it or scrolls to it, and the panel
166
+ * is brought into view. The room is kept until the panel closes, because the
167
+ * container it lengthened would otherwise read as room to spare.
168
+ *
161
169
  * A panel that would run past the left or the right edge of the clipping
162
170
  * ancestor or the viewport slides back inside both, and one wider than that
163
171
  * room shrinks to it.
@@ -141,8 +141,8 @@ export const PANEL_LIST_ATTR = 'data-panel-list';
141
141
  *
142
142
  * The token cap is sized for a list of rows, and a calendar is not one: six
143
143
  * weeks of days stand taller than the token, so under it every month would
144
- * scroll by a few rows. Such a region still takes the room as its cap, so
145
- * inside a short modal it scrolls rather than being cut.
144
+ * scroll by a few rows. Such a region takes the room as its cap, and never
145
+ * less than its whole height: a calendar that scrolls is one nobody can read.
146
146
  */
147
147
  export const PANEL_LIST_UNCAPPED = 'uncapped';
148
148
  /** The classes that put the surface under its anchor. */
@@ -154,6 +154,27 @@ export const PANEL_ABOVE = 'bottom-full mb-1 origin-bottom';
154
154
  * panel that fits exactly does not sit on the edge of the window or the modal.
155
155
  */
156
156
  const PANEL_EDGE_GAP = 8;
157
+ /** The gap PANEL_BELOW opens between the anchor and the surface, mt-1. */
158
+ const PANEL_OFFSET = 4;
159
+ /**
160
+ * The fewest rows a list keeps in view, or all of them when it has fewer.
161
+ * Below that a list is a slot that scrolls one row at a time.
162
+ */
163
+ const PANEL_MIN_ROWS = 4;
164
+ /**
165
+ * The least height a region is usable at: an uncapped region's whole height,
166
+ * a list's first few rows and its padding, or a short list whole.
167
+ */
168
+ function leastHeight(list, capped) {
169
+ const rows = list.querySelectorAll('[role="option"], [role="menuitem"]');
170
+ if (!capped || rows.length <= PANEL_MIN_ROWS)
171
+ return list.scrollHeight;
172
+ const style = getComputedStyle(list);
173
+ let height = (parseFloat(style.paddingTop) || 0) + (parseFloat(style.paddingBottom) || 0);
174
+ for (let i = 0; i < PANEL_MIN_ROWS; i++)
175
+ height += rows[i].offsetHeight;
176
+ return height;
177
+ }
157
178
  /**
158
179
  * The nearest ancestor that clips or scrolls its content.
159
180
  *
@@ -197,6 +218,14 @@ function swap(node, remove, add) {
197
218
  * the cap without this code knowing its value. A region marked uncapped takes
198
219
  * the room alone.
199
220
  *
221
+ * Neither side may hold even that: a one-field modal body is about 100px
222
+ * tall, and capped to it a calendar showed its month row and a list no whole
223
+ * row. When neither side has room for the region's least height, the panel
224
+ * opens below at its own height and the anchor takes a bottom margin of that
225
+ * height, so the container grows to hold it or scrolls to it, and the panel
226
+ * is brought into view. The room is kept until the panel closes, because the
227
+ * container it lengthened would otherwise read as room to spare.
228
+ *
200
229
  * A panel that would run past the left or the right edge of the clipping
201
230
  * ancestor or the viewport slides back inside both, and one wider than that
202
231
  * room shrinks to it.
@@ -212,6 +241,8 @@ export function placePanel(node) {
212
241
  const anchor = node.offsetParent instanceof HTMLElement ? node.offsetParent : node.parentElement;
213
242
  const clip = anchor === null ? null : clippingAncestor(anchor);
214
243
  const list = node.querySelector(`[${PANEL_LIST_ATTR}]`);
244
+ const capped = list?.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
245
+ let reserved = false;
215
246
  function measure() {
216
247
  if (anchor === null)
217
248
  return;
@@ -227,7 +258,11 @@ export function placePanel(node) {
227
258
  const below = clipBottom - rect.bottom - PANEL_EDGE_GAP;
228
259
  const above = rect.top - clipTop - PANEL_EDGE_GAP;
229
260
  const natural = node.scrollHeight;
230
- const flip = natural > below && above > below;
261
+ // What the surface adds around the list, a search field for instance, is
262
+ // taken out of the room so the whole panel fits and not only its list.
263
+ const chrome = list === null ? 0 : Math.max(0, node.offsetHeight - list.offsetHeight);
264
+ const reserve = reserved || (list !== null && chrome + leastHeight(list, capped) > Math.max(above, below));
265
+ const flip = !reserve && natural > below && above > below;
231
266
  if (flip)
232
267
  swap(node, PANEL_BELOW, PANEL_ABOVE);
233
268
  else
@@ -260,15 +295,35 @@ export function placePanel(node) {
260
295
  shift = lo - left;
261
296
  }
262
297
  node.style.translate = shift === 0 ? '' : `${shift}px 0`;
263
- if (list === null)
298
+ if (list === null || anchor === null)
264
299
  return;
265
- // What the surface adds around the list, a search field for instance, is
266
- // taken out of the room so the whole panel fits and not only its list.
267
- const chrome = Math.max(0, node.offsetHeight - list.offsetHeight);
300
+ if (reserve) {
301
+ anchor.style.marginBottom = `${node.offsetHeight + PANEL_OFFSET}px`;
302
+ if (!reserved)
303
+ reveal();
304
+ reserved = true;
305
+ return;
306
+ }
268
307
  const room = Math.max(0, Math.round((flip ? above : below) - chrome));
269
- const capped = list.getAttribute(PANEL_LIST_ATTR) !== PANEL_LIST_UNCAPPED;
270
308
  list.style.maxHeight = capped ? `min(var(--spacing-panel-max), ${room}px)` : `${room}px`;
271
309
  }
310
+ /**
311
+ * Scrolls the container down until the panel's bottom is in view, or its
312
+ * top when it is taller than the container. Down only: scrollIntoView also
313
+ * scrolls sideways, and a modal body on a 320px phone took a 20px sideways
314
+ * scroll from the room the panel's slide back had already made up.
315
+ */
316
+ function reveal() {
317
+ const target = clip ?? window;
318
+ if (typeof target.scrollBy !== 'function')
319
+ return;
320
+ const top = node.getBoundingClientRect().top;
321
+ const clipTop = clip === null ? 0 : clip.getBoundingClientRect().top;
322
+ const clipBottom = clip === null ? window.innerHeight : clip.getBoundingClientRect().bottom;
323
+ const by = Math.min(top + node.offsetHeight + PANEL_EDGE_GAP - clipBottom, top - clipTop);
324
+ if (by > 0)
325
+ target.scrollBy(0, by);
326
+ }
272
327
  measure();
273
328
  const scroller = clip ?? window;
274
329
  window.addEventListener('resize', measure, { passive: true });
@@ -277,6 +332,8 @@ export function placePanel(node) {
277
332
  destroy() {
278
333
  window.removeEventListener('resize', measure);
279
334
  scroller.removeEventListener('scroll', measure);
335
+ if (reserved && anchor !== null)
336
+ anchor.style.marginBottom = '';
280
337
  },
281
338
  };
282
339
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyeve-labs/ui-kit",
3
- "version": "0.38.5",
3
+ "version": "0.39.1",
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>",