@makolabs/ripple 0.0.1-dev.42 → 0.0.1-dev.44

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.
@@ -5,6 +5,7 @@
5
5
  import { dropdownMenu } from '../../index.js';
6
6
  import { onMount, onDestroy } from 'svelte';
7
7
  import { Size } from '../../variants.js';
8
+ import Portal from '../../utils/Portal.svelte';
8
9
 
9
10
  let {
10
11
  sections = [],
@@ -26,7 +27,6 @@
26
27
 
27
28
  let dropdownRef = $state<HTMLDivElement | undefined>();
28
29
  let triggerRef = $state<HTMLDivElement | undefined>();
29
- let portalEl = $state<HTMLDivElement | undefined>();
30
30
  let triggerRect = $state<DOMRect | null>(null);
31
31
 
32
32
  const {
@@ -90,10 +90,10 @@
90
90
  posStyles += `left: ${right - triggerWidth}px;`;
91
91
  }
92
92
  } else {
93
- const centeredLeft = left + (triggerWidth / 2);
94
- if (centeredLeft + (dropdownWidthPx / 2) > viewportWidth - 20) {
93
+ const centeredLeft = left + triggerWidth / 2;
94
+ if (centeredLeft + dropdownWidthPx / 2 > viewportWidth - 20) {
95
95
  posStyles += `right: 20px; left: auto;`;
96
- } else if (centeredLeft - (dropdownWidthPx / 2) < 20) {
96
+ } else if (centeredLeft - dropdownWidthPx / 2 < 20) {
97
97
  posStyles += `left: 20px; right: auto;`;
98
98
  } else {
99
99
  posStyles += `left: ${centeredLeft}px; transform: translateX(-50%);`;
@@ -111,18 +111,16 @@
111
111
  function handleToggle() {
112
112
  if (disabled) return;
113
113
  isOpen = !isOpen;
114
-
115
- if (isOpen) {
116
- setTimeout(updatePosition, 0); // Use setTimeout to ensure DOM is updated
117
- }
118
114
  }
119
115
 
120
116
  function handleClickOutside(event: MouseEvent) {
121
- if (isOpen &&
117
+ if (
118
+ isOpen &&
122
119
  dropdownRef &&
123
120
  !dropdownRef.contains(event.target as Node) &&
124
121
  triggerRef &&
125
- !triggerRef.contains(event.target as Node)) {
122
+ !triggerRef.contains(event.target as Node)
123
+ ) {
126
124
  isOpen = false;
127
125
  }
128
126
  }
@@ -131,49 +129,6 @@
131
129
  if (item.onclick) item.onclick();
132
130
  isOpen = false;
133
131
  }
134
-
135
- function updatePosition() {
136
- if (triggerRef) {
137
- triggerRect = triggerRef.getBoundingClientRect();
138
- }
139
- }
140
-
141
- function renderPortalDropdown() {
142
- if (!portalEl || !document.body.contains(portalEl)) {
143
- portalEl = document.createElement('div');
144
- portalEl.id = 'dropdown-portal';
145
- portalEl.style.position = 'fixed';
146
- portalEl.style.top = '0';
147
- portalEl.style.left = '0';
148
- portalEl.style.width = '100%';
149
- portalEl.style.height = '100%';
150
- portalEl.style.pointerEvents = 'none';
151
- portalEl.style.zIndex = '9999';
152
- document.body.appendChild(portalEl);
153
- }
154
- }
155
-
156
- onMount(() => {
157
- renderPortalDropdown();
158
- window.addEventListener('scroll', updatePosition, true);
159
- window.addEventListener('resize', updatePosition);
160
- });
161
-
162
- onDestroy(() => {
163
- if (!triggerRef) return;
164
- window.removeEventListener('scroll', updatePosition, true);
165
- window.removeEventListener('resize', updatePosition);
166
- if (portalEl && document.body.contains(portalEl)) {
167
- document.body.removeChild(portalEl);
168
- }
169
- });
170
-
171
- $effect(() => {
172
- if (isOpen) {
173
- renderPortalDropdown();
174
- updatePosition();
175
- }
176
- });
177
132
  </script>
178
133
 
179
134
  <svelte:window onclick={handleClickOutside} />
@@ -194,68 +149,80 @@
194
149
  {/if}
195
150
 
196
151
  {#if Icon}
197
- <Icon class="size-5 text-default-400" />
152
+ <Icon class="text-default-400 size-5" />
198
153
  {:else if label}
199
- <svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28" class="size-5">
200
- <path fill="currentColor"
201
- d="M4.22 9.47a.75.75 0 0 1 1.06 0L14 18.19l8.72-8.72a.75.75 0 1 1 1.06 1.06l-9.25 9.25a.75.75 0 0 1-1.06 0l-9.25-9.25a.75.75 0 0 1 0-1.06" />
154
+ <svg
155
+ xmlns="http://www.w3.org/2000/svg"
156
+ width="28"
157
+ height="28"
158
+ viewBox="0 0 28 28"
159
+ class="size-5"
160
+ >
161
+ <path
162
+ fill="currentColor"
163
+ d="M4.22 9.47a.75.75 0 0 1 1.06 0L14 18.19l8.72-8.72a.75.75 0 1 1 1.06 1.06l-9.25 9.25a.75.75 0 0 1-1.06 0l-9.25-9.25a.75.75 0 0 1 0-1.06"
164
+ />
202
165
  </svg>
203
166
  {/if}
204
167
  </button>
205
168
  </div>
206
169
  </div>
207
170
 
208
- {#if isOpen && portalEl}
209
- <div
210
- bind:this={dropdownRef}
211
- class={containerClass_}
212
- role="menu"
213
- aria-orientation="vertical"
214
- aria-labelledby="menu-button"
215
- style={dropdownStyles}
216
- transition:fly={{ duration: 150, y: 5, opacity: 0 }}
217
- >
218
- {#if header}
219
- <button class={headerClass_} onclick={header.onclick} aria-label="Header Actions">
220
- {#if header.content}
221
- {@render header.content()}
222
- {:else}
223
- {#if header.title}
224
- <span class={headerTitleClass}>{header.title}</span>
225
- {/if}
226
- {#if header.subtitle}
227
- <span class={headerSubtitleClass}>{header.subtitle}</span>
228
- {/if}
229
- {/if}
230
- </button>
231
- {/if}
232
-
233
- {#each sections as section_, sectionIndex (sectionIndex)}
234
- <div class={sectionClass}>
235
- {#each section_.items as menuItem, itemIndex (itemIndex)}
236
- {@const itemProps = {
237
- class: itemClass_,
238
- role: 'menuitem',
239
- tabindex: -1,
240
- id: `menu-item-${sectionIndex}-${itemIndex}`,
241
- 'data-active': menuItem.active
242
- }}
243
- {#if menuItem.href}
244
- <a href={menuItem.href} {...itemProps}>
245
- {@render DropItemContent(menuItem)}
246
- </a>
171
+ {#if isOpen}
172
+ <Portal target={triggerRef}>
173
+ <div
174
+ bind:this={dropdownRef}
175
+ class={containerClass_}
176
+ role="menu"
177
+ aria-orientation="vertical"
178
+ aria-labelledby="menu-button"
179
+ style={dropdownStyles}
180
+ transition:fly={{ duration: 150, y: 5, opacity: 0 }}
181
+ >
182
+ {#if header}
183
+ <button class={headerClass_} onclick={header.onclick} aria-label="Header Actions">
184
+ {#if header.content}
185
+ {@render header.content()}
247
186
  {:else}
248
- <button
249
- type="button"
250
- onclick={() => handleItemClick(menuItem)}
251
- disabled={!menuItem.onclick} {...itemProps}>
252
- {@render DropItemContent(menuItem)}
253
- </button>
187
+ {#if header.title}
188
+ <span class={headerTitleClass}>{header.title}</span>
189
+ {/if}
190
+ {#if header.subtitle}
191
+ <span class={headerSubtitleClass}>{header.subtitle}</span>
192
+ {/if}
254
193
  {/if}
255
- {/each}
256
- </div>
257
- {/each}
258
- </div>
194
+ </button>
195
+ {/if}
196
+
197
+ {#each sections as section_, sectionIndex (sectionIndex)}
198
+ <div class={sectionClass}>
199
+ {#each section_.items as menuItem, itemIndex (itemIndex)}
200
+ {@const itemProps = {
201
+ class: itemClass_,
202
+ role: 'menuitem',
203
+ tabindex: -1,
204
+ id: `menu-item-${sectionIndex}-${itemIndex}`,
205
+ 'data-active': menuItem.active
206
+ }}
207
+ {#if menuItem.href}
208
+ <a href={menuItem.href} {...itemProps}>
209
+ {@render DropItemContent(menuItem)}
210
+ </a>
211
+ {:else}
212
+ <button
213
+ type="button"
214
+ onclick={() => handleItemClick(menuItem)}
215
+ disabled={!menuItem.onclick}
216
+ {...itemProps}
217
+ >
218
+ {@render DropItemContent(menuItem)}
219
+ </button>
220
+ {/if}
221
+ {/each}
222
+ </div>
223
+ {/each}
224
+ </div>
225
+ </Portal>
259
226
  {/if}
260
227
 
261
228
  {#snippet DropItemContent(menuItem: DropdownItem)}
@@ -264,4 +231,4 @@
264
231
  <ItemIcon class={iconClass} />
265
232
  {/if}
266
233
  <span class="truncate">{menuItem.label}</span>
267
- {/snippet}
234
+ {/snippet}
@@ -5,6 +5,7 @@
5
5
  import type { SelectItem, SelectProps } from '../../index.js';
6
6
  import Badge from '../badge/Badge.svelte';
7
7
  import { Size } from '../../variants.js';
8
+ import Portal from '../../utils/Portal.svelte';
8
9
 
9
10
  let {
10
11
  items = [],
@@ -29,7 +30,7 @@
29
30
 
30
31
  let open = $state(false);
31
32
  let searchQuery = $state('');
32
- let selectRef = $state<HTMLDivElement | null>(null);
33
+ let labelRef = $state<HTMLLabelElement | null>(null);
33
34
  let searchInputRef = $state<HTMLInputElement | null>(null);
34
35
  let highlightedIndex = $state(-1);
35
36
 
@@ -50,7 +51,7 @@
50
51
  );
51
52
 
52
53
  const baseClass = $derived(cn(base(), className));
53
- const triggerClass_ = $derived(cn(trigger(), triggerClass));
54
+ const triggerClass_ = $derived(cn(trigger(), triggerClass, baseClass));
54
55
  const triggerIconClass = $derived(cn(triggerIcon(), iconClass));
55
56
  const containerClass_ = $derived(cn(container(), containerClass));
56
57
  const searchInputClass_ = $derived(cn(searchInput(), searchInputClass));
@@ -122,15 +123,26 @@
122
123
  }
123
124
 
124
125
  function handleClickOutside(event: MouseEvent) {
125
- if (selectRef && !selectRef.contains(event.target as Node) && open) {
126
- open = false;
127
- onclose();
126
+ // Check if the click is inside the portal content
127
+ const portalContent = document.querySelector('.ripple-portal .portal-content');
128
+
129
+ // If the click is inside either the label (trigger) or the portal content, don't close
130
+ if (
131
+ (labelRef && labelRef.contains(event.target as Node)) ||
132
+ (portalContent && portalContent.contains(event.target as Node)) ||
133
+ !open
134
+ ) {
135
+ return;
128
136
  }
137
+
138
+ // Otherwise close the dropdown
139
+ open = false;
140
+ onclose();
129
141
  }
130
142
 
131
143
  function handleKeydown(event: KeyboardEvent) {
132
144
  // check if the event is fired from the select
133
- if (!selectRef || !selectRef.contains(event.target as Node)) return;
145
+ if (!labelRef || !labelRef.contains(event.target as Node)) return;
134
146
 
135
147
  if (!open) {
136
148
  if (event.key === 'Enter' || event.key === ' ' || event.key === 'ArrowDown') {
@@ -182,59 +194,61 @@
182
194
 
183
195
  <svelte:window onclick={handleClickOutside} onkeydown={handleKeydown} />
184
196
 
185
- <div bind:this={selectRef} class={baseClass} data-state={open ? 'open' : 'closed'}>
186
- <label
187
- class={triggerClass_}
188
- aria-disabled={disabled}
189
- aria-haspopup="listbox"
190
- aria-labelledby="select-label"
191
- >
192
- <button
193
- type="button"
194
- aria-label="Toggle dropdown"
195
- {disabled}
196
- aria-expanded={open}
197
- onclick={handleToggle}
198
- ></button>
199
- <span class="flex min-h-[1.5rem] flex-1 flex-wrap items-center gap-1 overflow-hidden">
200
- {#if multiple && selectedItems.length > 0}
201
- {#each selectedItems as item (item.value)}
202
- <Badge {size} color="info" onclose={() => removeItem(item.value)}>
203
- {item.value}
204
- </Badge>
205
- {/each}
206
- {:else if !multiple && selectedItem}
207
- <span id="select-label" class="flex-1 truncate text-left">
208
- {selectedItem.label}
209
- </span>
210
- {:else}
211
- <span id="select-label" class="text-default-500 px-1">
212
- {placeholder}
213
- </span>
214
- {/if}
215
- </span>
216
-
217
- <span class="ml-auto flex flex-shrink-0 items-center pl-2">
218
- {#if Icon}
219
- <Icon class={triggerIconClass} />
220
- {:else}
221
- <svg
222
- xmlns="http://www.w3.org/2000/svg"
223
- viewBox="0 0 20 20"
224
- fill="currentColor"
225
- class={cn(triggerIconClass, open && 'rotate-180 transform')}
226
- >
227
- <path
228
- fill-rule="evenodd"
229
- d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
230
- clip-rule="evenodd"
231
- />
232
- </svg>
233
- {/if}
234
- </span>
235
- </label>
236
-
237
- {#if open}
197
+ <label
198
+ bind:this={labelRef}
199
+ class={triggerClass_}
200
+ aria-disabled={disabled}
201
+ aria-haspopup="listbox"
202
+ aria-labelledby="select-label"
203
+ data-state={open ? 'open' : 'closed'}
204
+ >
205
+ <button
206
+ type="button"
207
+ aria-label="Toggle dropdown"
208
+ {disabled}
209
+ aria-expanded={open}
210
+ onclick={handleToggle}
211
+ ></button>
212
+ <span class="flex min-h-[1.5rem] flex-1 flex-wrap items-center gap-1 overflow-hidden">
213
+ {#if multiple && selectedItems.length > 0}
214
+ {#each selectedItems as item (item.value)}
215
+ <Badge {size} color="info" onclose={() => removeItem(item.value)}>
216
+ {item.value}
217
+ </Badge>
218
+ {/each}
219
+ {:else if !multiple && selectedItem}
220
+ <span id="select-label" class="flex-1 truncate text-left">
221
+ {selectedItem.label}
222
+ </span>
223
+ {:else}
224
+ <span id="select-label" class="text-default-500 px-1">
225
+ {placeholder}
226
+ </span>
227
+ {/if}
228
+ </span>
229
+
230
+ <span class="ml-auto flex flex-shrink-0 items-center pl-2">
231
+ {#if Icon}
232
+ <Icon class={triggerIconClass} />
233
+ {:else}
234
+ <svg
235
+ xmlns="http://www.w3.org/2000/svg"
236
+ viewBox="0 0 20 20"
237
+ fill="currentColor"
238
+ class={cn(triggerIconClass, open && 'rotate-180 transform')}
239
+ >
240
+ <path
241
+ fill-rule="evenodd"
242
+ d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
243
+ clip-rule="evenodd"
244
+ />
245
+ </svg>
246
+ {/if}
247
+ </span>
248
+ </label>
249
+
250
+ {#if open}
251
+ <Portal target={labelRef}>
238
252
  <div class={containerClass_} role="listbox" aria-labelledby="select-label">
239
253
  {#if searchable}
240
254
  <div class={searchInputClass_}>
@@ -269,7 +283,10 @@
269
283
  <li>
270
284
  <button
271
285
  type="button"
272
- onclick={() => handleSelect(item)}
286
+ onclick={(event) => {
287
+ handleSelect(item);
288
+ event.preventDefault();
289
+ }}
273
290
  disabled={item.disabled}
274
291
  class={itemClass_}
275
292
  role="option"
@@ -310,5 +327,5 @@
310
327
  </ul>
311
328
  {/if}
312
329
  </div>
313
- {/if}
314
- </div>
330
+ </Portal>
331
+ {/if}
@@ -2,7 +2,7 @@ import { tv } from '../../helper/cls.js';
2
2
  import { Size } from '../../variants.js';
3
3
  export const dropdownMenu = tv({
4
4
  slots: {
5
- base: 'relative inline-block text-left',
5
+ base: 'inline-block text-left',
6
6
  trigger: 'inline-flex w-full justify-center items-center gap-x-1.5 rounded-md bg-white px-3 py-2 text-sm font-semibold text-default-900 shadow-xs ring-1 ring-inset ring-default-300 hover:bg-default-50 cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed',
7
7
  container: 'absolute z-50 mt-2 origin-top-right divide-y divide-default-100 rounded-md bg-white ring-1 ring-black/5 shadow-lg focus:outline-none',
8
8
  section: 'py-1',
@@ -2,11 +2,11 @@ import { tv } from 'tailwind-variants';
2
2
  import { Size } from '../../variants.js';
3
3
  export const selectTV = tv({
4
4
  slots: {
5
- base: 'relative inline-block',
5
+ base: '',
6
6
  trigger: `flex items-center justify-between w-full text-left bg-white border
7
7
  border-default-200 text-default-700 hover:border-default-300 rounded-md cursor-pointer`,
8
8
  triggerIcon: 'transition-transform duration-200 text-default-500',
9
- container: 'absolute z-50 w-full mt-1 bg-white overflow-clip border border-default-200 rounded-md shadow-md',
9
+ container: 'absolute z-50 w-full mt-1 bg-white overflow-clip border border-default-200 rounded-md shadow-md origin-top-left top-full left-0 mt-2',
10
10
  searchInput: 'flex items-center gap-x-3 w-full outline-none px-2 h-10 border-b border-b-default-200',
11
11
  list: 'py-1 max-h-60 overflow-x-clip overflow-y-auto h-full',
12
12
  item: `w-full px-3 py-2 text-sm text-left
@@ -0,0 +1,108 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy, type Snippet } from 'svelte';
3
+
4
+ /**
5
+ * Target element to mount the portal content to
6
+ * Defaults to document.body
7
+ */
8
+ let { target, children }: { target?: HTMLElement | null; children: Snippet } = $props();
9
+
10
+ let ref: HTMLElement;
11
+ let portal: HTMLElement;
12
+ let animationFrameId: number;
13
+ let isPositioned = $state(false);
14
+
15
+ // Position update without animation - for immediate positioning
16
+ function updatePosition() {
17
+ if (!ref || !target) return;
18
+
19
+ const { top, left, width, height } = target.getBoundingClientRect();
20
+ const scrollY = window.scrollY || document.documentElement.scrollTop;
21
+ const scrollX = window.scrollX || document.documentElement.scrollLeft;
22
+
23
+ // Set instant positioning without transitions for first render
24
+ if (!isPositioned) {
25
+ ref.style.position = 'absolute';
26
+ ref.style.width = `${width}px`;
27
+ ref.style.zIndex = '10000'; // Ensure the highest z-index
28
+ ref.style.top = `${height}px`; // Position below the target
29
+ ref.style.left = '0px';
30
+ ref.style.transform = `translate(${left + scrollX}px, ${top + scrollY}px)`;
31
+ ref.style.visibility = 'hidden'; // Keep hidden until fully positioned
32
+
33
+ // Wait for next frame to ensure positioning is applied before showing
34
+ animationFrameId = requestAnimationFrame(() => {
35
+ ref.style.opacity = '1';
36
+ ref.style.visibility = 'visible';
37
+ isPositioned = true;
38
+
39
+ // Now add transition for subsequent updates
40
+ ref.style.transition = 'transform 0.1s ease-out';
41
+ });
42
+ } else {
43
+ // For subsequent updates, smoothly transition
44
+ ref.style.transform = `translate(${left + scrollX}px, ${top + scrollY}px)`;
45
+ }
46
+ }
47
+
48
+ // Handle scroll and resize with animation frames for smooth updates
49
+ function handlePositionUpdate() {
50
+ if (animationFrameId) {
51
+ cancelAnimationFrame(animationFrameId);
52
+ }
53
+
54
+ animationFrameId = requestAnimationFrame(updatePosition);
55
+ }
56
+
57
+ onMount(() => {
58
+ // Create portal container
59
+ portal = document.createElement('div');
60
+ portal.className = 'ripple-portal';
61
+ portal.style.position = 'fixed';
62
+ portal.style.zIndex = '10000'; // Ensure highest z-index
63
+ portal.style.top = '0';
64
+ portal.style.left = '0';
65
+ portal.style.width = '100%';
66
+ portal.style.pointerEvents = 'none'; // Allow clicking through the container but not its children
67
+
68
+ // Default to document.body if no target is provided
69
+ const targetElement = document.body; // Always append to body for best visibility
70
+ targetElement.appendChild(portal);
71
+
72
+ // Move the content to the portal
73
+ portal.appendChild(ref);
74
+
75
+ // Allow pointer events on the content
76
+ ref.style.pointerEvents = 'auto';
77
+
78
+ // Initially hide the content
79
+ ref.style.opacity = '0';
80
+
81
+ // Position immediately - critical for first render
82
+ updatePosition();
83
+
84
+ // Add event listeners for position updates
85
+ window.addEventListener('resize', handlePositionUpdate);
86
+ window.addEventListener('scroll', handlePositionUpdate, true);
87
+ });
88
+
89
+ onDestroy(() => {
90
+ // Clean up on component destruction
91
+ if (portal && portal.parentNode) {
92
+ portal.parentNode.removeChild(portal);
93
+ }
94
+
95
+ // Clean up event listeners
96
+ window.removeEventListener('resize', handlePositionUpdate);
97
+ window.removeEventListener('scroll', handlePositionUpdate, true);
98
+
99
+ // Cancel any pending animation frame
100
+ if (animationFrameId) {
101
+ cancelAnimationFrame(animationFrameId);
102
+ }
103
+ });
104
+ </script>
105
+
106
+ <div class="portal-content" bind:this={ref}>
107
+ {@render children()}
108
+ </div>
@@ -0,0 +1,8 @@
1
+ import { type Snippet } from 'svelte';
2
+ type $$ComponentProps = {
3
+ target?: HTMLElement | null;
4
+ children: Snippet;
5
+ };
6
+ declare const Portal: import("svelte").Component<$$ComponentProps, {}, "">;
7
+ type Portal = ReturnType<typeof Portal>;
8
+ export default Portal;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@makolabs/ripple",
3
- "version": "0.0.1-dev.42",
3
+ "version": "0.0.1-dev.44",
4
4
  "description": "Simple Svelte 5 powered component library ✨",
5
5
  "repository": {
6
6
  "type": "git",