@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webjsdev/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
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/
|
|
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/
|
|
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
|
+
}
|