@makolabs/ripple 3.0.1 → 3.0.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.
@@ -143,38 +143,6 @@
143
143
  return rgbToHex(r * (1 - amount), g * (1 - amount), b * (1 - amount));
144
144
  }
145
145
 
146
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
147
- function barGradient(baseHex: string, stacked: boolean): any {
148
- if (stacked) {
149
- // Horizontal sheen — same light direction for all segments
150
- return {
151
- type: 'linear',
152
- x: 0,
153
- y: 0,
154
- x2: 1,
155
- y2: 0,
156
- colorStops: [
157
- { offset: 0, color: baseHex },
158
- { offset: 0.5, color: lighten(baseHex, 0.1) },
159
- { offset: 1, color: baseHex }
160
- ]
161
- };
162
- }
163
- // Standalone bars — top-to-bottom depth
164
- return {
165
- type: 'linear',
166
- x: 0,
167
- y: 0,
168
- x2: 0,
169
- y2: 1,
170
- colorStops: [
171
- { offset: 0, color: lighten(baseHex, 0.2) },
172
- { offset: 0.6, color: baseHex },
173
- { offset: 1, color: darken(baseHex, 0.15) }
174
- ]
175
- };
176
- }
177
-
178
146
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
179
147
  function lineGradient(baseHex: string): any {
180
148
  return {
@@ -435,9 +403,10 @@
435
403
  color: getColor(seriesConfig.color),
436
404
  emphasis: seriesConfig.emphasis || {
437
405
  focus: 'series',
438
- blurScope: 'global'
406
+ blurScope: 'global',
407
+ itemStyle: { borderRadius: 0 }
439
408
  },
440
- blur: { itemStyle: { opacity: 0.25 }, lineStyle: { opacity: 0.25 } },
409
+ blur: { itemStyle: { opacity: 0.25, borderRadius: 0 }, lineStyle: { opacity: 0.25 } },
441
410
  animation: true,
442
411
  animationDuration: AnimationDuration,
443
412
  ...(axisMarkLine && { markLine: axisMarkLine }),
@@ -475,8 +444,11 @@
475
444
  barGap: '10%',
476
445
  color: getColor(seriesConfig.color),
477
446
  itemStyle: {
478
- color: barGradient(getColor(seriesConfig.color) ?? '#6366f1', !!seriesConfig.stack),
479
- opacity: seriesConfig.opacity ?? 1
447
+ color: getColor(seriesConfig.color) ?? '#6366f1',
448
+ opacity: seriesConfig.opacity ?? 1,
449
+ borderColor: darken(getColor(seriesConfig.color) ?? '#6366f1', 0.15),
450
+ borderWidth: 1,
451
+ borderRadius: [0, 0, 0, 0]
480
452
  }
481
453
  }),
482
454
 
@@ -37,8 +37,7 @@
37
37
  header: headerVClass,
38
38
  body,
39
39
  footer: footerVClass,
