@r2digisolutions/components 0.19.6 → 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,42 +118,114 @@
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 inline-flex', 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
- 'inline-flex items-center text-left transition-colors',
228
+ 'inline-flex w-full min-w-0 max-w-full items-center text-left transition-colors',
133
229
  'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/40',
134
230
  variant === 'outline' &&
135
231
  'gap-2.5 rounded-xl border border-border bg-surface-elevated hover:bg-surface-overlay',
@@ -141,14 +237,16 @@
141
237
  ]}
142
238
  aria-haspopup="menu"
143
239
  aria-expanded={open}
144
- onclick={toggle}
240
+ aria-controls={menuId || undefined}
145
241
  >
146
- <Avatar {src} {name} size={avatarSize} {status} />
242
+ <span class="shrink-0">
243
+ <Avatar {src} {name} size={avatarSize} {status} />
244
+ </span>
147
245
  {#if showMeta}
148
- <span class="hidden min-w-0 sm:block">
149
- <span class="block max-w-[9rem] truncate text-sm font-medium text-primary">{name}</span>
246
+ <span class="hidden min-w-0 flex-1 overflow-hidden sm:block">
247
+ <span class="block truncate text-sm font-medium text-primary">{name}</span>
150
248
  {#if showSubtitle && (email || role)}
151
- <span class="block max-w-[11rem] truncate text-xs text-muted">
249
+ <span class="block truncate text-xs text-muted">
152
250
  {role ? `${role}` : ''}{role && email ? ' · ' : ''}{email ?? ''}
153
251
  </span>
154
252
  {/if}
@@ -168,77 +266,90 @@
168
266
  {/if}
169
267
  </button>
170
268
 
171
- {#if open}
172
- <div
173
- class={[
174
- 'absolute z-50 w-[min(100vw-2rem,18rem)] overflow-hidden rounded-2xl border border-border bg-surface-elevated shadow-xl',
175
- side === 'top' ? 'bottom-full mb-2' : 'top-full mt-2',
176
- align === 'end' ? 'right-0' : 'left-0'
177
- ]}
178
- role="menu"
179
- aria-label="User menu"
180
- >
181
- {#if header}
182
- {@render header()}
183
- {:else}
184
- <div class="border-b border-border bg-surface-overlay/50 px-3.5 py-3">
185
- <div class="flex items-start gap-3">
186
- <Avatar {src} {name} size="lg" {status} />
187
- <div class="min-w-0 flex-1 pt-0.5">
188
- <p class="truncate text-sm font-semibold text-primary">{name}</p>
189
- {#if email}
190
- <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>
191
305
  {/if}
192
- <div class="mt-2 flex flex-wrap items-center gap-2">
193
- {#if status && statusLabel}
194
- <StatusDot {status} size="sm" showLabel label={statusLabel} />
195
- {/if}
196
- {#if role}
197
- <span class="rounded-md bg-surface-elevated px-1.5 py-0.5 text-[10px] font-medium text-secondary ring-1 ring-border">
198
- {role}
199
- </span>
200
- {/if}
201
- {#if plan}
202
- <Badge variant="primary" size="sm">{plan}</Badge>
203
- {/if}
204
- </div>
205
306
  </div>
206
307
  </div>
207
308
  </div>
208
- {/if}
209
-
210
- <div class="p-1.5">
211
- {#each items as item (item.id)}
212
- {#if item.separator}
213
- <div class="my-1.5 border-t border-border" role="separator"></div>
214
- {:else}
215
- <button
216
- type="button"
217
- role="menuitem"
218
- disabled={item.disabled}
219
- onclick={() => select(item)}
220
- class={[
221
- 'flex w-full items-start gap-2 rounded-xl px-2.5 py-2 text-left transition-colors',
222
- 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500/30',
223
- item.destructive
224
- ? 'text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40'
225
- : 'text-primary hover:bg-surface-overlay',
226
- item.disabled && 'cursor-not-allowed opacity-40'
227
- ]}
228
- >
229
- <span class="min-w-0 flex-1">
230
- <span class="block text-sm font-medium">{item.label}</span>
231
- {#if item.description}
232
- <span class="block text-xs text-muted">{item.description}</span>
233
- {/if}
234
- </span>
235
- {#if item.shortcut}
236
- <span class="mt-0.5 shrink-0 font-mono text-[10px] text-muted">{item.shortcut}</span>
237
- {/if}
238
- </button>
239
- {/if}
240
- {/each}
241
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}
242
343
  </div>
243
- {/if}
344
+ </div>
244
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 p-3 border-t', 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.6",
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",