@webjsdev/ui 0.3.1 → 0.3.2
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.
- package/package.json +4 -3
- package/packages/registry/README.md +35 -0
- package/packages/registry/components/accordion.ts +74 -0
- package/packages/registry/components/alert-dialog.ts +359 -0
- package/packages/registry/components/alert.ts +51 -0
- package/packages/registry/components/aspect-ratio.ts +22 -0
- package/packages/registry/components/avatar.ts +52 -0
- package/packages/registry/components/badge.ts +40 -0
- package/packages/registry/components/breadcrumb.ts +43 -0
- package/packages/registry/components/button.ts +72 -0
- package/packages/registry/components/card.ts +86 -0
- package/packages/registry/components/checkbox.ts +97 -0
- package/packages/registry/components/collapsible.ts +60 -0
- package/packages/registry/components/dialog.ts +378 -0
- package/packages/registry/components/dropdown-menu.ts +591 -0
- package/packages/registry/components/hover-card.ts +175 -0
- package/packages/registry/components/input.ts +36 -0
- package/packages/registry/components/kbd.ts +25 -0
- package/packages/registry/components/label.ts +23 -0
- package/packages/registry/components/native-select.ts +110 -0
- package/packages/registry/components/pagination.ts +45 -0
- package/packages/registry/components/popover.ts +260 -0
- package/packages/registry/components/progress.ts +46 -0
- package/packages/registry/components/radio-group.ts +113 -0
- package/packages/registry/components/separator.ts +30 -0
- package/packages/registry/components/skeleton.ts +16 -0
- package/packages/registry/components/sonner.ts +240 -0
- package/packages/registry/components/switch.ts +52 -0
- package/packages/registry/components/table.ts +58 -0
- package/packages/registry/components/tabs.ts +271 -0
- package/packages/registry/components/textarea.ts +27 -0
- package/packages/registry/components/toggle-group.ts +236 -0
- package/packages/registry/components/toggle.ts +118 -0
- package/packages/registry/components/tooltip.ts +195 -0
- package/packages/registry/lib/utils.ts +241 -0
- package/packages/registry/package.json +7 -0
- package/packages/registry/registry.json +479 -0
- package/packages/registry/themes/base-colors.js +193 -0
- package/packages/registry/themes/index.css +141 -0
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DropdownMenu: popover-style menu of actions. Tier-2. Hand-rolled
|
|
3
|
+
* keyboard nav, focus management, and positioning (no Radix).
|
|
4
|
+
*
|
|
5
|
+
* APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/menu/
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* DropdownMenu → <ui-dropdown-menu open>
|
|
9
|
+
* DropdownMenuTrigger → <ui-dropdown-menu-trigger>
|
|
10
|
+
* DropdownMenuContent → <ui-dropdown-menu-content side align side-offset align-offset>
|
|
11
|
+
* DropdownMenuItem → <ui-dropdown-menu-item variant inset>
|
|
12
|
+
* DropdownMenuCheckboxItem → <ui-dropdown-menu-item type="checkbox" checked>
|
|
13
|
+
* DropdownMenuRadioGroup → <ui-dropdown-menu-group> wrapping
|
|
14
|
+
* DropdownMenuRadioItem → <ui-dropdown-menu-item type="radio" value>
|
|
15
|
+
* DropdownMenuLabel → <ui-dropdown-menu-label inset>
|
|
16
|
+
* DropdownMenuSeparator → <ui-dropdown-menu-separator>
|
|
17
|
+
* DropdownMenuShortcut → <ui-dropdown-menu-shortcut>
|
|
18
|
+
* DropdownMenuGroup → <ui-dropdown-menu-group>
|
|
19
|
+
* DropdownMenuSub → <ui-dropdown-menu-sub>
|
|
20
|
+
* DropdownMenuSubTrigger → <ui-dropdown-menu-sub-trigger inset>
|
|
21
|
+
* DropdownMenuSubContent → <ui-dropdown-menu-sub-content>
|
|
22
|
+
*
|
|
23
|
+
* Usage:
|
|
24
|
+
* <ui-dropdown-menu>
|
|
25
|
+
* <ui-dropdown-menu-trigger>
|
|
26
|
+
* <button class=${buttonClass({ variant: 'outline' })}>Options</button>
|
|
27
|
+
* </ui-dropdown-menu-trigger>
|
|
28
|
+
* <ui-dropdown-menu-content align="end">
|
|
29
|
+
* <ui-dropdown-menu-label>My Account</ui-dropdown-menu-label>
|
|
30
|
+
* <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
|
|
31
|
+
* <ui-dropdown-menu-item>Profile</ui-dropdown-menu-item>
|
|
32
|
+
* <ui-dropdown-menu-sub>
|
|
33
|
+
* <ui-dropdown-menu-sub-trigger>Invite users</ui-dropdown-menu-sub-trigger>
|
|
34
|
+
* <ui-dropdown-menu-sub-content>
|
|
35
|
+
* <ui-dropdown-menu-item>Email</ui-dropdown-menu-item>
|
|
36
|
+
* </ui-dropdown-menu-sub-content>
|
|
37
|
+
* </ui-dropdown-menu-sub>
|
|
38
|
+
* <ui-dropdown-menu-separator></ui-dropdown-menu-separator>
|
|
39
|
+
* <ui-dropdown-menu-item variant="destructive">Sign out</ui-dropdown-menu-item>
|
|
40
|
+
* </ui-dropdown-menu-content>
|
|
41
|
+
* </ui-dropdown-menu>
|
|
42
|
+
*
|
|
43
|
+
* Attributes on <ui-dropdown-menu>:
|
|
44
|
+
* `open`: boolean (reflected). Open state.
|
|
45
|
+
*
|
|
46
|
+
* Attributes on <ui-dropdown-menu-content>:
|
|
47
|
+
* `side`: "top" | "right" | "bottom" (default) | "left".
|
|
48
|
+
* `align`: "start" (default) | "center" | "end".
|
|
49
|
+
* `side-offset`: number, default 4. Pixels between trigger and content.
|
|
50
|
+
* `align-offset`: number, default 0. Pixels of cross-axis shift.
|
|
51
|
+
*
|
|
52
|
+
* Attributes on <ui-dropdown-menu-item>:
|
|
53
|
+
* `variant`: "default" (default) | "destructive".
|
|
54
|
+
* `inset`: boolean. Adds left padding to align with checkbox / radio items.
|
|
55
|
+
* `type`: omit (default) | "checkbox" | "radio".
|
|
56
|
+
* `checked`: boolean. Applies to checkbox / radio items.
|
|
57
|
+
* `value`: string. Identifier for radio items.
|
|
58
|
+
*
|
|
59
|
+
* Events:
|
|
60
|
+
* `ui-open-change` on <ui-dropdown-menu>: `{ detail: { open } }` after a transition.
|
|
61
|
+
* `ui-item-select` bubbled by an item: `{ detail: { value, item } }` on activation.
|
|
62
|
+
*
|
|
63
|
+
* Programmatic API on <ui-dropdown-menu>: `.show()` · `.hide()` · `.toggle()`.
|
|
64
|
+
*
|
|
65
|
+
* Keyboard:
|
|
66
|
+
* ArrowUp / ArrowDown move focus between items
|
|
67
|
+
* ArrowRight on a sub-trigger: open submenu, focus first item
|
|
68
|
+
* ArrowLeft inside a submenu: close it, refocus the sub-trigger
|
|
69
|
+
* Home / End first / last item
|
|
70
|
+
* Enter / Space activate focused item
|
|
71
|
+
* Escape close menu (or close current submenu first)
|
|
72
|
+
* Tab close menu and proceed with normal tab order
|
|
73
|
+
*
|
|
74
|
+
* Design tokens used: --popover, --popover-foreground, --accent,
|
|
75
|
+
* --accent-foreground, --destructive, --muted-foreground, --border.
|
|
76
|
+
*/
|
|
77
|
+
import { WebComponent, html, unsafeHTML } from '@webjsdev/core';
|
|
78
|
+
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
79
|
+
|
|
80
|
+
// --------------------------------------------------------------------------
|
|
81
|
+
// Class helpers
|
|
82
|
+
// --------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
export const dropdownMenuContentClass = (): string =>
|
|
85
|
+
'fixed z-50 max-h-[--available-height] min-w-[8rem] m-0 overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md';
|
|
86
|
+
|
|
87
|
+
export const dropdownMenuItemClass = (): string =>
|
|
88
|
+
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:hover:bg-destructive/10 data-[variant=destructive]:hover:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 dark:data-[variant=destructive]:hover:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
89
|
+
|
|
90
|
+
export const dropdownMenuCheckboxItemClass = (): string =>
|
|
91
|
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0";
|
|
92
|
+
|
|
93
|
+
export const dropdownMenuRadioItemClass = (): string =>
|
|
94
|
+
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8";
|
|
95
|
+
|
|
96
|
+
export const dropdownMenuLabelClass = (): string =>
|
|
97
|
+
'px-2 pt-2 pb-1.5 text-xs font-semibold text-muted-foreground data-[inset]:pl-8';
|
|
98
|
+
|
|
99
|
+
export const dropdownMenuSeparatorClass = (): string => '-mx-1 my-1 h-px bg-border';
|
|
100
|
+
|
|
101
|
+
export const dropdownMenuShortcutClass = (): string =>
|
|
102
|
+
'ml-auto text-xs tracking-widest text-muted-foreground';
|
|
103
|
+
|
|
104
|
+
export const dropdownMenuSubTriggerClass = (): string =>
|
|
105
|
+
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm select-none outline-hidden focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&>svg:last-child]:ml-auto";
|
|
106
|
+
|
|
107
|
+
export const dropdownMenuSubContentClass = (): string =>
|
|
108
|
+
'fixed z-50 min-w-[8rem] m-0 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg';
|
|
109
|
+
|
|
110
|
+
const CHEVRON_RIGHT_SVG =
|
|
111
|
+
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="ml-auto size-4" aria-hidden="true"><polyline points="9 18 15 12 9 6"></polyline></svg>';
|
|
112
|
+
|
|
113
|
+
const SUB_CLOSE_DELAY = 200;
|
|
114
|
+
|
|
115
|
+
// --------------------------------------------------------------------------
|
|
116
|
+
// <ui-dropdown-menu>
|
|
117
|
+
// --------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
export class UiDropdownMenu extends WebComponent {
|
|
120
|
+
static properties = {
|
|
121
|
+
open: { type: Boolean, reflect: true },
|
|
122
|
+
};
|
|
123
|
+
declare open: boolean;
|
|
124
|
+
|
|
125
|
+
_typeBuffer = '';
|
|
126
|
+
_typeBufferTimer: number | undefined;
|
|
127
|
+
|
|
128
|
+
_docClickHandler = (e: MouseEvent): void => this._onDocClick(e);
|
|
129
|
+
_keyHandler = (e: KeyboardEvent): void => this._onKeyDown(e);
|
|
130
|
+
_resizeHandler = (): void => this._reposition();
|
|
131
|
+
|
|
132
|
+
constructor() {
|
|
133
|
+
super();
|
|
134
|
+
this.open = false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
disconnectedCallback(): void {
|
|
138
|
+
if (this.open) this._teardown();
|
|
139
|
+
super.disconnectedCallback?.();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
toggle(): void { this.open = !this.open; }
|
|
143
|
+
show(): void { this.open = true; }
|
|
144
|
+
hide(): void { this.open = false; }
|
|
145
|
+
|
|
146
|
+
render() {
|
|
147
|
+
return html`<div data-slot="dropdown-menu" data-state=${this.open ? 'open' : 'closed'}>
|
|
148
|
+
<slot></slot>
|
|
149
|
+
</div>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
153
|
+
if (!changedProperties.has('open')) return;
|
|
154
|
+
if (changedProperties.get('open') === undefined) return;
|
|
155
|
+
// Wait one microtask for <ui-dropdown-menu-content>'s [popover] to commit.
|
|
156
|
+
queueMicrotask(() => this._afterRender());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_afterRender(): void {
|
|
160
|
+
const content = this._content();
|
|
161
|
+
if (content) {
|
|
162
|
+
this._syncContentPopover(content);
|
|
163
|
+
}
|
|
164
|
+
if (this.open) this._setup();
|
|
165
|
+
else this._teardown();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
_content(): HTMLElement | null {
|
|
169
|
+
return this.querySelector('ui-dropdown-menu-content [popover]');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
_syncContentPopover(content: HTMLElement): void {
|
|
173
|
+
const p = content as HTMLElement & {
|
|
174
|
+
showPopover?: () => void;
|
|
175
|
+
hidePopover?: () => void;
|
|
176
|
+
matches: (s: string) => boolean;
|
|
177
|
+
};
|
|
178
|
+
if (typeof p.showPopover !== 'function') return;
|
|
179
|
+
if (this.open && !p.matches(':popover-open')) p.showPopover();
|
|
180
|
+
else if (!this.open && p.matches(':popover-open')) p.hidePopover();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_reposition(): void {
|
|
184
|
+
const trigger = this.querySelector<HTMLElement>('ui-dropdown-menu-trigger');
|
|
185
|
+
const content = this._content();
|
|
186
|
+
const host = this.querySelector<HTMLElement>('ui-dropdown-menu-content');
|
|
187
|
+
if (!trigger || !content || !host) return;
|
|
188
|
+
positionFloating(trigger, content, {
|
|
189
|
+
side: (host.getAttribute('side') ?? 'bottom') as PopoverSide,
|
|
190
|
+
align: (host.getAttribute('align') ?? 'start') as PopoverAlign,
|
|
191
|
+
sideOffset: Number(host.getAttribute('side-offset') ?? 4),
|
|
192
|
+
alignOffset: Number(host.getAttribute('align-offset') ?? 0),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
_setup(): void {
|
|
197
|
+
this._reposition();
|
|
198
|
+
document.addEventListener('click', this._docClickHandler);
|
|
199
|
+
document.addEventListener('keydown', this._keyHandler);
|
|
200
|
+
window.addEventListener('resize', this._resizeHandler);
|
|
201
|
+
window.addEventListener('scroll', this._resizeHandler, true);
|
|
202
|
+
queueMicrotask(() => {
|
|
203
|
+
const first = this.querySelector<HTMLElement>(
|
|
204
|
+
'ui-dropdown-menu-item:not([data-disabled]) [role="menuitem"]',
|
|
205
|
+
);
|
|
206
|
+
first?.focus();
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_teardown(): void {
|
|
211
|
+
document.removeEventListener('click', this._docClickHandler);
|
|
212
|
+
document.removeEventListener('keydown', this._keyHandler);
|
|
213
|
+
window.removeEventListener('resize', this._resizeHandler);
|
|
214
|
+
window.removeEventListener('scroll', this._resizeHandler, true);
|
|
215
|
+
this.querySelectorAll<UiDropdownMenuSub>('ui-dropdown-menu-sub[open]').forEach(
|
|
216
|
+
(sub) => sub.hide(),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_onDocClick(e: MouseEvent): void {
|
|
221
|
+
if (!this.open) return;
|
|
222
|
+
if (e.composedPath().some((n) => n === this)) return;
|
|
223
|
+
this.hide();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_onKeyDown(e: KeyboardEvent): void {
|
|
227
|
+
if (!this.open) return;
|
|
228
|
+
if (e.key === 'Escape') {
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
this.hide();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Active context = nearest content / sub-content panel owning focus.
|
|
235
|
+
// Scoping arrow nav avoids walking into siblings of a different submenu.
|
|
236
|
+
const active = document.activeElement as HTMLElement | null;
|
|
237
|
+
const context = active?.closest('[role="menu"]') as HTMLElement | null;
|
|
238
|
+
if (!context) return;
|
|
239
|
+
|
|
240
|
+
const items = Array.from(
|
|
241
|
+
context.querySelectorAll<HTMLElement>('[role="menuitem"]:not([data-disabled])'),
|
|
242
|
+
).filter((it) => it.closest('[role="menu"]') === context);
|
|
243
|
+
if (items.length === 0) return;
|
|
244
|
+
const idx = active ? items.indexOf(active) : -1;
|
|
245
|
+
|
|
246
|
+
if (e.key === 'ArrowDown') {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
items[(idx + 1) % items.length].focus();
|
|
249
|
+
} else if (e.key === 'ArrowUp') {
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
items[(idx - 1 + items.length) % items.length].focus();
|
|
252
|
+
} else if (e.key === 'Home') {
|
|
253
|
+
e.preventDefault();
|
|
254
|
+
items[0].focus();
|
|
255
|
+
} else if (e.key === 'End') {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
items[items.length - 1].focus();
|
|
258
|
+
} else if (e.key === 'ArrowRight') {
|
|
259
|
+
// Open submenu owned by the focused sub-trigger and move focus into it.
|
|
260
|
+
const subTrigger = active?.closest('ui-dropdown-menu-sub-trigger');
|
|
261
|
+
if (subTrigger) {
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
const sub = subTrigger.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
|
|
264
|
+
if (sub) {
|
|
265
|
+
sub.show();
|
|
266
|
+
queueMicrotask(() => {
|
|
267
|
+
const firstSubItem = sub.querySelector<HTMLElement>(
|
|
268
|
+
'ui-dropdown-menu-sub-content [role="menuitem"]:not([data-disabled])',
|
|
269
|
+
);
|
|
270
|
+
firstSubItem?.focus();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} else if (e.key === 'ArrowLeft') {
|
|
275
|
+
// Inside a sub-content: close the submenu and refocus its trigger.
|
|
276
|
+
if (context.closest('ui-dropdown-menu-sub-content')) {
|
|
277
|
+
e.preventDefault();
|
|
278
|
+
const sub = context.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
|
|
279
|
+
const trigger = sub?.querySelector<HTMLElement>(
|
|
280
|
+
'ui-dropdown-menu-sub-trigger [role="menuitem"]',
|
|
281
|
+
);
|
|
282
|
+
sub?.hide();
|
|
283
|
+
trigger?.focus();
|
|
284
|
+
}
|
|
285
|
+
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
286
|
+
this._typeahead(e, items);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_typeahead(e: KeyboardEvent, items: HTMLElement[]): void {
|
|
291
|
+
this._typeBuffer = (this._typeBuffer + e.key).toLowerCase();
|
|
292
|
+
clearTimeout(this._typeBufferTimer);
|
|
293
|
+
this._typeBufferTimer = window.setTimeout(() => { this._typeBuffer = ''; }, 500);
|
|
294
|
+
const buffer = this._typeBuffer;
|
|
295
|
+
const match = items.find((it) => {
|
|
296
|
+
const text = (it.getAttribute('text-value') ?? it.textContent ?? '').trim().toLowerCase();
|
|
297
|
+
return text.startsWith(buffer);
|
|
298
|
+
});
|
|
299
|
+
if (match) {
|
|
300
|
+
e.preventDefault();
|
|
301
|
+
match.focus();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
UiDropdownMenu.register('ui-dropdown-menu');
|
|
306
|
+
|
|
307
|
+
// --------------------------------------------------------------------------
|
|
308
|
+
// <ui-dropdown-menu-trigger>
|
|
309
|
+
// --------------------------------------------------------------------------
|
|
310
|
+
|
|
311
|
+
export class UiDropdownMenuTrigger extends WebComponent {
|
|
312
|
+
render() {
|
|
313
|
+
return html`<div
|
|
314
|
+
data-slot="dropdown-menu-trigger"
|
|
315
|
+
@click=${this._onClick}
|
|
316
|
+
><slot></slot></div>`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
_onClick = (): void => (this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?.toggle();
|
|
320
|
+
}
|
|
321
|
+
UiDropdownMenuTrigger.register('ui-dropdown-menu-trigger');
|
|
322
|
+
|
|
323
|
+
// --------------------------------------------------------------------------
|
|
324
|
+
// <ui-dropdown-menu-content>
|
|
325
|
+
// --------------------------------------------------------------------------
|
|
326
|
+
|
|
327
|
+
export class UiDropdownMenuContent extends WebComponent {
|
|
328
|
+
render() {
|
|
329
|
+
return html`<div
|
|
330
|
+
data-slot="dropdown-menu-content"
|
|
331
|
+
role="menu"
|
|
332
|
+
popover="manual"
|
|
333
|
+
class=${dropdownMenuContentClass()}
|
|
334
|
+
><slot></slot></div>`;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
UiDropdownMenuContent.register('ui-dropdown-menu-content');
|
|
338
|
+
|
|
339
|
+
// --------------------------------------------------------------------------
|
|
340
|
+
// <ui-dropdown-menu-item>
|
|
341
|
+
// --------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
export class UiDropdownMenuItem extends WebComponent {
|
|
344
|
+
static properties = {
|
|
345
|
+
variant: { type: String, reflect: true },
|
|
346
|
+
};
|
|
347
|
+
declare variant: 'default' | 'destructive';
|
|
348
|
+
|
|
349
|
+
constructor() {
|
|
350
|
+
super();
|
|
351
|
+
this.variant = 'default';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
render() {
|
|
355
|
+
const inset = this.hasAttribute('inset');
|
|
356
|
+
return html`<div
|
|
357
|
+
data-slot="dropdown-menu-item"
|
|
358
|
+
role="menuitem"
|
|
359
|
+
tabindex="-1"
|
|
360
|
+
data-variant=${this.variant}
|
|
361
|
+
?data-inset=${inset}
|
|
362
|
+
class=${dropdownMenuItemClass()}
|
|
363
|
+
@click=${this._onClick}
|
|
364
|
+
@pointerenter=${this._onPointerEnter}
|
|
365
|
+
@focus=${this._onFocus}
|
|
366
|
+
@blur=${this._onBlur}
|
|
367
|
+
><slot></slot></div>`;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
_onClick = (e: Event): void => {
|
|
371
|
+
const el = e.currentTarget as HTMLElement;
|
|
372
|
+
if (el.hasAttribute('data-disabled')) return;
|
|
373
|
+
(this.closest('ui-dropdown-menu') as UiDropdownMenu | null)?.hide();
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
_onPointerEnter = (e: Event): void => {
|
|
377
|
+
const el = e.currentTarget as HTMLElement;
|
|
378
|
+
if (el.hasAttribute('data-disabled')) return;
|
|
379
|
+
el.focus();
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
_onFocus = (e: Event): void => {
|
|
383
|
+
(e.currentTarget as HTMLElement).setAttribute('data-highlighted', '');
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
_onBlur = (e: Event): void => {
|
|
387
|
+
(e.currentTarget as HTMLElement).removeAttribute('data-highlighted');
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
UiDropdownMenuItem.register('ui-dropdown-menu-item');
|
|
391
|
+
|
|
392
|
+
// --------------------------------------------------------------------------
|
|
393
|
+
// <ui-dropdown-menu-label>
|
|
394
|
+
// --------------------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
export class UiDropdownMenuLabel extends WebComponent {
|
|
397
|
+
render() {
|
|
398
|
+
const inset = this.hasAttribute('inset');
|
|
399
|
+
return html`<div
|
|
400
|
+
data-slot="dropdown-menu-label"
|
|
401
|
+
?data-inset=${inset}
|
|
402
|
+
class=${dropdownMenuLabelClass()}
|
|
403
|
+
><slot></slot></div>`;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
UiDropdownMenuLabel.register('ui-dropdown-menu-label');
|
|
407
|
+
|
|
408
|
+
// --------------------------------------------------------------------------
|
|
409
|
+
// <ui-dropdown-menu-separator>
|
|
410
|
+
// --------------------------------------------------------------------------
|
|
411
|
+
|
|
412
|
+
export class UiDropdownMenuSeparator extends WebComponent {
|
|
413
|
+
render() {
|
|
414
|
+
return html`<div
|
|
415
|
+
data-slot="dropdown-menu-separator"
|
|
416
|
+
role="separator"
|
|
417
|
+
class=${dropdownMenuSeparatorClass()}
|
|
418
|
+
></div>`;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
UiDropdownMenuSeparator.register('ui-dropdown-menu-separator');
|
|
422
|
+
|
|
423
|
+
// --------------------------------------------------------------------------
|
|
424
|
+
// <ui-dropdown-menu-shortcut>
|
|
425
|
+
// --------------------------------------------------------------------------
|
|
426
|
+
|
|
427
|
+
export class UiDropdownMenuShortcut extends WebComponent {
|
|
428
|
+
render() {
|
|
429
|
+
return html`<span
|
|
430
|
+
data-slot="dropdown-menu-shortcut"
|
|
431
|
+
class=${dropdownMenuShortcutClass()}
|
|
432
|
+
><slot></slot></span>`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
UiDropdownMenuShortcut.register('ui-dropdown-menu-shortcut');
|
|
436
|
+
|
|
437
|
+
// --------------------------------------------------------------------------
|
|
438
|
+
// <ui-dropdown-menu-group>
|
|
439
|
+
// --------------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
export class UiDropdownMenuGroup extends WebComponent {
|
|
442
|
+
render() {
|
|
443
|
+
return html`<div
|
|
444
|
+
data-slot="dropdown-menu-group"
|
|
445
|
+
role="group"
|
|
446
|
+
><slot></slot></div>`;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
UiDropdownMenuGroup.register('ui-dropdown-menu-group');
|
|
450
|
+
|
|
451
|
+
// --------------------------------------------------------------------------
|
|
452
|
+
// Submenu: Sub / SubTrigger / SubContent
|
|
453
|
+
// --------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
export class UiDropdownMenuSub extends WebComponent {
|
|
456
|
+
static properties = {
|
|
457
|
+
open: { type: Boolean, reflect: true },
|
|
458
|
+
};
|
|
459
|
+
declare open: boolean;
|
|
460
|
+
|
|
461
|
+
_closeTimer: number | undefined;
|
|
462
|
+
|
|
463
|
+
constructor() {
|
|
464
|
+
super();
|
|
465
|
+
this.open = false;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
disconnectedCallback(): void {
|
|
469
|
+
this._cancelClose();
|
|
470
|
+
super.disconnectedCallback?.();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
show(): void { this._cancelClose(); this.open = true; }
|
|
474
|
+
hide(): void { this._cancelClose(); this.open = false; }
|
|
475
|
+
toggle(): void { if (this.open) this.hide(); else this.show(); }
|
|
476
|
+
|
|
477
|
+
render() {
|
|
478
|
+
return html`<div
|
|
479
|
+
data-slot="dropdown-menu-sub"
|
|
480
|
+
data-state=${this.open ? 'open' : 'closed'}
|
|
481
|
+
@pointerenter=${this._cancelCloseHandler}
|
|
482
|
+
@pointerleave=${this._scheduleCloseHandler}
|
|
483
|
+
><slot></slot></div>`;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
487
|
+
if (!changedProperties.has('open')) return;
|
|
488
|
+
if (changedProperties.get('open') === undefined) return;
|
|
489
|
+
queueMicrotask(() => this._afterRender());
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
_afterRender(): void {
|
|
493
|
+
const subContent = this.querySelector<HTMLElement>('ui-dropdown-menu-sub-content [popover]');
|
|
494
|
+
if (subContent) {
|
|
495
|
+
const p = subContent as HTMLElement & {
|
|
496
|
+
showPopover?: () => void;
|
|
497
|
+
hidePopover?: () => void;
|
|
498
|
+
matches: (s: string) => boolean;
|
|
499
|
+
};
|
|
500
|
+
if (typeof p.showPopover === 'function') {
|
|
501
|
+
if (this.open && !p.matches(':popover-open')) p.showPopover();
|
|
502
|
+
else if (!this.open && p.matches(':popover-open')) p.hidePopover();
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (this.open) this._position();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
_cancelCloseHandler = (): void => this._cancelClose();
|
|
509
|
+
_scheduleCloseHandler = (): void => this._scheduleClose();
|
|
510
|
+
|
|
511
|
+
_scheduleClose(): void {
|
|
512
|
+
this._cancelClose();
|
|
513
|
+
this._closeTimer = window.setTimeout(() => this.hide(), SUB_CLOSE_DELAY);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
_cancelClose(): void {
|
|
517
|
+
if (this._closeTimer !== undefined) {
|
|
518
|
+
clearTimeout(this._closeTimer);
|
|
519
|
+
this._closeTimer = undefined;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
_position(): void {
|
|
524
|
+
const trigger = this.querySelector<HTMLElement>(
|
|
525
|
+
'ui-dropdown-menu-sub-trigger [role="menuitem"]',
|
|
526
|
+
);
|
|
527
|
+
const content = this.querySelector<HTMLElement>(
|
|
528
|
+
'ui-dropdown-menu-sub-content [popover]',
|
|
529
|
+
);
|
|
530
|
+
const contentHost = this.querySelector<HTMLElement>('ui-dropdown-menu-sub-content');
|
|
531
|
+
if (!trigger || !content || !contentHost) return;
|
|
532
|
+
positionFloating(trigger, content, {
|
|
533
|
+
side: (contentHost.getAttribute('side') ?? 'right') as PopoverSide,
|
|
534
|
+
align: (contentHost.getAttribute('align') ?? 'start') as PopoverAlign,
|
|
535
|
+
sideOffset: Number(contentHost.getAttribute('side-offset') ?? -4),
|
|
536
|
+
alignOffset: Number(contentHost.getAttribute('align-offset') ?? 0),
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
UiDropdownMenuSub.register('ui-dropdown-menu-sub');
|
|
541
|
+
|
|
542
|
+
export class UiDropdownMenuSubTrigger extends WebComponent {
|
|
543
|
+
// SSR-safe: linkedom doesn't implement closest() on custom elements.
|
|
544
|
+
_sub(): UiDropdownMenuSub | null {
|
|
545
|
+
if (typeof this.closest !== 'function') return null;
|
|
546
|
+
return this.closest('ui-dropdown-menu-sub') as UiDropdownMenuSub | null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
render() {
|
|
550
|
+
const inset = this.hasAttribute('inset');
|
|
551
|
+
const open = !!this._sub()?.open;
|
|
552
|
+
return html`<div
|
|
553
|
+
data-slot="dropdown-menu-sub-trigger"
|
|
554
|
+
role="menuitem"
|
|
555
|
+
tabindex="-1"
|
|
556
|
+
aria-haspopup="menu"
|
|
557
|
+
aria-expanded=${String(open)}
|
|
558
|
+
data-state=${open ? 'open' : 'closed'}
|
|
559
|
+
?data-inset=${inset}
|
|
560
|
+
class=${dropdownMenuSubTriggerClass()}
|
|
561
|
+
@click=${this._onClick}
|
|
562
|
+
@pointerenter=${this._onPointerEnter}
|
|
563
|
+
><slot></slot>${unsafeHTML(CHEVRON_RIGHT_SVG)}</div>`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
_onClick = (e: Event): void => {
|
|
567
|
+
const el = e.currentTarget as HTMLElement;
|
|
568
|
+
if (el.hasAttribute('data-disabled')) return;
|
|
569
|
+
this._sub()?.toggle();
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
_onPointerEnter = (e: Event): void => {
|
|
573
|
+
const el = e.currentTarget as HTMLElement;
|
|
574
|
+
if (el.hasAttribute('data-disabled')) return;
|
|
575
|
+
el.focus();
|
|
576
|
+
this._sub()?.show();
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
UiDropdownMenuSubTrigger.register('ui-dropdown-menu-sub-trigger');
|
|
580
|
+
|
|
581
|
+
export class UiDropdownMenuSubContent extends WebComponent {
|
|
582
|
+
render() {
|
|
583
|
+
return html`<div
|
|
584
|
+
data-slot="dropdown-menu-sub-content"
|
|
585
|
+
role="menu"
|
|
586
|
+
popover="manual"
|
|
587
|
+
class=${dropdownMenuSubContentClass()}
|
|
588
|
+
><slot></slot></div>`;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
UiDropdownMenuSubContent.register('ui-dropdown-menu-sub-content');
|