@r2digisolutions/components 0.19.7 → 0.19.8

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,8 +1,10 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
+ import { on } from 'svelte/events';
3
4
  import Avatar from '../../atoms/Avatar/Avatar.svelte';
4
5
  import Badge from '../../atoms/Badge/Badge.svelte';
5
6
  import StatusDot from '../../atoms/StatusDot/StatusDot.svelte';
7
+ import { createId } from '../../../utils/id.js';
6
8
 
7
9
  export interface UserMenuItem {
8
10
  id: string;
@@ -74,7 +76,29 @@
74
76
  onopenchange
75
77
  }: UserMenuProps = $props();
76
78
 
77
- let rootEl = $state<HTMLDivElement | null>(null);
79
+ let triggerEl = $state<HTMLButtonElement | null>(null);
80
+ let menuEl = $state<HTMLDivElement | null>(null);
81
+ let menuStyle = $state('');
82
+ let menuId = $state('');
83
+
84
+ $effect(() => {
85
+ if (!menuId) menuId = createId('user-menu');
86
+ });
87
+
88
+ /**
89
+ * Declarative invoker avoids the light-dismiss ↔ click race:
90
+ * without an associated source, pointerup closes and click reopens.
91
+ */
92
+ $effect(() => {
93
+ const btn = triggerEl;
94
+ const menu = menuEl;
95
+ if (!btn || !menu) return;
96
+ btn.popoverTargetElement = menu;
97
+ btn.popoverTargetAction = 'toggle';
98
+ return () => {
99
+ btn.popoverTargetElement = null;
100
+ };
101
+ });
78
102
 
79
103
  const avatarSize = $derived(size === 'sm' ? 'sm' : size === 'lg' ? 'lg' : 'md');
80
104
  const statusLabel = $derived(
@@ -94,39 +118,111 @@
94
118
  onopenchange?.(next);
95
119
  }
96
120
 
