@webjsdev/ui 0.3.1 → 0.3.3

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.
Files changed (39) hide show
  1. package/package.json +4 -3
  2. package/packages/registry/README.md +35 -0
  3. package/packages/registry/components/accordion.ts +74 -0
  4. package/packages/registry/components/alert-dialog.ts +359 -0
  5. package/packages/registry/components/alert.ts +51 -0
  6. package/packages/registry/components/aspect-ratio.ts +22 -0
  7. package/packages/registry/components/avatar.ts +52 -0
  8. package/packages/registry/components/badge.ts +40 -0
  9. package/packages/registry/components/breadcrumb.ts +43 -0
  10. package/packages/registry/components/button.ts +72 -0
  11. package/packages/registry/components/card.ts +86 -0
  12. package/packages/registry/components/checkbox.ts +97 -0
  13. package/packages/registry/components/collapsible.ts +60 -0
  14. package/packages/registry/components/dialog.ts +378 -0
  15. package/packages/registry/components/dropdown-menu.ts +607 -0
  16. package/packages/registry/components/hover-card.ts +175 -0
  17. package/packages/registry/components/input.ts +36 -0
  18. package/packages/registry/components/kbd.ts +25 -0
  19. package/packages/registry/components/label.ts +23 -0
  20. package/packages/registry/components/native-select.ts +110 -0
  21. package/packages/registry/components/pagination.ts +45 -0
  22. package/packages/registry/components/popover.ts +260 -0
  23. package/packages/registry/components/progress.ts +46 -0
  24. package/packages/registry/components/radio-group.ts +113 -0
  25. package/packages/registry/components/separator.ts +30 -0
  26. package/packages/registry/components/skeleton.ts +16 -0
  27. package/packages/registry/components/sonner.ts +240 -0
  28. package/packages/registry/components/switch.ts +52 -0
  29. package/packages/registry/components/table.ts +58 -0
  30. package/packages/registry/components/tabs.ts +271 -0
  31. package/packages/registry/components/textarea.ts +27 -0
  32. package/packages/registry/components/toggle-group.ts +236 -0
  33. package/packages/registry/components/toggle.ts +118 -0
  34. package/packages/registry/components/tooltip.ts +195 -0
  35. package/packages/registry/lib/utils.ts +241 -0
  36. package/packages/registry/package.json +7 -0
  37. package/packages/registry/registry.json +479 -0
  38. package/packages/registry/themes/base-colors.js +193 -0
  39. 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');