40
- title: titleVClass,
41
- closeButton
40
+ title: titleVClass
42
41
  } = $derived(
43
42
  drawer({
44
43
  open,
@@ -54,7 +53,6 @@
54
53
  const headerClasses = $derived(cn(headerVClass(), headerClass));
55
54
  const bodyClasses = $derived(cn(body(), bodyClass));
56
55
  const titleClasses = $derived(cn(titleVClass(), titleClass));
57
- const closeButtonClasses = $derived(cn(closeButton(), ''));
58
56
  const footerClasses = $derived(cn(footerVClass(), 'mt-auto', footerClass));
59
57
 
60
58
  function handleBackdropClick(e: MouseEvent) {
@@ -181,41 +179,36 @@
181
179
  data-testid={buildTestId('drawer', 'dialog', testId)}
182
180
  >
183
181
  <div class={contentClasses}>
182
+ <!-- Always-visible close button -->
183
+ <button
184
+ type="button"
185
+ class="bg-default-100 text-default-600 hover:bg-default-200 hover:text-default-900 absolute top-2 right-2 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full transition-colors"
186
+ onclick={handleCloseClick}
187
+ aria-label="Close drawer"
188
+ data-testid={buildTestId('drawer', 'close', testId)}
189
+ >
190
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 12 12">
191
+ <path
192
+ fill="currentColor"
193
+ d="m1.897 2.054l.073-.084a.75.75 0 0 1 .976-.073l.084.073L6 4.939l2.97-2.97a.75.75 0 1 1 1.06 1.061L7.061 6l2.97 2.97a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L6 7.061l-2.97 2.97A.75.75 0 1 1 1.97 8.97L4.939 6l-2.97-2.97a.75.75 0 0 1-.072-.976l.073-.084z"
194
+ />
195
+ </svg>
196
+ </button>
197
+
184
198
  <!-- Header -->
185
- {#if header || title}
199
+ {#if title}
200
+ <div class={headerClasses}>
201
+ <h3
202
+ id="drawer-title"
203
+ class={titleClasses}
204
+ data-testid={buildTestId('drawer', 'title', testId)}
205
+ >
206
+ {title}
207
+ </h3>
208
+ </div>
209
+ {:else if header}
186
210
  <div class={headerClasses}>
187
- {#if header}
188
- {@render header()}
189
- {:else}
190
- {#if title}
191
- <h3
192
- id="drawer-title"
193
- class={titleClasses}
194
- data-testid={buildTestId('drawer', 'title', testId)}
195
- >
196
- {title}
197
- </h3>
198
- {/if}
199
- <button
200
- type="button"
201
- class={closeButtonClasses}
202
- onclick={handleCloseClick}
203
- aria-label="Close drawer"
204
- data-testid={buildTestId('drawer', 'close', testId)}
205
- >
206
- <svg
207
- xmlns="http://www.w3.org/2000/svg"
208
- width="12"
209
- height="12"
210
- viewBox="0 0 12 12"
211
- >
212
- <path
213
- fill="currentColor"
214
- d="m1.897 2.054l.073-.084a.75.75 0 0 1 .976-.073l.084.073L6 4.939l2.97-2.97a.75.75 0 1 1 1.06 1.061L7.061 6l2.97 2.97a.75.75 0 0 1 .072.976l-.073.084a.75.75 0 0 1-.976.073l-.084-.073L6 7.061l-2.97 2.97A.75.75 0 1 1 1.97 8.97L4.939 6l-2.97-2.97a.75.75 0 0 1-.072-.976l.073-.084z"
215
- />
216
- </svg>
217
- </button>
218
- {/if}
211
+ {@render header()}
219
212
  </div>
220
213
  {/if}
221
214
 
@@ -4,8 +4,8 @@ export const drawer = tv({
4
4
  slots: {
5
5
  base: 'fixed inset-0 z-50 flex overflow-hidden',
6
6
  backdrop: 'fixed inset-0 transition-opacity bg-black/50',
7
- contentWrapper: 'absolute flex flex-col transform transition-transform',
8
- content: 'flex flex-col h-full w-full overflow-y-auto bg-white',
7
+ contentWrapper: 'absolute flex flex-col transform transition-transform max-w-[100vw]',
8
+ content: 'relative flex flex-col h-full w-full overflow-y-auto bg-white',
9
9
  header: 'flex items-center justify-between px-4 py-3 border-b border-default-200',
10
10
  body: 'flex-1 overflow-y-auto p-4',
11
11
  footer: 'flex justify-end border-t border-default-200 p-4',
@@ -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,38 @@
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;
100
+ // Fire onsearch immediately (documented "every keystroke" contract).
101
+ // Only debounce local filtering so rapid typing doesn't churn the
102
+ // DOM with intermediate filter results.
93
103
  onsearch?.(v);
104
+ clearTimeout(debounceTimer);
105
+ debounceTimer = setTimeout(() => {
106
+ debouncedQuery = v;
107
+ highlightedIndex = 0;
108
+ }, 300);
94
109
  }
95
110
 
111
+ $effect(() => {
112
+ return () => clearTimeout(debounceTimer);
113
+ });
114
+
96
115
  function handleKey(e: KeyboardEvent) {
97
116
  if (disabled) return;
98
117
  if (e.key === 'ArrowDown') {
@@ -169,7 +188,7 @@
169
188
  type="button"
170
189
  onclick={clear}
171
190
  aria-label="Clear selection"
172
- class="text-default-400 hover:text-default-700 flex size-5 items-center justify-center rounded"
191
+ class="text-default-400 hover:text-default-700 flex size-5 cursor-pointer items-center justify-center rounded"
173
192
  >
174
193
  <svg class="size-3" viewBox="0 0 12 12" fill="none" aria-hidden="true">
175
194
  <path
@@ -181,18 +200,32 @@
181
200
  </svg>
182
201
  </button>
183
202
  {/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"
203
+ <button
204
+ type="button"
205
+ onclick={(e) => {
206
+ e.stopPropagation();
207
+ if (!disabled) {
208
+ if (open) closeMenu();
209
+ else openMenu();
210
+ }
211
+ }}
212
+ class="text-default-400 hover:text-default-600 flex cursor-pointer items-center justify-center rounded"
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(''),
@@ -206,19 +216,17 @@
206
216
  }
207
217
 
208
218
  function handleClickOutside(event: MouseEvent) {
209
- // Check if the click is inside the portal content
219
+ if (!open) return;
220
+ // On mobile the sheet handles its own close via backdrop.
221
+ if (isMobile) return;
210
222
  const portalContent = document.querySelector('.ripple-portal .portal-content');
211
-
212
- // If the click is inside either the label (trigger) or the portal content, don't close
223
+ const target = event.target as Node;
213
224
  if (
214
- (labelRef && labelRef.contains(event.target as Node)) ||
215
- (portalContent && portalContent.contains(event.target as Node)) ||
216
- !open
225
+ (labelRef && labelRef.contains(target)) ||
226
+ (portalContent && portalContent.contains(target))
217
227
  ) {
218
228
  return;
219
229
  }
220
-
221
- // Otherwise close the dropdown
222
230
  open = false;
223
231
  onclose();
224
232
  }
@@ -402,7 +410,7 @@
402
410
  {/each}
403
411
  {/if}
404
412
 
405
- {#if open}
413
+ {#if open && !isMobile}
406
414
  <Portal target={labelRef}>
407
415
  <div
408
416
  class={containerClass_}
@@ -411,66 +419,92 @@
411
419
  data-testid={buildTestId('select', 'list', testId)}
412
420
  transition:fly={{ y: -8, duration: 300, easing: quintOut }}
413
421
  >
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}
422
+ {@render selectListContent()}
474
423
  </div>
475
424
  </Portal>
476
425
  {/if}
426
+
427
+ {#if open && isMobile}
428
+ <button
429
+ type="button"
430
+ class="fixed inset-0 z-[9998] bg-black/40 backdrop-blur-sm"
431
+ aria-label="Close"
432
+ onclick={() => (open = false)}
433
+ ></button>
434
+ <div
435
+ 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"
436
+ role="listbox"
437
+ aria-labelledby="{selectId}-label"
438
+ transition:fly={{ y: 300, duration: 200, easing: quintOut }}
439
+ >
440
+ <div class="flex justify-center py-2">
441
+ <div class="bg-default-300 h-1 w-8 rounded-full"></div>
442
+ </div>
443
+ <div class="flex-1 cursor-pointer overflow-y-auto">
444
+ {@render selectListContent()}
445
+ </div>
446
+ </div>
447
+ {/if}
448
+
449
+ {#snippet selectListContent()}
450
+ {#if isSearchable}
451
+ <div class={searchInputClass_}>
452
+ <svg
453
+ xmlns="http://www.w3.org/2000/svg"
454
+ width="12"
455
+ height="12"
456
+ viewBox="0 0 12 12"
457
+ class="size-4"
458
+ >
459
+ <path
460
+ fill="currentColor"
461
+ 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"
462
+ />
463
+ </svg>
464
+ <input
465
+ bind:this={searchInputRef}
466
+ bind:value={searchQuery}
467
+ type="text"
468
+ class="w-full ring-0 outline-0"
469
+ placeholder="Search..."
470
+ aria-label="Search select options"
471
+ oninput={() => (hasSearched = true)}
472
+ data-testid={buildTestId('select', 'search', testId)}
473
+ />
474
+ </div>
475
+ {/if}
476
+
477
+ {#if asyncLoading}
478
+ <div class={emptyMessageClass} data-select-loading="">{loadingText}</div>
479
+ {:else if filteredItems.length === 0}
480
+ <div class={emptyMessageClass}>{emptyText}</div>
481
+ {:else if groupedItems}
482
+ <ul class={listClass_}>
483
+ {#each groupedItems as group, gIdx (group.label ?? `__null__${gIdx}`)}
484
+ {#if group.label !== null}
485
+ <li
486
+ class="text-default-500 bg-default-50 px-3 py-1.5 text-xs font-semibold tracking-wide uppercase"
487
+ role="presentation"
488
+ data-select-group={group.label}
489
+ >
490
+ {group.label}
491
+ </li>
492
+ {/if}
493
+ {#each group.items as groupItem (groupItem.value)}
494
+ {@const flatIdx = filteredItems.indexOf(groupItem)}
495
+ {@const selected = valueArray.includes(groupItem.value)}
496
+ {@const highlighted = flatIdx === highlightedIndex}
497
+ {@render itemButton(groupItem, flatIdx, selected, highlighted)}
498
+ {/each}
499
+ {/each}
500
+ </ul>
501
+ {:else}
502
+ <ul class={listClass_}>
503
+ {#each filteredItems as flatItem, index (flatItem.value)}
504
+ {@const selected = valueArray.includes(flatItem.value)}
505
+ {@const highlighted = index === highlightedIndex}
506
+ {@render itemButton(flatItem, index, selected, highlighted)}
507
+ {/each}
508
+ </ul>
509
+ {/if}
510
+ {/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={closeOnOutsideClick ? close : undefined}
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'