@makolabs/ripple 3.0.1 → 3.0.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.
@@ -56,7 +56,7 @@
56
56
  styles.header(),
57
57
  'flex gap-3',
58
58
  headerClass,
59
- disabled && 'cursor-not-allowed opacity-50'
59
+ disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
60
60
  )}
61
61
  aria-expanded={open}
62
62
  aria-controls={id}
@@ -47,9 +47,7 @@
47
47
  };
48
48
 
49
49
  const filteredItems = $derived(
50
- onsearch
51
- ? items // when server-side, parent controls the filtered list
52
- : items.filter((item) => (filter ?? defaultFilter)(item, query))
50
+ onsearch ? items : items.filter((item) => (filter ?? defaultFilter)(item, debouncedQuery))
53
51
  );
54
52
 
55
53
  // Reset highlight when list shrinks
@@ -62,6 +60,10 @@
62
60
 
63
61
  function openMenu() {
64
62
  if (disabled) return;
63
+ if (skipNextFocusOpen) {
64
+ skipNextFocusOpen = false;
65
+ return;
66
+ }
65
67
  open = true;
66
68
  query = '';
67
69
  }
@@ -78,21 +80,35 @@
78
80
  closeMenu();
79
81
  }
80
82
 
83
+ let skipNextFocusOpen = false;
84
+
81
85
  function clear(e: MouseEvent) {
82
86
  e.stopPropagation();
83
87
  value = null;
84
88
  onchange?.(null);
89
+ skipNextFocusOpen = true;
85
90
  inputEl?.focus();
86
91
  }
87
92
 
93
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
94
+ let debouncedQuery = $state('');
95
+
88
96
  function handleInput(e: Event) {
89
97
  const v = (e.currentTarget as HTMLInputElement).value;
90
98
  query = v;
91
99
  open = true;
92
- highlightedIndex = 0;
93
- onsearch?.(v);
100
+ clearTimeout(debounceTimer);
101
+ debounceTimer = setTimeout(() => {
102
+ debouncedQuery = v;
103
+ highlightedIndex = 0;
104
+ onsearch?.(v);
105
+ }, 1000);
94
106
  }
95
107
 
