@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
|
|
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
|
|
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
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
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}
|