@r2digisolutions/components 0.14.11 → 0.14.13

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.
@@ -1,5 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { on } from 'svelte/events';
3
4
  import ComboboxItem from '../ComboboxItem/ComboboxItem.svelte';
4
5
  import {
5
6
  COMBOBOX_CREATE_KEY,
@@ -65,8 +66,11 @@
65
66
  }: ComboboxProps = $props();
66
67
 
67
68
  let rootEl = $state<HTMLDivElement | null>(null);
69
+ let triggerEl = $state<HTMLDivElement | null>(null);
70
+ let popoverEl = $state<HTMLDivElement | null>(null);
68
71
  let inputEl = $state<HTMLInputElement | null>(null);
69
72
  let highlighted = $state(0);
73
+ let openingGesture = $state(false);
70
74
  let childEntries = $state<{ id: number; value: string; disabled: boolean; label: string }[]>([]);
71
75
  let nextChildId = 0;
72
76
  const uid = $props.id();
@@ -117,8 +121,96 @@
117
121
  const highlightedKey = $derived(navKeys[activeIndex] ?? null);
118
122
  const hasRows = $derived(items.length > 0 || childEntries.length > 0 || Boolean(children));
119
123
 
124
+ function positionPopover(opts: { measure?: boolean } = {}) {
125
+ if (!triggerEl || !popoverEl) return;
126
+ if (!popoverEl.matches(':popover-open') && opts.measure !== false) return;
127
+
128
+ const rect = triggerEl.getBoundingClientRect();
129
+ if (rect.width < 2 && rect.height < 2) return;
130
+
131
+ const gap = 8;
132
+ const margin = 8;
133
+ const maxH = 240;
134
+ const spaceBelow = window.innerHeight - rect.bottom - gap - margin;
135
+ const spaceAbove = rect.top - gap - margin;
136
+ const openUp = spaceBelow < 120 && spaceAbove > spaceBelow;
137
+ const height = Math.min(maxH, Math.max(80, openUp ? spaceAbove : spaceBelow));
138
+
139
+ const top = openUp ? undefined : rect.bottom + gap;
140
+ const bottom = openUp ? window.innerHeight - rect.top + gap : undefined;
141
+
142
+ const style = [
143
+ 'position:fixed',
144
+ 'margin:0',
145
+ 'inset:auto',
146
+ `left:${Math.max(margin, Math.min(rect.left, window.innerWidth - rect.width - margin))}px`,
147
+ `width:${rect.width}px`,
148
+ top !== undefined ? `top:${top}px` : 'top:auto',
149
+ bottom !== undefined ? `bottom:${bottom}px` : 'bottom:auto',
150
+ `max-height:${height}px`
151
+ ].join(';');
152
+
153
+ popoverEl.style.cssText = style;
154
+ }
155
+
156
+ function syncNative() {
157
+ if (!popoverEl) return;
158
+ const isOpen = popoverEl.matches(':popover-open');
159
+ try {
160
+ if (open && !isOpen) popoverEl.showPopover();
161
+ else if (!open && isOpen) popoverEl.hidePopover();
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ }
166
+
167
+ function onDocPointerDown(event: PointerEvent) {
168
+ if (!open || openingGesture) return;
169
+ const target = event.target;
170
+ if (!(target instanceof Node)) return;
171
+ if (rootEl?.contains(target) || popoverEl?.contains(target)) return;
172
+ setOpen(false);
173
+ }
174
+
175
+ function markOpeningGesture() {
176
+ openingGesture = true;
177
+ queueMicrotask(() => {
178
+ openingGesture = false;
179
+ });
180
+ }
181
+
182
+ function onPopoverClose() {
183
+ if (selected) query = selected.label;
184
+ else if (!creatable) query = '';
185
+ }
186
+
187
+ function handleBeforeToggle(event: ToggleEvent) {
188
+ if (event.newState === 'open' && disabled) {
189
+ event.preventDefault();
190
+ return;
191
+ }
192
+ if (event.newState === 'open') {
193
+ positionPopover({ measure: false });
194
+ }
195
+ }
196
+
197
+ function handleToggle(event: ToggleEvent) {
198
+ const next = event.newState === 'open';
199
+ open = next;
200
+ if (next) {
201
+ highlighted = 0;
202
+ queueMicrotask(() => {
203
+ positionPopover();
204
+ requestAnimationFrame(() => positionPopover());
205
+ });
206
+ } else {
207
+ onPopoverClose();
208
+ }
209
+ }
210
+
120
211
  function setOpen(next: boolean) {
121
212
  if (disabled) return;
213
+ if (next) markOpeningGesture();
122
214
  open = next;
123
215
  if (next) {
124
216
  highlighted = 0;
@@ -207,21 +299,45 @@
207
299
  }
208
300
  }
209
301
 
210
- function onDocPointerDown(e: PointerEvent) {
211
- if (!open || !rootEl) return;
212
- const path = typeof e.composedPath === 'function' ? e.composedPath() : [];
213
- if (path.includes(rootEl) || rootEl.contains(e.target as Node)) return;
214
- setOpen(false);
215
- if (selected) query = selected.label;
216
- else if (!creatable) query = '';
217
- }
218
-
219
302
  $effect(() => {
220
303
  if (selected && !open && query !== selected.label) {
221
304
  query = selected.label;
222
305
  }
223
306
  });
224
307
 
308
+ $effect(() => {
309
+ open;
310
+ queueMicrotask(() => {
311
+ syncNative();
312
+ if (open) positionPopover();
313
+ });
314
+ });
315
+
316
+ $effect(() => {
317
+ if (!open) return;
318
+
319
+ let frame = 0;
320
+ const reposition = () => {
321
+ cancelAnimationFrame(frame);
322
+ frame = requestAnimationFrame(() => positionPopover());
323
+ };
324
+
325
+ const offScroll = on(window, 'scroll', reposition, { capture: true, passive: true });
326
+ const offResize = on(window, 'resize', reposition);
327
+ // Defer past the opening click — same gesture must not count as outside.
328
+ const timer = window.setTimeout(() => {
329
+ document.addEventListener('pointerdown', onDocPointerDown, true);
330
+ }, 0);
331
+
332
+ return () => {
333
+ cancelAnimationFrame(frame);
334
+ offScroll();
335
+ offResize();
336
+ window.clearTimeout(timer);
337
+ document.removeEventListener('pointerdown', onDocPointerDown, true);
338
+ };
339
+ });
340
+
225
341
  const ctx: ComboboxContext = {
226
342
  getValue: () => value,
227
343
  getQuery: () => query,
@@ -243,14 +359,13 @@
243
359
  setComboboxContext(ctx);
244
360
  </script>
245
361
 
246
- <svelte:document onpointerdown={onDocPointerDown} />
247
-
248
- <div class={['max-w-sm relative w-full', className]} bind:this={rootEl}>
362
+ <div class={['max-w-sm w-full', className]} bind:this={rootEl}>
249
363
  {#if label}
250
364
  <span class="mb-1.5 text-sm font-medium text-primary block">{label}</span>
251
365
  {/if}
252
366
 
253
367
  <div
368
+ bind:this={triggerEl}
254
369
  class={[
255
370
  'h-10 gap-2 rounded-xl border-border bg-surface-elevated px-3 flex items-center border transition-colors',
256
371
  open && 'border-brand-500 ring-brand-500/20 ring-2',
@@ -312,16 +427,19 @@
312
427
  {/if}
313
428
  </div>
314
429
 
315
- {#if open}
316
- <div
317
- id={listboxId}
318
- role="listbox"
319
- class="left-0 right-0 mt-2 max-h-60 rounded-xl border-border bg-surface-elevated p-1.5 shadow-xl absolute z-50 overflow-y-auto border"
320
- >
321
- {#if !hasRows}
322
- <div class="px-3 py-2.5 text-xs text-muted text-center">{emptyText}</div>
323
- {:else}
324
- {#each items as item, index (itemKey(item))}
430
+ <div
431
+ bind:this={popoverEl}
432
+ id={listboxId}
433
+ popover="manual"
434
+ role="listbox"
435
+ onbeforetoggle={handleBeforeToggle}
436
+ ontoggle={handleToggle}
437
+ class="combobox-popover inset-auto m-0 overflow-y-auto rounded-xl border border-border bg-surface-elevated p-1.5 shadow-xl outline-none"
438
+ >
439
+ {#if !hasRows}
440
+ <div class="px-3 py-2.5 text-xs text-muted text-center">{emptyText}</div>
441
+ {:else}
442
+ {#each items as item, index (itemKey(item))}
325
443
  {#if item.kind === 'create'}
326
444
  <ComboboxItem
327
445
  value={COMBOBOX_CREATE_KEY}
@@ -369,6 +487,19 @@
369
487
  {/each}
370
488
  {@render children?.()}
371
489
  {/if}
372
- </div>
373
- {/if}
490
+ </div>
374
491
  </div>
492
+
493
+ <style>
494
+ .combobox-popover {
495
+ position: fixed;
496
+ }
497
+
498
+ .combobox-popover:popover-open {
499
+ display: block;
500
+ }
501
+
502
+ .combobox-popover:not(:popover-open) {
503
+ display: none;
504
+ }
505
+ </style>
@@ -166,13 +166,14 @@
166
166
  class={[
167
167
  useSplit && 'lg:hidden',
168
168
  isCard && 'px-4 pt-4 sm:px-5 sm:pt-5',
169
+ isCard && !divided && 'pb-1',
169
170
  !isCard && 'pb-0'
170
171
  ]}
171
172
  >
172
173
  <div class="flex items-start justify-between gap-3">
173
174
  <div class="flex min-w-0 flex-1 items-start gap-3">
174
175
  {@render iconEl('md')}
175
- <div class="min-w-0 space-y-1">
176
+ <div class="min-w-0 space-y-0.5">
176
177
  {#if eyebrow}
177
178
  <p class="text-[10px] font-semibold uppercase tracking-[0.14em] text-muted">
178
179
  {eyebrow}
@@ -192,7 +193,7 @@
192
193
  </div>
193
194
 
194
195
  {#if divided && layout === 'stack'}
195
- <div class={['mt-4 border-b border-border', isCard && '-mx-4 sm:-mx-5']}></div>
196
+ <div class={['mt-3 border-b border-border', isCard && '-mx-4 sm:-mx-5']}></div>
196
197
  {/if}
197
198
  </div>
198
199
  {/if}
@@ -200,7 +201,8 @@
200
201
  {#if open}
201
202
  <div
202
203
  class={[
203
- isCard && 'px-4 py-4 sm:px-5 sm:py-5',
204
+ isCard && divided && 'px-4 py-4 sm:px-5 sm:py-5',
205
+ isCard && !divided && 'px-4 pt-2 pb-4 sm:px-5 sm:pb-5',
204
206
  !isCard && (collapsible || divided) && layout === 'stack' && 'pt-4',
205
207
  useSplit &&
206
208
  'lg:grid lg:grid-cols-[minmax(12rem,17rem)_minmax(0,1fr)] lg:items-start lg:gap-10'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.14.11",
3
+ "version": "0.14.13",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",