97
- function toggle() {
98
- setOpen(!open);
121
+ function positionMenu() {
122
+ if (!triggerEl || !menuEl) return;
123
+ if (!menuEl.matches(':popover-open')) return;
124
+
125
+ const rect = triggerEl.getBoundingClientRect();
126
+ const gap = 8;
127
+ const margin = 8;
128
+ const vv = window.visualViewport;
129
+ const viewW = vv?.width ?? window.innerWidth;
130
+ const viewH = vv?.height ?? window.innerHeight;
131
+ const viewLeft = vv?.offsetLeft ?? 0;
132
+ const viewTop = vv?.offsetTop ?? 0;
133
+
134
+ const width = Math.min(Math.max(rect.width, 280), viewW - margin * 2);
135
+ const height = menuEl.offsetHeight || 280;
136
+
137
+ const spaceBelow = viewTop + viewH - rect.bottom - gap - margin;
138
+ const spaceAbove = rect.top - viewTop - gap - margin;
139
+
140
+ let place: 'top' | 'bottom' = side;
141
+ if (side === 'bottom' && height > spaceBelow && spaceAbove > spaceBelow) place = 'top';
142
+ else if (side === 'top' && height > spaceAbove && spaceBelow > spaceAbove) place = 'bottom';
143
+
144
+ let top: number;
145
+ if (place === 'bottom') {
146
+ top = rect.bottom + gap;
147
+ if (top + height > viewTop + viewH - margin) {
148
+ top = Math.max(viewTop + margin, viewTop + viewH - margin - height);
149
+ }
150
+ } else {
151
+ top = rect.top - gap - height;
152
+ if (top < viewTop + margin) top = viewTop + margin;
153
+ }
154
+
155
+ let left = align === 'end' ? rect.right - width : rect.left;
156
+ left = Math.min(Math.max(left, viewLeft + margin), viewLeft + viewW - margin - width);
157
+
158
+ menuStyle = [
159
+ `top:${top}px`,
160
+ 'bottom:auto',
161
+ `left:${left}px`,
162
+ 'right:auto',
163
+ `width:${width}px`,
164
+ 'height:auto',
165
+ 'max-height:none'
166
+ ].join(';');
167
+ }
168
+
169
+ function closeMenu() {
170
+ if (menuEl?.matches(':popover-open')) menuEl.hidePopover();
99
171
  }
100
172
 
101
173
  function select(item: UserMenuItem) {
102
174
  if (item.disabled || item.separator) return;
103
175
  onselect?.(item.id, item);
104
- setOpen(false);
176
+ closeMenu();
105
177
  }
106
178
 
107
- function onDocPointer(e: PointerEvent) {
108
- if (!open || !rootEl) return;
109
- if (!rootEl.contains(e.target as Node)) setOpen(false);
179
+ function handleBeforeToggle(event: ToggleEvent) {
180
+ if (event.newState === 'open') {
181
+ queueMicrotask(() => {
182
+ positionMenu();
183
+ requestAnimationFrame(() => positionMenu());
184
+ });
185
+ }
110
186
  }
111
187
 
112
- function onKey(e: KeyboardEvent) {
113
- if (!open) return;
114
- if (e.key === 'Escape') setOpen(false);
188
+ function handleToggle(event: ToggleEvent) {
189
+ const next = event.newState === 'open';
190
+ setOpen(next);
191
+ if (next) {
192
+ queueMicrotask(() => {
193
+ positionMenu();
194
+ requestAnimationFrame(() => positionMenu());
195
+ });
196
+ }
115
197
  }
116
198
 
117
199
  $effect(() => {
118
- if (typeof window === 'undefined') return;
119
- document.addEventListener('pointerdown', onDocPointer);
120
- window.addEventListener('keydown', onKey);
200
+ if (!open) return;
201
+ let frame = 0;
202
+ const reposition = (event?: Event) => {
203
+ const target = event?.target;
204
+ if (target instanceof Node && menuEl?.contains(target)) return;
205
+ cancelAnimationFrame(frame);
206
+ frame = requestAnimationFrame(() => positionMenu());
207
+ };
208
+ const offScroll = on(window, 'scroll', reposition, { capture: true, passive: true });
209
+ const offResize = on(window, 'resize', reposition);
210
+ const offVisual =
211
+ typeof window.visualViewport !== 'undefined' && window.visualViewport
212
+ ? on(window.visualViewport, 'resize', reposition)
213
+ : () => {};
121
214
  return () => {
122
- document.removeEventListener('pointerdown', onDocPointer);
123
- window.removeEventListener('keydown', onKey);
215
+ cancelAnimationFrame(frame);
216
+ offScroll();
217
+ offResize();
218
+ offVisual();
124
219
  };
125
220
  });
126
221
  </script>
127
222
 
128
- <div bind:this={rootEl} class={['relative flex w-full min-w-0', className]}>
223
+ <div class={['relative flex w-full min-w-0', className]}>
129
224
  <button
225
+ bind:this={triggerEl}
130
226
  type="button"
131
227
  class={[
132
228
  'inline-flex w-full min-w-0 max-w-full items-center text-left transition-colors',
@@ -141,7 +237,7 @@
141
237
  ]}
142
238
  aria-haspopup="menu"
143
239
  aria-expanded={open}
144
- onclick={toggle}
240
+ aria-controls={menuId || undefined}
145
241
  >
146
242
  <span class="shrink-0">
147
243
  <Avatar {src} {name} size={avatarSize} {status} />
@@ -170,77 +266,90 @@
170
266
  {/if}
171
267
  </button>
172
268
 
173
- {#if open}
174
- <div
175
- class={[
176
- 'absolute z-50 w-[min(100vw-2rem,18rem)] overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl',
177
- side === 'top' ? 'bottom-full mb-2' : 'top-full mt-2',
178
- align === 'end' ? 'right-0' : 'left-0'
179
- ]}
180
- role="menu"
181
- aria-label="User menu"
182
- >
183
- {#if header}
184
- {@render header()}
185
- {:else}
186
- <div class="border-b border-border bg-surface-overlay/50 px-3.5 py-3">
187
- <div class="flex items-start gap-3">
188
- <Avatar {src} {name} size="lg" {status} />
189
- <div class="min-w-0 flex-1 pt-0.5">
190
- <p class="truncate text-sm font-semibold text-primary">{name}</p>
191
- {#if email}
192
- <p class="truncate text-xs text-muted">{email}</p>
269
+ <div
270
+ bind:this={menuEl}
271
+ id={menuId}
272
+ popover="auto"
273
+ role="menu"
274
+ tabindex={-1}
275
+ aria-label="User menu"
276
+ style={menuStyle}
277
+ ontoggle={handleToggle}
278
+ onbeforetoggle={handleBeforeToggle}
279
+ class="user-menu m-0 overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl inset-auto outline-none"
280
+ >
281
+ {#if header}
282
+ {@render header()}
283
+ {:else}
284
+ <div class="border-b border-border bg-surface-overlay/50 px-3.5 py-3">
285
+ <div class="flex items-start gap-3">
286
+ <Avatar {src} {name} size="lg" {status} />
287
+ <div class="min-w-0 flex-1 pt-0.5">
288
+ <p class="truncate text-sm font-semibold text-primary">{name}</p>
289
+ {#if email}
290
+ <p class="truncate text-xs text-muted">{email}</p>
291
+ {/if}
292
+ <div class="mt-2 flex flex-wrap items-center gap-2">
293
+ {#if status && statusLabel}
294
+ <StatusDot {status} size="sm" showLabel label={statusLabel} />
295
+ {/if}
296
+ {#if role}
297
+ <span
298
+ class="rounded-md bg-surface-elevated px-1.5 py-0.5 text-[10px] font-medium text-secondary ring-1 ring-border"
299
+ >
300
+ {role}
301
+ </span>
302
+ {/if}
303
+ {#if plan}
304
+ <Badge variant="primary" size="sm">{plan}</Badge>
193
305
  {/if}
194
- <div class="mt-2 flex flex-wrap items-center gap-2">
195
- {#if status && statusLabel}
196
- <StatusDot {status} size="sm" showLabel label={statusLabel} />
197
- {/if}
198
- {#if role}
199
- <span class="rounded-md bg-surface-elevated px-1.5 py-0.5 text-[10px] font-medium text-secondary ring-1 ring-border">
200
- {role}
201
- </span>
202
- {/if}
203
- {#if plan}
204
- <Badge variant="primary" size="sm">{plan}</Badge>
205
- {/if}
206
- </div>
207
306
  </div>
208
307
  </div>
209
308
  </div>
210
- {/if}
211
-
212
- <div class="p-1.5">
213
- {#each items as item (item.id)}
214
- {#if item.separator}
215
- <div class="my-1.5 border-t border-border" role="separator"></div>
216
- {:else}
217
- <button
218
- type="button"
219
- role="menuitem"
220
- disabled={item.disabled}
221
- onclick={() => select(item)}
222
- class={[
223
- 'flex w-full items-start gap-2 rounded-xl px-2.5 py-2 text-left transition-colors',
224
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
225
- item.destructive
226
- ? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
227
- : 'text-primary hover:bg-surface-overlay',
228
- item.disabled && 'cursor-not-allowed opacity-40'
229
- ]}
230
- >
231
- <span class="min-w-0 flex-1">
232
- <span class="block text-sm font-medium">{item.label}</span>
233
- {#if item.description}
234
- <span class="block text-xs text-muted">{item.description}</span>
235
- {/if}
236
- </span>
237
- {#if item.shortcut}
238
- <span class="mt-0.5 shrink-0 font-mono text-[10px] text-muted">{item.shortcut}</span>
239
- {/if}
240
- </button>
241
- {/if}
242
- {/each}
243
309
  </div>
310
+ {/if}
311
+
312
+ <div class="p-1.5">
313
+ {#each items as item (item.id)}
314
+ {#if item.separator}
315
+ <div class="my-1.5 border-t border-border" role="separator"></div>
316
+ {:else}
317
+ <button
318
+ type="button"
319
+ role="menuitem"
320
+ disabled={item.disabled}
321
+ onclick={() => select(item)}
322
+ class={[
323
+ 'flex w-full items-start gap-2 rounded-xl px-2.5 py-2 text-left transition-colors',
324
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
325
+ item.destructive
326
+ ? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
327
+ : 'text-primary hover:bg-surface-overlay',
328
+ item.disabled && 'cursor-not-allowed opacity-40'
329
+ ]}
330
+ >
331
+ <span class="min-w-0 flex-1">
332
+ <span class="block text-sm font-medium">{item.label}</span>
333
+ {#if item.description}
334
+ <span class="block text-xs text-muted">{item.description}</span>
335
+ {/if}
336
+ </span>
337
+ {#if item.shortcut}
338
+ <span class="mt-0.5 shrink-0 font-mono text-[10px] text-muted">{item.shortcut}</span>
339
+ {/if}
340
+ </button>
341
+ {/if}
342
+ {/each}
244
343
  </div>
245
- {/if}
344
+ </div>
246
345
  </div>
346
+
347
+ <style>
348
+ .user-menu {
349
+ position: fixed;
350
+ }
351
+
352
+ .user-menu:popover-open {
353
+ display: block;
354
+ }
355
+ </style>
@@ -175,7 +175,7 @@
175
175
  </nav>
176
176
 
177
177
  {#if footer}
178
- <div class={['border-border min-w-0 overflow-hidden border-t p-3', collapsed && 'flex justify-center']}>
178
+ <div class={['border-border min-w-0 border-t p-3', collapsed && 'flex justify-center']}>
179
179
  {@render footer()}
180
180
  </div>
181
181
  {/if}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.19.7",
3
+ "version": "0.19.8",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",