108
+ $effect(() => {
109
+ return () => clearTimeout(debounceTimer);
110
+ });
111
+
96
112
  function handleKey(e: KeyboardEvent) {
97
113
  if (disabled) return;
98
114
  if (e.key === 'ArrowDown') {
@@ -169,7 +185,7 @@
169
185
  type="button"
170
186
  onclick={clear}
171
187
  aria-label="Clear selection"
172
- class="text-default-400 hover:text-default-700 flex size-5 items-center justify-center rounded"
188
+ class="text-default-400 hover:text-default-700 flex size-5 cursor-pointer items-center justify-center rounded"
173
189
  >
174
190
  <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
175
191
  <path
@@ -181,18 +197,35 @@
181
197
  </svg>
182
198
  </button>
183
199
  {/if}
184
- <svg
185
- class={cn('text-default-400 size-4 transition-transform', open && 'rotate-180')}
186
- viewBox="0 0 20 20"
187
- fill="currentColor"
188
- aria-hidden="true"
200
+ <button
201
+ type="button"
202
+ onclick={(e) => {
203
+ e.stopPropagation();
204
+ if (!disabled) {
205
+ if (open) closeMenu();
206
+ else openMenu();
207
+ }
208
+ }}
209
+ class={cn(
210
+ 'text-default-400 hover:text-default-700 flex cursor-pointer items-center justify-center rounded',
211
+ !disabled && 'hover:text-default-600'
212
+ )}
213
+ aria-label={open ? 'Close suggestions' : 'Open suggestions'}
214
+ {disabled}
189
215
  >
190
- <path
191
- fill-rule="evenodd"
192
- d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06z"
193
- clip-rule="evenodd"
194
- />
195
- </svg>
216
+ <svg
217
+ class={cn('size-4 transition-transform', open && 'rotate-180')}
218
+ viewBox="0 0 20 20"
219
+ fill="currentColor"
220
+ aria-hidden="true"
221
+ >
222
+ <path
223
+ fill-rule="evenodd"
224
+ d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06z"
225
+ clip-rule="evenodd"
226
+ />
227
+ </svg>
228
+ </button>
196
229
  </div>
197
230
 
198
231
  {#snippet content()}
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { tick } from 'svelte';
2
+ import { tick, onMount } from 'svelte';
3
3
  import { cn } from '../../helper/cls.js';
4
4
  import { buildTestId } from '../../helper/testid.js';
5
5
  import { selectTV } from './select.js';
@@ -10,6 +10,16 @@
10
10
  import { Size } from '../../variants.js';
11
11
  import Portal from '../../utils/Portal.svelte';
12
12
 
13
+ let isMobile = $state(
14
+ typeof window !== 'undefined' && window.matchMedia('(max-width: 639.98px)').matches
15
+ );
16
+ onMount(() => {
17
+ const mql = window.matchMedia('(max-width: 639.98px)');
18
+ const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
19
+ mql.addEventListener('change', handler);
20
+ return () => mql.removeEventListener('change', handler);
21
+ });
22
+
13
23
  let {
14
24
  items = [],
15
25
  value = $bindable(''),
@@ -402,7 +412,7 @@
402
412
  {/each}
403
413
  {/if}
404
414
 
405
- {#if open}
415
+ {#if open && !isMobile}
406
416
  <Portal target={labelRef}>
407
417
  <div
408
418
  class={containerClass_}
@@ -411,66 +421,92 @@
411
421
  data-testid={buildTestId('select', 'list', testId)}
412
422
  transition:fly={{ y: -8, duration: 300, easing: quintOut }}
413
423
  >
414
- {#if isSearchable}
415
- <div class={searchInputClass_}>
416
- <svg
417
- xmlns="http://www.w3.org/2000/svg"
418
- width="12"
419
- height="12"
420
- viewBox="0 0 12 12"
421
- class="size-4"
422
- >
423
- <path
424
- fill="currentColor"
425
- d="M5 1a4 4 0 1 0 2.452 7.16l2.694 2.693a.5.5 0 1 0 .707-.707L8.16 7.453A4 4 0 0 0 5 1M2 5a3 3 0 1 1 6 0a3 3 0 0 1-6 0"
426
- />
427
- </svg>
428
- <input
429
- bind:this={searchInputRef}
430
- bind:value={searchQuery}
431
- type="text"
432
- class="w-full ring-0 outline-0"
433
- placeholder="Search..."
434
- aria-label="Search select options"
435
- oninput={() => (hasSearched = true)}
436
- data-testid={buildTestId('select', 'search', testId)}
437
- />
438
- </div>
439
- {/if}
440
-
441
- {#if asyncLoading}
442
- <div class={emptyMessageClass} data-select-loading="">{loadingText}</div>
443
- {:else if filteredItems.length === 0}
444
- <div class={emptyMessageClass}>{emptyText}</div>
445
- {:else if groupedItems}
446
- <ul class={listClass_}>
447
- {#each groupedItems as group, gIdx (group.label ?? `__null__${gIdx}`)}
448
- {#if group.label !== null}
449
- <li
450
- class="text-default-500 bg-default-50 px-3 py-1.5 text-xs font-semibold tracking-wide uppercase"
451
- role="presentation"
452
- data-select-group={group.label}
453
- >
454
- {group.label}
455
- </li>
456
- {/if}
457
- {#each group.items as groupItem (groupItem.value)}
458
- {@const flatIdx = filteredItems.indexOf(groupItem)}
459
- {@const selected = valueArray.includes(groupItem.value)}
460
- {@const highlighted = flatIdx === highlightedIndex}
461
- {@render itemButton(groupItem, flatIdx, selected, highlighted)}
462
- {/each}
463
- {/each}
464
- </ul>
465
- {:else}
466
- <ul class={listClass_}>
467
- {#each filteredItems as flatItem, index (flatItem.value)}
468
- {@const selected = valueArray.includes(flatItem.value)}
469
- {@const highlighted = index === highlightedIndex}
470
- {@render itemButton(flatItem, index, selected, highlighted)}
471
- {/each}
472
- </ul>
473
- {/if}
424
+ {@render selectListContent()}
474
425
  </div>
475
426
  </Portal>
476
427
  {/if}
428
+
429
+ {#if open && isMobile}
430
+ <button
431
+ type="button"
432
+ class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
433
+ aria-label="Close"
434
+ onclick={() => (open = false)}
435
+ ></button>
436
+ <div
437
+ class="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[85vh] min-h-48 flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl"
438
+ role="listbox"
439
+ aria-labelledby="{selectId}-label"
440
+ transition:fly={{ y: 300, duration: 200, easing: quintOut }}
441
+ >
442
+ <div class="flex justify-center py-2">
443
+ <div class="bg-default-300 h-1 w-8 rounded-full"></div>
444
+ </div>
445
+ <div class="flex-1 cursor-pointer overflow-y-auto">
446
+ {@render selectListContent()}
447
+ </div>
448
+ </div>
449
+ {/if}
450
+
451
+ {#snippet selectListContent()}
452
+ {#if isSearchable}
453
+ <div class={searchInputClass_}>
454
+ <svg
455
+ xmlns="http://www.w3.org/2000/svg"
456
+ width="12"
457
+ height="12"
458
+ viewBox="0 0 12 12"
459
+ class="size-4"
460
+ >
461
+ <path
462
+ fill="currentColor"
463
+ d="M5 1a4 4 0 1 0 2.452 7.16l2.694 2.693a.5.5 0 1 0 .707-.707L8.16 7.453A4 4 0 0 0 5 1M2 5a3 3 0 1 1 6 0a3 3 0 0 1-6 0"
464
+ />
465
+ </svg>
466
+ <input
467
+ bind:this={searchInputRef}
468
+ bind:value={searchQuery}
469
+ type="text"
470
+ class="w-full ring-0 outline-0"
471
+ placeholder="Search..."
472
+ aria-label="Search select options"
473
+ oninput={() => (hasSearched = true)}
474
+ data-testid={buildTestId('select', 'search', testId)}
475
+ />
476
+ </div>
477
+ {/if}
478
+
479
+ {#if asyncLoading}
480
+ <div class={emptyMessageClass} data-select-loading="">{loadingText}</div>
481
+ {:else if filteredItems.length === 0}
482
+ <div class={emptyMessageClass}>{emptyText}</div>
483
+ {:else if groupedItems}
484
+ <ul class={listClass_}>
485
+ {#each groupedItems as group, gIdx (group.label ?? `__null__${gIdx}`)}
486
+ {#if group.label !== null}
487
+ <li
488
+ class="text-default-500 bg-default-50 px-3 py-1.5 text-xs font-semibold tracking-wide uppercase"
489
+ role="presentation"
490
+ data-select-group={group.label}
491
+ >
492
+ {group.label}
493
+ </li>
494
+ {/if}
495
+ {#each group.items as groupItem (groupItem.value)}
496
+ {@const flatIdx = filteredItems.indexOf(groupItem)}
497
+ {@const selected = valueArray.includes(groupItem.value)}
498
+ {@const highlighted = flatIdx === highlightedIndex}
499
+ {@render itemButton(groupItem, flatIdx, selected, highlighted)}
500
+ {/each}
501
+ {/each}
502
+ </ul>
503
+ {:else}
504
+ <ul class={listClass_}>
505
+ {#each filteredItems as flatItem, index (flatItem.value)}
506
+ {@const selected = valueArray.includes(flatItem.value)}
507
+ {@const highlighted = index === highlightedIndex}
508
+ {@render itemButton(flatItem, index, selected, highlighted)}
509
+ {/each}
510
+ </ul>
511
+ {/if}
512
+ {/snippet}
@@ -1,6 +1,9 @@
1
1
  <script lang="ts">
2
2
  import { cn } from '../../helper/cls.js';
3
3
  import { buildTestId } from '../../helper/testid.js';
4
+ import { fly } from 'svelte/transition';
5
+ import { quintOut } from 'svelte/easing';
6
+ import { onMount } from 'svelte';
4
7
  import type { PopoverProps, PopoverPlacement } from './popover-types.js';
5
8
 
6
9
  let {
@@ -25,13 +28,25 @@
25
28
  let showTimer: ReturnType<typeof setTimeout> | undefined;
26
29
  let hideTimer: ReturnType<typeof setTimeout> | undefined;
27
30
 
28
- // Panel position in viewport coordinates — updated on open and on
29
- // scroll/resize so the panel tracks the trigger. Kept reactive so the
30
- // style attribute re-renders when these change.
31
31
  let panelTop = $state(0);
32
32
  let panelLeft = $state(0);
33
33
  let panelTransform = $state('');
34
34
 
35
+ // Mobile detection — below `sm` (640px), click/manual popovers
36
+ // render as a bottom sheet instead of a positioned dropdown.
37
+ // Hover popovers are skipped on mobile (no hover).
38
+ let isMobile = $state(
39
+ typeof window !== 'undefined' && window.matchMedia('(max-width: 639.98px)').matches
40
+ );
41
+ onMount(() => {
42
+ const mql = window.matchMedia('(max-width: 639.98px)');
43
+ const handler = (e: MediaQueryListEvent) => (isMobile = e.matches);
44
+ mql.addEventListener('change', handler);
45
+ return () => mql.removeEventListener('change', handler);
46
+ });
47
+
48
+ const useSheet = $derived(isMobile && trigger !== 'hover');
49
+
35
50
  function show() {
36
51
  if (disabled) return;
37
52
  clearTimeout(showTimer);
@@ -65,7 +80,6 @@
65
80
  open = false;
66
81
  }
67
82
 
68
- // Clean up any in-flight timers on unmount.
69
83
  $effect(() => {
70
84
  return () => {
71
85
  clearTimeout(showTimer);
@@ -79,23 +93,12 @@
79
93
 
80
94
  function handleWindowClick(e: MouseEvent) {
81
95
  if (!closeOnOutsideClick || !open) return;
96
+ if (useSheet) return;
82
97
  const target = e.target as Node;
83
- // The panel lives outside the trigger wrapper in the DOM, so check both.
84
98
  if (wrapper?.contains(target) || panelEl?.contains(target)) return;
85
99
  close();
86
100
  }
87
101
 
88
- /**
89
- * Compute panel position from the trigger's bounding rect. Uses
90
- * `position: fixed` + a very high z-index so the panel sits above
91
- * sibling content (Storybook sidebars, sticky headers, etc.) instead
92
- * of being clipped / layered under it.
93
- *
94
- * The computed position is then clamped to the viewport with an 8px
95
- * gutter — this keeps panels on-screen near viewport edges on narrow
96
- * devices. We measure the panel's own rect so the clamp accounts for
97
- * the `translate(-50%, 0)` etc. transforms that re-anchor the panel.
98
- */
99
102
  function updatePosition() {
100
103
  if (!wrapper || !panelEl) return;
101
104
  const r = wrapper.getBoundingClientRect();
@@ -124,10 +127,6 @@
124
127
  break;
125
128
  }
126
129
 
127
- // Clamp to viewport. We re-measure the panel after applying the
128
- // transform (next frame) because its true on-screen rect depends
129
- // on `panelTransform`. Adjust `panelLeft`/`panelTop` so the rect
130
- // ends up inside [VIEWPORT_GUTTER, viewport - VIEWPORT_GUTTER].
131
130
  requestAnimationFrame(() => {
132
131
  if (!panelEl) return;
133
132
  const pr = panelEl.getBoundingClientRect();
@@ -144,13 +143,8 @@
144
143
  });
145
144
  }
146
145
 
147
- /**
148
- * Re-measure on every open, and while open, on scroll/resize so the
149
- * panel follows its trigger when the page scrolls.
150
- */
151
146
  $effect(() => {
152
- if (!open) return;
153
- // Initial placement (defer one frame so panelEl has mounted).
147
+ if (!open || useSheet) return;
154
148
  requestAnimationFrame(updatePosition);
155
149
 
156
150
  const handler = () => updatePosition();
@@ -162,15 +156,6 @@
162
156
  };
163
157
  });
164
158
 
165
- /**
166
- * Portal the panel element to `document.body` once it mounts, so any
167
- * ancestor with `transform` / `filter` / `will-change` (Storybook's
168
- * docs container, a scaled preview, a card with transform-based
169
- * hover, etc.) doesn't re-parent our `position: fixed` panel and
170
- * clip / mis-position it. On unmount, we simply remove the node —
171
- * Svelte's own `{#if}` teardown will still work because it tracks
172
- * the element by reference, not by DOM path.
173
- */
174
159
  $effect(() => {
175
160
  if (!panelEl) return;
176
161
  const el = panelEl;
@@ -202,14 +187,6 @@
202
187
  data-testid={buildTestId('popover', undefined, testId)}
203
188
  >
204
189
  {#if trigger === 'click'}
205
- <!--
206
- Click-mode: forward clicks to toggle. Do NOT add role/tabindex here
207
- — consumers pass an interactive child (Button, <a>, etc.) that
208
- handles its own focus/keyboard; adding them here would nest
209
- interactive elements (invalid HTML + a11y issue). Keyboard support
210
- comes via Enter/Space on the inner button dispatching a click,
211
- which bubbles to this handler.
212
- -->
213
190
  <!-- svelte-ignore a11y_click_events_have_key_events -->
214
191
  <!-- svelte-ignore a11y_no_static_element_interactions -->
215
192
  <span class="inline-flex" aria-haspopup="dialog" aria-expanded={open} onclick={toggle}>
@@ -227,12 +204,12 @@
227
204
  {@render children()}
228
205
  </span>
229
206
  {:else}
230
- <!-- manual — consumer drives `open` -->
231
207
  <span class="inline-flex">{@render children()}</span>
232
208
  {/if}
233
209
  </span>
234
210
 
235
- {#if open}
211
+ <!-- Desktop: positioned panel -->
212
+ {#if open && !useSheet}
236
213
  <div
237
214
  bind:this={panelEl}
238
215
  role="dialog"
@@ -252,3 +229,29 @@
252
229
  {@render content({ close })}
253
230
  </div>
254
231
  {/if}
232
+
233
+ <!-- Mobile: bottom sheet -->
234
+ {#if open && useSheet}
235
+ <button
236
+ type="button"
237
+ class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
238
+ aria-label="Close"
239
+ onclick={close}
240
+ ></button>
241
+ <div
242
+ role="dialog"
243
+ tabindex="-1"
244
+ class="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[85vh] min-h-48 flex-col overflow-hidden rounded-t-2xl bg-white shadow-2xl"
245
+ transition:fly={{ y: 300, duration: 200, easing: quintOut }}
246
+ data-testid={buildTestId('popover', 'sheet', testId)}
247
+ >
248
+ <!-- Drag handle -->
249
+ <div class="flex cursor-pointer justify-center py-2">
250
+ <div class="bg-default-300 h-1 w-8 rounded-full"></div>
251
+ </div>
252
+ <!-- Content -->
253
+ <div class="flex-1 cursor-pointer overflow-y-auto p-2">
254
+ {@render content({ close })}
255
+ </div>
256
+ </div>
257
+ {/if}
@@ -314,7 +314,7 @@
314
314
  type="button"
315
315
  onclick={() => handleSelect(group, tab.value)}
316
316
  class={cn(
317
- 'rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap',
317
+ 'cursor-pointer rounded-full border px-3 py-1 text-xs font-medium whitespace-nowrap',
318
318
  active
319
319
  ? 'bg-primary-50 text-primary-700 border-primary-200'
320
320
  : 'border-default-200 text-default-700 hover:bg-default-50'