@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
|
|
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
|
|
98
|
-
|
|
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
|
-
|
|
176
|
+
closeMenu();
|
|
105
177
|
}
|
|
106
178
|
|
|
107
|
-
function
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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 (
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
215
|
+
cancelAnimationFrame(frame);
|
|
216
|
+
offScroll();
|
|
217
|
+
offResize();
|
|
218
|
+
offVisual();
|
|
124
219
|
};
|
|
125
220
|
});
|
|
126
221
|
</script>
|
|
127
222
|
|
|
128
|
-
<div
|
|
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
|
-
|
|
240
|
+
aria-controls={menuId || undefined}
|
|
145
241
|
>
|
|
146
|
-
<
|
|
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
|
|
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
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
|
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}
|