@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,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dialog: modal dialog built on the native `<dialog>` element. Tier-2.
|
|
3
|
+
* The custom element is a thin decorator over `HTMLDialogElement.showModal()`,
|
|
4
|
+
* which gives us top-layer rendering (no z-index wars), the `::backdrop`
|
|
5
|
+
* pseudo, focus trap with initial-focus + restore-on-close, Escape-to-close
|
|
6
|
+
* via the cancel event, and background-inert for free.
|
|
7
|
+
*
|
|
8
|
+
* Composition: `<ui-dialog-content>` owns the native `<dialog>` element
|
|
9
|
+
* (its render() emits the `<dialog>` wrapper around its slotted content),
|
|
10
|
+
* while `<ui-dialog>` just tracks open state and asks the content child
|
|
11
|
+
* to `showModal()` / `close()`. Every <slot> is a default slot, which
|
|
12
|
+
* avoids the SSR pitfall where slot="..." set in connectedCallback never
|
|
13
|
+
* runs server-side (linkedom has no upgrade lifecycle).
|
|
14
|
+
*
|
|
15
|
+
* shadcn parity:
|
|
16
|
+
* Dialog → <ui-dialog open>
|
|
17
|
+
* DialogTrigger → <ui-dialog-trigger>
|
|
18
|
+
* DialogContent → <ui-dialog-content show-close-button>
|
|
19
|
+
* DialogClose → <ui-dialog-close>
|
|
20
|
+
* DialogHeader → <div class=${dialogHeaderClass()}>
|
|
21
|
+
* DialogTitle → <h2 class=${dialogTitleClass()}>
|
|
22
|
+
* DialogDescription → <p class=${dialogDescriptionClass()}>
|
|
23
|
+
* DialogFooter → <div class=${dialogFooterClass()}>
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* <ui-dialog>
|
|
27
|
+
* <ui-dialog-trigger>
|
|
28
|
+
* <button class=${buttonClass({ variant: 'outline' })}>Edit profile</button>
|
|
29
|
+
* </ui-dialog-trigger>
|
|
30
|
+
* <ui-dialog-content>
|
|
31
|
+
* <div class=${dialogHeaderClass()}>
|
|
32
|
+
* <h2 class=${dialogTitleClass()}>Edit profile</h2>
|
|
33
|
+
* <p class=${dialogDescriptionClass()}>Make changes and click save.</p>
|
|
34
|
+
* </div>
|
|
35
|
+
* <div class="grid gap-3">
|
|
36
|
+
* <label class=${labelClass()} for="dlg-name">Name</label>
|
|
37
|
+
* <input class=${inputClass()} id="dlg-name" placeholder="Your name">
|
|
38
|
+
* </div>
|
|
39
|
+
* <div class=${dialogFooterClass()}>
|
|
40
|
+
* <ui-dialog-close><button class=${buttonClass({ variant: 'outline' })}>Cancel</button></ui-dialog-close>
|
|
41
|
+
* <button class=${buttonClass()}>Save</button>
|
|
42
|
+
* </div>
|
|
43
|
+
* </ui-dialog-content>
|
|
44
|
+
* </ui-dialog>
|
|
45
|
+
*
|
|
46
|
+
* <!-- Suppress the auto-injected top-right X close: -->
|
|
47
|
+
* <ui-dialog-content show-close-button="false">…</ui-dialog-content>
|
|
48
|
+
*
|
|
49
|
+
* Attributes on <ui-dialog>:
|
|
50
|
+
* `open`: boolean (reflected). Presence shows the dialog.
|
|
51
|
+
*
|
|
52
|
+
* Attributes on <ui-dialog-content>:
|
|
53
|
+
* `show-close-button`: "false" suppresses the auto-injected top-right X
|
|
54
|
+
* close button (default: shown).
|
|
55
|
+
*
|
|
56
|
+
* Events:
|
|
57
|
+
* `ui-open-change` on <ui-dialog>: `{ detail: { open } }` after a transition.
|
|
58
|
+
*
|
|
59
|
+
* Programmatic API on <ui-dialog>: `.show()` · `.hide()` · `.toggle()`.
|
|
60
|
+
*
|
|
61
|
+
* Keyboard: Escape closes (native `cancel` event); Tab cycles trapped
|
|
62
|
+
* within the dialog (native focus trap).
|
|
63
|
+
*
|
|
64
|
+
* Design tokens used: --background, --border, --muted-foreground.
|
|
65
|
+
*/
|
|
66
|
+
import { WebComponent, html, unsafeHTML } from '@webjsdev/core';
|
|
67
|
+
import { buttonClass } from './button.ts';
|
|
68
|
+
|
|
69
|
+
// --------------------------------------------------------------------------
|
|
70
|
+
// Class helpers for subparts.
|
|
71
|
+
// --------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export const dialogHeaderClass = (): string =>
|
|
74
|
+
'flex flex-col gap-2 text-center sm:text-left';
|
|
75
|
+
|
|
76
|
+
export const dialogTitleClass = (): string =>
|
|
77
|
+
'text-lg leading-none font-semibold';
|
|
78
|
+
|
|
79
|
+
export const dialogDescriptionClass = (): string =>
|
|
80
|
+
'text-sm text-muted-foreground';
|
|
81
|
+
|
|
82
|
+
export const dialogFooterClass = (): string =>
|
|
83
|
+
'flex flex-col-reverse gap-2 sm:flex-row sm:justify-end';
|
|
84
|
+
|
|
85
|
+
export const dialogContentClass = (): string =>
|
|
86
|
+
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none sm:max-w-lg';
|
|
87
|
+
|
|
88
|
+
export const dialogCloseButtonClass = (): string =>
|
|
89
|
+
"absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-hidden focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
90
|
+
|
|
91
|
+
const DIALOG_CLOSE_X_SVG =
|
|
92
|
+
'<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" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"></path></svg>';
|
|
93
|
+
|
|
94
|
+
// --------------------------------------------------------------------------
|
|
95
|
+
// Pre-hydration paint fallback. Before the script upgrades the custom
|
|
96
|
+
// elements, <ui-dialog-content> sits in normal flow and would flash
|
|
97
|
+
// visible. The selector-based rules hide it until JS marks the host as
|
|
98
|
+
// `[open]`. Once upgraded, the native <dialog> wrapper takes over:
|
|
99
|
+
// closed `<dialog>` is UA `display: none`; opened via showModal() is
|
|
100
|
+
// `display: block` in the top layer.
|
|
101
|
+
// --------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
const STYLES = `
|
|
104
|
+
ui-dialog:not([open]) ui-dialog-content { display: none !important; }
|
|
105
|
+
ui-dialog[open] { display: contents; }
|
|
106
|
+
ui-dialog-content { display: grid; }
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
// Clears the UA defaults on <dialog> so it becomes an invisible top-layer
|
|
110
|
+
// host. The visible box is rendered by <ui-dialog-content>. The
|
|
111
|
+
// backdrop: variant styles the ::backdrop pseudo-element.
|
|
112
|
+
const NATIVE_DIALOG_CLASS = 'border-0 bg-transparent p-0 m-0 w-0 h-0 max-w-none max-h-none overflow-visible text-inherit backdrop:bg-black/50';
|
|
113
|
+
|
|
114
|
+
function installStyles(): void {
|
|
115
|
+
if (typeof document === 'undefined') return;
|
|
116
|
+
if (document.getElementById('ui-dialog-styles')) return;
|
|
117
|
+
const style = document.createElement('style');
|
|
118
|
+
style.id = 'ui-dialog-styles';
|
|
119
|
+
style.textContent = STYLES;
|
|
120
|
+
document.head.appendChild(style);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// --------------------------------------------------------------------------
|
|
124
|
+
// Body scroll lock. Refcounted so nested dialogs unlock in order. Native
|
|
125
|
+
// <dialog> does not lock body scroll, only inert-ifies the background.
|
|
126
|
+
// --------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
let scrollLockCount = 0;
|
|
129
|
+
let savedOverflow = '';
|
|
130
|
+
let savedPaddingRight = '';
|
|
131
|
+
|
|
132
|
+
function lockScroll(): void {
|
|
133
|
+
if (scrollLockCount === 0) {
|
|
134
|
+
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
|
|
135
|
+
savedOverflow = document.body.style.overflow;
|
|
136
|
+
savedPaddingRight = document.body.style.paddingRight;
|
|
137
|
+
document.body.style.overflow = 'hidden';
|
|
138
|
+
if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
|
|
139
|
+
}
|
|
140
|
+
scrollLockCount++;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function unlockScroll(): void {
|
|
144
|
+
scrollLockCount = Math.max(0, scrollLockCount - 1);
|
|
145
|
+
if (scrollLockCount === 0) {
|
|
146
|
+
document.body.style.overflow = savedOverflow;
|
|
147
|
+
document.body.style.paddingRight = savedPaddingRight;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --------------------------------------------------------------------------
|
|
152
|
+
// <ui-dialog>
|
|
153
|
+
// Owns the open state. Defers the actual <dialog> element to the child
|
|
154
|
+
// <ui-dialog-content> (so no named slot is needed, which avoids the
|
|
155
|
+
// SSR routing problem). On open transitions, asks the content child
|
|
156
|
+
// to showModal() / close() its inner <dialog>.
|
|
157
|
+
// --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
export class UiDialog extends WebComponent {
|
|
160
|
+
static properties = {
|
|
161
|
+
open: { type: Boolean, reflect: true },
|
|
162
|
+
};
|
|
163
|
+
declare open: boolean;
|
|
164
|
+
|
|
165
|
+
constructor() {
|
|
166
|
+
super();
|
|
167
|
+
this.open = false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
connectedCallback(): void {
|
|
171
|
+
installStyles();
|
|
172
|
+
// Legacy <ui-dialog-overlay> isn't supported anymore; the native
|
|
173
|
+
// ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
|
|
174
|
+
this.querySelector<HTMLElement>(':scope > ui-dialog-overlay')?.remove();
|
|
175
|
+
super.connectedCallback?.();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
disconnectedCallback(): void {
|
|
179
|
+
if (this.open) this._teardown();
|
|
180
|
+
super.disconnectedCallback?.();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
show(): void { this.open = true; }
|
|
184
|
+
hide(): void { this.open = false; }
|
|
185
|
+
toggle(): void { this.open = !this.open; }
|
|
186
|
+
|
|
187
|
+
// Back-compat getter alongside the reactive `open` prop.
|
|
188
|
+
get isOpen(): boolean { return this.open; }
|
|
189
|
+
|
|
190
|
+
render() {
|
|
191
|
+
return html`<div data-slot="dialog" data-state=${this.open ? 'open' : 'closed'}>
|
|
192
|
+
<slot></slot>
|
|
193
|
+
</div>`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
197
|
+
if (!changedProperties.has('open')) return;
|
|
198
|
+
// Constructor sets open=false, which records a (undefined -> false)
|
|
199
|
+
// transition in the first update. Skip it so the dialog doesn't
|
|
200
|
+
// emit a teardown + ui-open-change for the initial state.
|
|
201
|
+
if (changedProperties.get('open') === undefined) return;
|
|
202
|
+
// Defer one microtask so the content child has committed its own
|
|
203
|
+
// render (the native <dialog> element lives in the content's template).
|
|
204
|
+
queueMicrotask(() => {
|
|
205
|
+
if (this.open) this._setup();
|
|
206
|
+
else this._teardown();
|
|
207
|
+
this.dispatchEvent(
|
|
208
|
+
new CustomEvent('ui-open-change', { detail: { open: this.open }, bubbles: true }),
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
get _content(): UiDialogContent | null {
|
|
214
|
+
return this.querySelector('ui-dialog-content') as UiDialogContent | null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
_setup(): void {
|
|
218
|
+
const content = this._content;
|
|
219
|
+
if (!content) return;
|
|
220
|
+
lockScroll();
|
|
221
|
+
content.showModal();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_teardown(): void {
|
|
225
|
+
unlockScroll();
|
|
226
|
+
this._content?.close();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
UiDialog.register('ui-dialog');
|
|
230
|
+
|
|
231
|
+
// --------------------------------------------------------------------------
|
|
232
|
+
// <ui-dialog-trigger>
|
|
233
|
+
// --------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
export class UiDialogTrigger extends WebComponent {
|
|
236
|
+
render() {
|
|
237
|
+
return html`<div
|
|
238
|
+
data-slot="dialog-trigger"
|
|
239
|
+
@click=${this._onClick}
|
|
240
|
+
><slot></slot></div>`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
_onClick = (): void => {
|
|
244
|
+
(this.closest('ui-dialog') as UiDialog | null)?.show();
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
UiDialogTrigger.register('ui-dialog-trigger');
|
|
248
|
+
|
|
249
|
+
// --------------------------------------------------------------------------
|
|
250
|
+
// <ui-dialog-content>
|
|
251
|
+
// Auto-injects an X close button (top-right) unless show-close-button="false".
|
|
252
|
+
// --------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
// <ui-dialog-content> owns the native <dialog> element. Renders a native
|
|
255
|
+
// <dialog> wrapper around its slotted content, plus the auto-injected X
|
|
256
|
+
// close button. Exposes showModal() / close() so the parent <ui-dialog>
|
|
257
|
+
// can drive the open state imperatively without a named slot.
|
|
258
|
+
|
|
259
|
+
export class UiDialogContent extends WebComponent {
|
|
260
|
+
static properties = {
|
|
261
|
+
showCloseButton: { type: String, reflect: true, attribute: 'show-close-button' },
|
|
262
|
+
};
|
|
263
|
+
declare showCloseButton: string;
|
|
264
|
+
|
|
265
|
+
constructor() {
|
|
266
|
+
super();
|
|
267
|
+
this.showCloseButton = 'true';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
showModal(): void {
|
|
271
|
+
const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
|
|
272
|
+
if (native && !native.open) native.showModal();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
close(): void {
|
|
276
|
+
const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="dialog-native"]');
|
|
277
|
+
if (native?.open) native.close();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
render() {
|
|
281
|
+
const wantClose = this.showCloseButton !== 'false';
|
|
282
|
+
const parentOpen = !!this._parent()?.open;
|
|
283
|
+
return html`<dialog
|
|
284
|
+
data-slot="dialog-native"
|
|
285
|
+
class=${NATIVE_DIALOG_CLASS}
|
|
286
|
+
@close=${this._onNativeClose}
|
|
287
|
+
@click=${this._onNativeBackdropClick}
|
|
288
|
+
><div
|
|
289
|
+
data-slot="dialog-content"
|
|
290
|
+
role="dialog"
|
|
291
|
+
aria-modal="true"
|
|
292
|
+
tabindex="-1"
|
|
293
|
+
data-state=${parentOpen ? 'open' : 'closed'}
|
|
294
|
+
class=${dialogContentClass()}
|
|
295
|
+
>
|
|
296
|
+
<slot></slot>
|
|
297
|
+
${wantClose
|
|
298
|
+
? html`<button
|
|
299
|
+
type="button"
|
|
300
|
+
aria-label="Close"
|
|
301
|
+
data-slot="dialog-close"
|
|
302
|
+
class=${dialogCloseButtonClass()}
|
|
303
|
+
@click=${this._onAutoCloseClick}
|
|
304
|
+
>${unsafeHTML(DIALOG_CLOSE_X_SVG)}</button>`
|
|
305
|
+
: ''}
|
|
306
|
+
</div></dialog>`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_onAutoCloseClick = (): void => {
|
|
310
|
+
this._parent()?.hide();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
_onNativeClose = (): void => {
|
|
314
|
+
const p = this._parent();
|
|
315
|
+
if (p?.open) p.open = false;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// Backdrop-click closes the dialog: the click target on the backdrop is
|
|
319
|
+
// the <dialog> element itself (the inner content panel catches its own
|
|
320
|
+
// clicks).
|
|
321
|
+
_onNativeBackdropClick = (e: MouseEvent): void => {
|
|
322
|
+
if (e.target === e.currentTarget) this._parent()?.hide();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// SSR-safe: linkedom doesn't implement closest() on custom elements.
|
|
326
|
+
_parent(): UiDialog | null {
|
|
327
|
+
if (typeof this.closest !== 'function') return null;
|
|
328
|
+
return this.closest('ui-dialog') as UiDialog | null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
UiDialogContent.register('ui-dialog-content');
|
|
332
|
+
|
|
333
|
+
// --------------------------------------------------------------------------
|
|
334
|
+
// <ui-dialog-close>
|
|
335
|
+
// --------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
export class UiDialogClose extends WebComponent {
|
|
338
|
+
render() {
|
|
339
|
+
return html`<div
|
|
340
|
+
data-slot="dialog-close"
|
|
341
|
+
@click=${this._onClick}
|
|
342
|
+
><slot></slot></div>`;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
_onClick = (): void => {
|
|
346
|
+
(this.closest('ui-dialog') as UiDialog | null)?.hide();
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
UiDialogClose.register('ui-dialog-close');
|
|
350
|
+
|
|
351
|
+
// --------------------------------------------------------------------------
|
|
352
|
+
// <ui-dialog-footer>
|
|
353
|
+
// --------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
export class UiDialogFooter extends WebComponent {
|
|
356
|
+
static properties = {
|
|
357
|
+
showCloseButton: { type: String, attribute: 'show-close-button' },
|
|
358
|
+
};
|
|
359
|
+
declare showCloseButton: string | null;
|
|
360
|
+
|
|
361
|
+
constructor() {
|
|
362
|
+
super();
|
|
363
|
+
this.showCloseButton = null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
render() {
|
|
367
|
+
const wantClose = this.showCloseButton !== null && this.showCloseButton !== 'false';
|
|
368
|
+
return html`<div data-slot="dialog-footer" class=${dialogFooterClass()}>
|
|
369
|
+
<slot></slot>
|
|
370
|
+
${wantClose
|
|
371
|
+
? html`<ui-dialog-close>
|
|
372
|
+
<button class=${buttonClass({ variant: 'outline' })} type="button">Close</button>
|
|
373
|
+
</ui-dialog-close>`
|
|
374
|
+
: ''}
|
|
375
|
+
</div>`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
UiDialogFooter.register('ui-dialog-footer');
|