@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/ui",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "description": "An AI-first component library - class-helper functions for visuals, custom elements only where state matters. Source-copied into your repo, you own it. Works with any Tailwind v4 project.",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "bin",
17
17
  "src",
18
+ "packages/registry",
18
19
  "README.md"
19
20
  ],
20
21
  "scripts": {
@@ -31,11 +32,11 @@
31
32
  },
32
33
  "repository": {
33
34
  "type": "git",
34
- "url": "git+https://github.com/vivek7405/webjs.git",
35
+ "url": "git+https://github.com/webjsdev/webjs.git",
35
36
  "directory": "packages/ui"
36
37
  },
37
38
  "homepage": "https://ui.webjs.dev",
38
- "bugs": "https://github.com/vivek7405/webjs/issues",
39
+ "bugs": "https://github.com/webjsdev/webjs/issues",
39
40
  "license": "MIT",
40
41
  "keywords": [
41
42
  "shadcn",
@@ -0,0 +1,35 @@
1
+ # @webjsdev/ui-registry
2
+
3
+ Internal: sources for the `@webjsdev/ui` component registry.
4
+
5
+ Not published. `registry.json` is the manifest, and the files it points at
6
+ are the source of truth. The website
7
+ ([`@webjsdev/ui-website`](../ui-website)) composes shadcn-compatible JSON on
8
+ demand and serves it at `https://ui.webjs.dev/registry/<name>.json`, which the
9
+ `@webjsdev/ui` CLI fetches. There is no build step, no generated output, no
10
+ `prestart` hook.
11
+
12
+ ## Layout
13
+
14
+ ```
15
+ components/ one .ts per shadcn component (web component port, light DOM + Tailwind)
16
+ lib/ shared lib code shipped into user projects (utils.ts → cn)
17
+ themes/
18
+ index.css neutral @theme block + CSS variables (light + dark defaults)
19
+ base-colors.js per-base-colour overrides (stone, zinc, mauve, olive, mist, taupe) + mergeThemeCss
20
+ registry.json manifest read by the website composer at request time
21
+ ```
22
+
23
+ Only `theme-neutral` is in `registry.json`. The other 6 base colours are
24
+ synthesized by the composer at request time: neutral CSS + per-colour
25
+ overrides → merged CSS, same `files[]` shape.
26
+
27
+ ## Wire endpoints
28
+
29
+ Served by `@webjsdev/ui-website` (`app/_lib/registry.server.ts` +
30
+ `app/registry/**`):
31
+
32
+ - `GET /registry/<name>.json`: single registry item (`type: registry:ui` /
33
+ `registry:theme` / `registry:lib`), with file contents inlined.
34
+ - `GET /registry/index.json`: flat metadata-only list.
35
+ - `GET /registry`: full manifest with every item's content inlined.
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Accordion: vertical collapsible list built on native <details>/<summary>.
3
+ *
4
+ * Tier-1 (no custom element). Exclusive open behaviour (Radix's
5
+ * `type="single"`) comes from giving every <details> the same
6
+ * `name="..."` attribute. Independent open (`type="multiple"`) is the
7
+ * default when `name` is omitted. Both modes give `collapsible` for
8
+ * free: clicking the open <summary> closes it.
9
+ *
10
+ * shadcn parity:
11
+ * <Accordion type="single" collapsible> → <div class=${accordionClass()}> wrapping
12
+ * <details name="..."> items
13
+ * <Accordion type="multiple"> → same, omit `name`
14
+ * <AccordionItem> → <details class=${accordionItemClass()}>
15
+ * <AccordionTrigger> → <summary class=${accordionTriggerClass()}>
16
+ * <AccordionContent> → <div class=${accordionContentClass()}>
17
+ *
18
+ * Usage (single-open, exclusive):
19
+ * <div class=${accordionClass()}>
20
+ * <details name="faq" class=${accordionItemClass()}>
21
+ * <summary class=${accordionTriggerClass()}>
22
+ * <span>Is it accessible?</span>
23
+ * <svg class="size-4 transition-transform group-open:rotate-180">…</svg>
24
+ * </summary>
25
+ * <div class=${accordionContentClass()}>Yes, native disclosure widget.</div>
26
+ * </details>
27
+ * <details name="faq" class=${accordionItemClass()} open>
28
+ * <summary class=${accordionTriggerClass()}>Is it styled?</summary>
29
+ * <div class=${accordionContentClass()}>Yes, shadcn design tokens.</div>
30
+ * </details>
31
+ * </div>
32
+ *
33
+ * Initial state: add `open` on the <details> that should render expanded
34
+ * on first paint. Programmatic toggling: `el.open = true | false`.
35
+ *
36
+ * `<details name="X">` is the platform's exclusive-accordion primitive:
37
+ * Chrome 120+, Safari 17.2+, Firefox 130+. Migrated from the prior
38
+ * <ui-accordion> custom element set.
39
+ *
40
+ * Design tokens used: --border, --ring, --foreground.
41
+ */
42
+
43
+ /** Root wrapper. Holds the column-of-items rhythm; no display: rules. */
44
+ export const accordionClass = (): string => 'w-full';
45
+
46
+ /**
47
+ * Item: each <details>. The `group` utility lets the trigger's chevron
48
+ * rotate on open via `group-open:rotate-180`. `last:border-b-0` cleans
49
+ * the trailing edge.
50
+ */
51
+ export const accordionItemClass = (): string => 'group border-b last:border-b-0';
52
+
53
+ /**
54
+ * Trigger: applied to <summary>. Hides the native disclosure triangle so
55
+ * authors can compose their own chevron icon (typical pattern: trailing
56
+ * lucide chevron with `group-open:rotate-180`).
57
+ *
58
+ * `disabled: true` returns the visual disabled state (greyed out,
59
+ * not-allowed cursor, no pointer events). For true keyboard prevention
60
+ *, the native disabled-disclosure-widget gap, add the standard
61
+ * `inert` attribute to the <details> element. shadcn's React `disabled`
62
+ * prop combines both; native HTML has no `disabled` on <details>.
63
+ */
64
+ export const accordionTriggerClass = (opts: { disabled?: boolean } = {}): string => {
65
+ const base = 'flex w-full cursor-pointer list-none items-center justify-between gap-4 py-4 text-left text-sm font-medium outline-none transition-all hover:underline focus-visible:ring-2 focus-visible:ring-ring/50 marker:hidden [&::-webkit-details-marker]:hidden';
66
+ if (opts.disabled) return `${base} pointer-events-none cursor-not-allowed opacity-50`;
67
+ return base;
68
+ };
69
+
70
+ /**
71
+ * Content: <details> hides this entirely when not [open], so all we add
72
+ * is the typography rhythm matching shadcn (bottom padding, small text).
73
+ */
74
+ export const accordionContentClass = (): string => 'pb-4 text-sm';
@@ -0,0 +1,359 @@
1
+ /**
2
+ * AlertDialog: modal requiring explicit Cancel / Action confirmation.
3
+ * Tier-2. Variant of Dialog with `role="alertdialog"`, no
4
+ * Escape-to-close, no overlay-click-to-close. Built on the native
5
+ * `<dialog>` element.
6
+ *
7
+ * APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/alertdialog/
8
+ *
9
+ * Composition follows dialog.ts: `<ui-alert-dialog-content>` owns the
10
+ * native `<dialog>` (its render() emits the `<dialog>` wrapper around
11
+ * its slotted content); the parent tracks open state and drives
12
+ * showModal() / close() on the content child. Every <slot> is a default
13
+ * slot, so SSR doesn't need to route children by name (which wouldn't
14
+ * work because slot="..." set in connectedCallback never runs
15
+ * server-side).
16
+ *
17
+ * shadcn parity:
18
+ * AlertDialog → <ui-alert-dialog open>
19
+ * AlertDialogTrigger → <ui-alert-dialog-trigger>
20
+ * AlertDialogContent → <ui-alert-dialog-content size>
21
+ * AlertDialogHeader → <div class=${alertDialogHeaderClass()}>
22
+ * AlertDialogTitle → <h2 class=${alertDialogTitleClass()}>
23
+ * AlertDialogDescription → <p class=${alertDialogDescriptionClass()}>
24
+ * AlertDialogFooter → <div class=${alertDialogFooterClass()}>
25
+ * AlertDialogAction → <ui-alert-dialog-action variant size>
26
+ * AlertDialogCancel → <ui-alert-dialog-cancel variant size>
27
+ *
28
+ * Usage:
29
+ * <ui-alert-dialog>
30
+ * <ui-alert-dialog-trigger>
31
+ * <button class=${buttonClass({ variant: 'destructive' })}>Delete</button>
32
+ * </ui-alert-dialog-trigger>
33
+ * <ui-alert-dialog-content>
34
+ * <div class=${alertDialogHeaderClass()}>
35
+ * <h2 class=${alertDialogTitleClass()}>Delete account?</h2>
36
+ * <p class=${alertDialogDescriptionClass()}>This cannot be undone.</p>
37
+ * </div>
38
+ * <div class=${alertDialogFooterClass()}>
39
+ * <ui-alert-dialog-cancel>Cancel</ui-alert-dialog-cancel>
40
+ * <ui-alert-dialog-action variant="destructive">Delete</ui-alert-dialog-action>
41
+ * </div>
42
+ * </ui-alert-dialog-content>
43
+ * </ui-alert-dialog>
44
+ *
45
+ * Attributes on <ui-alert-dialog>:
46
+ * `open`: boolean (reflected). Presence shows the dialog.
47
+ *
48
+ * Attributes on <ui-alert-dialog-content>:
49
+ * `size`: "default" (default) | "sm". The sm size flips the footer to
50
+ * a 2-column grid with full-width buttons.
51
+ *
52
+ * Attributes on <ui-alert-dialog-action> / <ui-alert-dialog-cancel>:
53
+ * `variant`: ButtonVariant. Action defaults to "default", cancel to "outline".
54
+ * `size`: ButtonSize. Defaults to "default".
55
+ *
56
+ * Events: none dispatched at present (no `ui-open-change`); observe the
57
+ * reflected `open` attribute on `<ui-alert-dialog>`.
58
+ *
59
+ * Programmatic API on <ui-alert-dialog>: `.show()` · `.hide()`.
60
+ *
61
+ * Keyboard: Escape is blocked (alert dialogs require explicit choice);
62
+ * Tab cycles trapped within the dialog (native focus trap).
63
+ *
64
+ * Design tokens used: --background, --border, --muted-foreground.
65
+ */
66
+ import { WebComponent, html } from '@webjsdev/core';
67
+ import { buttonClass, type ButtonVariant, type ButtonSize } from './button.ts';
68
+
69
+ export const alertDialogContentClass = (): string =>
70
+ 'group/alert-dialog-content 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 shadcn-lg shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg';
71
+
72
+ export const alertDialogHeaderClass = (): string =>
73
+ 'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left';
74
+
75
+ export const alertDialogFooterClass = (): string =>
76
+ 'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end';
77
+
78
+ export const alertDialogTitleClass = (): string => 'text-lg font-semibold';
79
+
80
+ export const alertDialogDescriptionClass = (): string => 'text-sm text-muted-foreground';
81
+
82
+ // Pre-hydration paint fallback (see dialog.ts for the long version).
83
+ const STYLES = `
84
+ ui-alert-dialog:not([open]) ui-alert-dialog-content { display: none !important; }
85
+ ui-alert-dialog-content { display: grid; }
86
+ `;
87
+
88
+ 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';
89
+
90
+ function installStyles(): void {
91
+ if (typeof document === 'undefined') return;
92
+ if (document.getElementById('ui-alert-dialog-styles')) return;
93
+ const style = document.createElement('style');
94
+ style.id = 'ui-alert-dialog-styles';
95
+ style.textContent = STYLES;
96
+ document.head.appendChild(style);
97
+ }
98
+
99
+ // Body scroll lock. Refcounted so nested dialogs unlock in order. Kept in
100
+ // lockstep with dialog.ts (not imported) so `webjs ui add alert-dialog`
101
+ // doesn't pull in the full dialog component.
102
+ let scrollLockCount = 0;
103
+ let savedOverflow = '';
104
+ let savedPaddingRight = '';
105
+
106
+ function lockScroll(): void {
107
+ if (scrollLockCount === 0) {
108
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
109
+ savedOverflow = document.body.style.overflow;
110
+ savedPaddingRight = document.body.style.paddingRight;
111
+ document.body.style.overflow = 'hidden';
112
+ if (scrollbarWidth > 0) document.body.style.paddingRight = `${scrollbarWidth}px`;
113
+ }
114
+ scrollLockCount++;
115
+ }
116
+
117
+ function unlockScroll(): void {
118
+ scrollLockCount = Math.max(0, scrollLockCount - 1);
119
+ if (scrollLockCount === 0) {
120
+ document.body.style.overflow = savedOverflow;
121
+ document.body.style.paddingRight = savedPaddingRight;
122
+ }
123
+ }
124
+
125
+ // --------------------------------------------------------------------------
126
+ // <ui-alert-dialog>
127
+ // Owns the open state. Defers the actual <dialog> element to the child
128
+ // <ui-alert-dialog-content>, so no named slot is needed (which avoids
129
+ // the SSR slot-routing problem). Drives showModal() / close() on the
130
+ // content child via prop transitions.
131
+ // --------------------------------------------------------------------------
132
+
133
+ export class UiAlertDialog extends WebComponent {
134
+ static properties = {
135
+ open: { type: Boolean, reflect: true },
136
+ };
137
+ declare open: boolean;
138
+
139
+ constructor() {
140
+ super();
141
+ this.open = false;
142
+ }
143
+
144
+ connectedCallback(): void {
145
+ installStyles();
146
+ // Legacy <ui-alert-dialog-overlay> isn't supported anymore; the native
147
+ // ::backdrop pseudo replaces it. Strip it if a stale doc uses it.
148
+ this.querySelector<HTMLElement>(':scope > ui-alert-dialog-overlay')?.remove();
149
+ super.connectedCallback?.();
150
+ }
151
+
152
+ disconnectedCallback(): void {
153
+ if (this.open) this._teardown();
154
+ super.disconnectedCallback?.();
155
+ }
156
+
157
+ show(): void { this.open = true; }
158
+ hide(): void { this.open = false; }
159
+
160
+ // Back-compat getter alongside the reactive `open` prop.
161
+ get isOpen(): boolean { return this.open; }
162
+
163
+ render() {
164
+ return html`<div data-slot="alert-dialog" data-state=${this.open ? 'open' : 'closed'}>
165
+ <slot></slot>
166
+ </div>`;
167
+ }
168
+
169
+ updated(changedProperties: Map<string, unknown>): void {
170
+ if (!changedProperties.has('open')) return;
171
+ // Skip the constructor's initial open=false set so we don't fire
172
+ // teardown for the closed-on-mount state.
173
+ if (changedProperties.get('open') === undefined) return;
174
+ // Defer one microtask so the content child has rendered its inner
175
+ // native <dialog>; that's where showModal() / close() act.
176
+ queueMicrotask(() => {
177
+ if (this.open) this._setup();
178
+ else this._teardown();
179
+ });
180
+ }
181
+
182
+ get _content(): UiAlertDialogContent | null {
183
+ return this.querySelector('ui-alert-dialog-content') as UiAlertDialogContent | null;
184
+ }
185
+
186
+ _setup(): void {
187
+ const content = this._content;
188
+ if (!content) return;
189
+ lockScroll();
190
+ content.showModal();
191
+ }
192
+
193
+ _teardown(): void {
194
+ unlockScroll();
195
+ this._content?.close();
196
+ }
197
+ }
198
+ UiAlertDialog.register('ui-alert-dialog');
199
+
200
+ // --------------------------------------------------------------------------
201
+ // <ui-alert-dialog-trigger>
202
+ // --------------------------------------------------------------------------
203
+
204
+ export class UiAlertDialogTrigger extends WebComponent {
205
+ render() {
206
+ return html`<div
207
+ data-slot="alert-dialog-trigger"
208
+ @click=${this._onClick}
209
+ ><slot></slot></div>`;
210
+ }
211
+
212
+ _onClick = (): void => (this.closest('ui-alert-dialog') as UiAlertDialog | null)?.show();
213
+ }
214
+ UiAlertDialogTrigger.register('ui-alert-dialog-trigger');
215
+
216
+ // --------------------------------------------------------------------------
217
+ // <ui-alert-dialog-content>
218
+ // Owns the native <dialog> element. Renders a <dialog> wrapper around its
219
+ // slotted children. Exposes showModal() / close() so the parent
220
+ // <ui-alert-dialog> can drive the open state imperatively without a
221
+ // named slot. Escape-to-close is blocked here (alert dialogs require an
222
+ // explicit choice via Cancel / Action).
223
+ // --------------------------------------------------------------------------
224
+
225
+ export class UiAlertDialogContent extends WebComponent {
226
+ static properties = {
227
+ size: { type: String, reflect: true },
228
+ };
229
+ declare size: 'default' | 'sm';
230
+
231
+ constructor() {
232
+ super();
233
+ this.size = 'default';
234
+ }
235
+
236
+ showModal(): void {
237
+ const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
238
+ if (native && !native.open) native.showModal();
239
+ }
240
+
241
+ close(): void {
242
+ const native = this.querySelector<HTMLDialogElement>('dialog[data-slot="alert-dialog-native"]');
243
+ if (native?.open) native.close();
244
+ }
245
+
246
+ render() {
247
+ const parentOpen = !!this._parent()?.open;
248
+ return html`<dialog
249
+ data-slot="alert-dialog-native"
250
+ class=${NATIVE_DIALOG_CLASS}
251
+ @cancel=${this._onNativeCancel}
252
+ @close=${this._onNativeClose}
253
+ ><div
254
+ data-slot="alert-dialog-content"
255
+ role="alertdialog"
256
+ aria-modal="true"
257
+ tabindex="-1"
258
+ data-size=${this.size}
259
+ data-state=${parentOpen ? 'open' : 'closed'}
260
+ class=${alertDialogContentClass()}
261
+ ><slot></slot></div></dialog>`;
262
+ }
263
+
264
+ // Block native Escape-to-close. Alert dialogs require an explicit
265
+ // Cancel / Action choice.
266
+ _onNativeCancel = (e: Event): void => e.preventDefault();
267
+ _onNativeClose = (): void => {
268
+ const p = this._parent();
269
+ if (p?.open) p.open = false;
270
+ };
271
+
272
+ // SSR-safe: linkedom doesn't implement closest() on custom elements.
273
+ _parent(): UiAlertDialog | null {
274
+ if (typeof this.closest !== 'function') return null;
275
+ return this.closest('ui-alert-dialog') as UiAlertDialog | null;
276
+ }
277
+ }
278
+ UiAlertDialogContent.register('ui-alert-dialog-content');
279
+
280
+ // --------------------------------------------------------------------------
281
+ // <ui-alert-dialog-cancel> + <ui-alert-dialog-action>
282
+ // shadcn's <AlertDialogAction> and <AlertDialogCancel> ARE button-styled
283
+ // elements with forwarded `variant` and `size` props. Each renders its
284
+ // own native <button> with @click handler; the user's label text or
285
+ // inline icon SVG projects through a slot inside that button.
286
+ //
287
+ // Authoring is bare text / icons, not a wrapped <button>:
288
+ //
289
+ // <ui-alert-dialog-cancel>Cancel</ui-alert-dialog-cancel>
290
+ // <ui-alert-dialog-action variant="destructive">Delete</ui-alert-dialog-action>
291
+ //
292
+ // (A legacy wrap-a-button form used to be supported by sniffing for an
293
+ // authored <button> in connectedCallback. SSR rendered the wrong branch
294
+ // because connectedCallback never fires server-side, producing invalid
295
+ // nested-<button> HTML that the parser flattened into siblings -- the
296
+ // "buttons have no text" symptom. Removed in favour of one canonical
297
+ // authoring shape that works in both SSR and CSR.)
298
+ //
299
+ // The extra `group-data-[size=sm]/alert-dialog-content:w-full` class
300
+ // makes the inner button stretch when the parent's footer flips to
301
+ // `grid grid-cols-2` (size=sm); otherwise the inline-flex button is
302
+ // content-width and sits at the start of its grid cell.
303
+ // --------------------------------------------------------------------------
304
+
305
+ const ALERT_DIALOG_ACTION_GRID_STRETCH = 'group-data-[size=sm]/alert-dialog-content:w-full';
306
+
307
+ export class UiAlertDialogCancel extends WebComponent {
308
+ static properties = {
309
+ variant: { type: String, reflect: true },
310
+ size: { type: String, reflect: true },
311
+ };
312
+ declare variant: ButtonVariant;
313
+ declare size: ButtonSize;
314
+
315
+ constructor() {
316
+ super();
317
+ this.variant = 'outline';
318
+ this.size = 'default';
319
+ }
320
+
321
+ render() {
322
+ return html`<button
323
+ type="button"
324
+ data-slot="alert-dialog-cancel"
325
+ class="${buttonClass({ variant: this.variant, size: this.size })} ${ALERT_DIALOG_ACTION_GRID_STRETCH}"
326
+ @click=${this._onClick}
327
+ ><slot></slot></button>`;
328
+ }
329
+
330
+ _onClick = (): void => (this.closest('ui-alert-dialog') as UiAlertDialog | null)?.hide();
331
+ }
332
+ UiAlertDialogCancel.register('ui-alert-dialog-cancel');
333
+
334
+ export class UiAlertDialogAction extends WebComponent {
335
+ static properties = {
336
+ variant: { type: String, reflect: true },
337
+ size: { type: String, reflect: true },
338
+ };
339
+ declare variant: ButtonVariant;
340
+ declare size: ButtonSize;
341
+
342
+ constructor() {
343
+ super();
344
+ this.variant = 'default';
345
+ this.size = 'default';
346
+ }
347
+
348
+ render() {
349
+ return html`<button
350
+ type="button"
351
+ data-slot="alert-dialog-action"
352
+ class="${buttonClass({ variant: this.variant, size: this.size })} ${ALERT_DIALOG_ACTION_GRID_STRETCH}"
353
+ @click=${this._onClick}
354
+ ><slot></slot></button>`;
355
+ }
356
+
357
+ _onClick = (): void => (this.closest('ui-alert-dialog') as UiAlertDialog | null)?.hide();
358
+ }
359
+ UiAlertDialogAction.register('ui-alert-dialog-action');
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Alert: informational banner. Tier-1 class helpers; compose with a
3
+ * native `<div role="alert">` (or `role="status"` for non-urgent updates).
4
+ *
5
+ * shadcn parity:
6
+ * Alert (variant: default | destructive) → alertClass({ variant })
7
+ * AlertTitle → alertTitleClass()
8
+ * AlertDescription → alertDescriptionClass()
9
+ *
10
+ * Usage:
11
+ * <div role="alert" class=${alertClass()}>
12
+ * <svg>…</svg>
13
+ * <div data-slot="alert-title" class=${alertTitleClass()}>Heads up</div>
14
+ * <div data-slot="alert-description" class=${alertDescriptionClass()}>
15
+ * Something happened. Probably fine.
16
+ * </div>
17
+ * </div>
18
+ *
19
+ * <!-- Destructive variant: accent stripe + colored title/description. -->
20
+ * <div role="alert" class=${alertClass({ variant: 'destructive' })}>
21
+ * <svg>…</svg>
22
+ * <div data-slot="alert-title" class=${alertTitleClass()}>Failed</div>
23
+ * <div data-slot="alert-description" class=${alertDescriptionClass()}>
24
+ * Couldn't save your changes.
25
+ * </div>
26
+ * </div>
27
+ *
28
+ * Design tokens used: --card, --card-foreground, --destructive, --muted-foreground.
29
+ */
30
+ import { cn } from '../lib/utils.ts';
31
+
32
+ const BASE =
33
+ 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current';
34
+
35
+ const VARIANTS = {
36
+ default: 'bg-card text-card-foreground',
37
+ destructive:
38
+ 'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
39
+ } as const;
40
+
41
+ export type AlertVariant = keyof typeof VARIANTS;
42
+
43
+ export function alertClass(opts: { variant?: AlertVariant } = {}): string {
44
+ return cn(BASE, VARIANTS[opts.variant ?? 'default']);
45
+ }
46
+
47
+ export const alertTitleClass = (): string =>
48
+ 'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight';
49
+
50
+ export const alertDescriptionClass = (): string =>
51
+ 'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed';
@@ -0,0 +1,22 @@
1
+ /**
2
+ * AspectRatio: preserve a width:height ratio for its child. Tier-1
3
+ * class helper over the modern CSS `aspect-ratio` property (Baseline
4
+ * 2022). No JS, no custom element.
5
+ *
6
+ * shadcn parity:
7
+ * AspectRatio (Radix primitive) → aspectRatioClass() + inline `aspect-ratio` style
8
+ *
9
+ * Usage:
10
+ * <div style="aspect-ratio: 16/9;" class="${aspectRatioClass()}">
11
+ * <img src="…" class="h-full w-full object-cover rounded-md">
12
+ * </div>
13
+ *
14
+ * <!-- Or with Tailwind's arbitrary aspect-ratio (no helper needed): -->
15
+ * <div class="aspect-[16/9]">
16
+ * <img …>
17
+ * </div>
18
+ *
19
+ * Design tokens used: none (layout only).
20
+ */
21
+
22
+ export const aspectRatioClass = (): string => 'relative w-full';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Avatar: circular user image with fallback. Tier-1 class helpers;
3
+ * compose with a native `<span>` or `<img>`. The fallback shows when the
4
+ * `<img>` is missing or fails to load (`onerror`).
5
+ *
6
+ * shadcn parity:
7
+ * Avatar (size: default | sm | lg) → avatarClass({ size }) with `data-size`
8
+ * AvatarImage → avatarImageClass()
9
+ * AvatarFallback → avatarFallbackClass()
10
+ * AvatarBadge → avatarBadgeClass()
11
+ * AvatarGroup → avatarGroupClass()
12
+ * AvatarGroupCount → avatarGroupCountClass()
13
+ *
14
+ * Usage:
15
+ * <span class=${avatarClass()} data-size="default" data-slot="avatar">
16
+ * <img class=${avatarImageClass()} src="…" alt="…">
17
+ * <span class=${avatarFallbackClass()}>VK</span>
18
+ * </span>
19
+ *
20
+ * <div class=${avatarGroupClass()}>
21
+ * <span class=${avatarClass()} data-size="default" data-slot="avatar">…</span>
22
+ * <span class=${avatarClass()} data-size="default" data-slot="avatar">…</span>
23
+ * <div class=${avatarGroupCountClass()}>+3</div>
24
+ * </div>
25
+ *
26
+ * Design tokens used: --muted, --muted-foreground, --primary, --background.
27
+ */
28
+ import { cn } from '../lib/utils.ts';
29
+
30
+ export type AvatarSize = 'default' | 'sm' | 'lg';
31
+
32
+ const AVATAR_BASE =
33
+ 'group/avatar relative flex size-8 shrink-0 overflow-hidden rounded-full select-none data-[size=lg]:size-10 data-[size=sm]:size-6';
34
+
35
+ export function avatarClass(_opts: { size?: AvatarSize } = {}): string {
36
+ // Size driven by data-size attribute on the element (matches shadcn).
37
+ return cn(AVATAR_BASE);
38
+ }
39
+
40
+ export const avatarImageClass = (): string => 'aspect-square size-full';
41
+
42
+ export const avatarFallbackClass = (): string =>
43
+ 'flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs';
44
+
45
+ export const avatarBadgeClass = (): string =>
46
+ 'absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground ring-2 ring-background select-none group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2 group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2';
47
+
48
+ export const avatarGroupClass = (): string =>
49
+ 'group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background';
50
+
51
+ export const avatarGroupCountClass = (): string =>
52
+ 'relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3';
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Badge: small visual label. Tier-1 class helper; compose with any
3
+ * inline element (commonly `<span>` or `<a>` for linked badges).
4
+ *
5
+ * shadcn parity:
6
+ * Badge (variant: default | secondary | destructive | outline | ghost | link)
7
+ * → badgeClass({ variant })
8
+ *
9
+ * Usage:
10
+ * <span class=${badgeClass()}>New</span>
11
+ * <span class=${badgeClass({ variant: 'destructive' })}>Error</span>
12
+ * <a class=${badgeClass({ variant: 'link' })} href="/profile">@vivek</a>
13
+ *
14
+ * The `[a&]:hover:...` hover styles only apply when the element is an `<a>`,
15
+ * so a static `<span>` doesn't pick up an unwanted hover.
16
+ *
17
+ * Design tokens used: --primary, --secondary, --destructive, --foreground,
18
+ * --accent, --border, --ring.
19
+ */
20
+ import { cn } from '../lib/utils.ts';
21
+
22
+ const BASE =
23
+ 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3';
24
+
25
+ const VARIANTS = {
26
+ default: 'bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
27
+ secondary: 'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
28
+ destructive:
29
+ 'bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
30
+ outline:
31
+ 'border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
32
+ ghost: '[a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
33
+ link: 'text-primary underline-offset-4 [a&]:hover:underline',
34
+ } as const;
35
+
36
+ export type BadgeVariant = keyof typeof VARIANTS;
37
+
38
+ export function badgeClass(opts: { variant?: BadgeVariant } = {}): string {
39
+ return cn(BASE, VARIANTS[opts.variant ?? 'default']);
40
+ }