@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,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sonner: toast notification queue. Tier-2. Hand-rolled (no `sonner`
|
|
3
|
+
* npm dependency); ships as a single `<ui-sonner>` viewport you mount
|
|
4
|
+
* once + an imperative `toast()` API published as a module export. A
|
|
5
|
+
* singleton bus routes new toasts to the most recently connected
|
|
6
|
+
* viewport; multiple `<ui-sonner>` instances are supported with
|
|
7
|
+
* explicit per-instance dispatch.
|
|
8
|
+
*
|
|
9
|
+
* shadcn parity:
|
|
10
|
+
* <Toaster /> → <ui-sonner position>
|
|
11
|
+
* toast(msg, opts)
|
|
12
|
+
* → toast() (plus toast.success / .error / .info / .warning /
|
|
13
|
+
* .loading / .promise / .dismiss)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* <!-- Mount once at the root of your app (typically in layout.ts): -->
|
|
17
|
+
* <ui-sonner position="bottom-right"></ui-sonner>
|
|
18
|
+
*
|
|
19
|
+
* // Then from anywhere (server or client component):
|
|
20
|
+
* import { toast } from '@/components/ui/sonner.ts';
|
|
21
|
+
* toast('Saved!');
|
|
22
|
+
* toast.success('Account created');
|
|
23
|
+
* toast.error('Failed to save', { description: 'Try again' });
|
|
24
|
+
* toast.promise(savePost(), { loading: 'Saving…', success: 'Saved', error: 'Failed' });
|
|
25
|
+
* toast.dismiss(id);
|
|
26
|
+
*
|
|
27
|
+
* Attributes on <ui-sonner>:
|
|
28
|
+
* `position`: "top-left" | "top-center" | "top-right" |
|
|
29
|
+
* "bottom-left" | "bottom-center" | "bottom-right" (default).
|
|
30
|
+
*
|
|
31
|
+
* Per-toast options (passed as the second arg to `toast(msg, opts)`):
|
|
32
|
+
* `id`: string | number. Stable id so repeated calls update in place.
|
|
33
|
+
* `description`: string. Secondary line under the title.
|
|
34
|
+
* `duration`: ms, default 4000 (loading toasts default to 0, no auto-dismiss).
|
|
35
|
+
* `action`: { label, onClick } | undefined. Renders an action button.
|
|
36
|
+
* `cancel`: { label, onClick } | undefined. Renders a cancel button.
|
|
37
|
+
*
|
|
38
|
+
* Events: none dispatched (consumers act on the id returned by `toast()`).
|
|
39
|
+
*
|
|
40
|
+
* Programmatic API on <ui-sonner>: `.addToast(message, opts, type)` for
|
|
41
|
+
* per-instance dispatch (bypasses the singleton router that `toast()`
|
|
42
|
+
* uses); typically only needed when mounting multiple viewports.
|
|
43
|
+
*
|
|
44
|
+
* Design tokens used: --popover, --popover-foreground, --border, --radius.
|
|
45
|
+
*/
|
|
46
|
+
import { WebComponent, html, repeat, unsafeHTML, signal } from '@webjsdev/core';
|
|
47
|
+
|
|
48
|
+
type ToastType = 'default' | 'success' | 'error' | 'info' | 'warning' | 'loading';
|
|
49
|
+
|
|
50
|
+
interface ToastOptions {
|
|
51
|
+
id?: string | number;
|
|
52
|
+
description?: string;
|
|
53
|
+
duration?: number;
|
|
54
|
+
action?: { label: string; onClick: () => void };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ToastItem extends ToastOptions {
|
|
58
|
+
id: string | number;
|
|
59
|
+
type: ToastType;
|
|
60
|
+
message: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let nextId = 1;
|
|
64
|
+
const toaster: { add(t: ToastItem): void; remove(id: string | number): void } = {
|
|
65
|
+
add() {},
|
|
66
|
+
remove() {},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
function publish(item: ToastItem): string | number {
|
|
70
|
+
toaster.add(item);
|
|
71
|
+
return item.id;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function makeToast(message: string, opts: ToastOptions = {}, type: ToastType = 'default'): string | number {
|
|
75
|
+
return publish({
|
|
76
|
+
id: opts.id ?? nextId++,
|
|
77
|
+
type,
|
|
78
|
+
message,
|
|
79
|
+
description: opts.description,
|
|
80
|
+
duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
|
|
81
|
+
action: opts.action,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function toast(message: string, opts?: ToastOptions): string | number {
|
|
86
|
+
return makeToast(message, opts, 'default');
|
|
87
|
+
}
|
|
88
|
+
toast.success = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'success');
|
|
89
|
+
toast.error = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'error');
|
|
90
|
+
toast.info = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'info');
|
|
91
|
+
toast.warning = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'warning');
|
|
92
|
+
toast.loading = (msg: string, o?: ToastOptions) => makeToast(msg, o, 'loading');
|
|
93
|
+
toast.dismiss = (id?: string | number) => {
|
|
94
|
+
if (id == null) return;
|
|
95
|
+
toaster.remove(id);
|
|
96
|
+
};
|
|
97
|
+
toast.promise = <T,>(p: Promise<T>, opts: { loading: string; success: string; error: string }) => {
|
|
98
|
+
const id = toast.loading(opts.loading);
|
|
99
|
+
p.then(() => {
|
|
100
|
+
toast.dismiss(id);
|
|
101
|
+
toast.success(opts.success);
|
|
102
|
+
}).catch(() => {
|
|
103
|
+
toast.dismiss(id);
|
|
104
|
+
toast.error(opts.error);
|
|
105
|
+
});
|
|
106
|
+
return id;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// --------------------------------------------------------------------------
|
|
110
|
+
// <ui-sonner> renders pending toasts. No user-projected children, so no
|
|
111
|
+
// <slot>: the toaster owns its content entirely via render().
|
|
112
|
+
// --------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
const POSITIONS = {
|
|
115
|
+
'top-right': 'top-4 right-4',
|
|
116
|
+
'top-left': 'top-4 left-4',
|
|
117
|
+
'top-center': 'top-4 left-1/2 -translate-x-1/2',
|
|
118
|
+
'bottom-right': 'bottom-4 right-4',
|
|
119
|
+
'bottom-left': 'bottom-4 left-4',
|
|
120
|
+
'bottom-center': 'bottom-4 left-1/2 -translate-x-1/2',
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
export type SonnerPosition = keyof typeof POSITIONS;
|
|
124
|
+
|
|
125
|
+
const TOAST_ITEM_BASE =
|
|
126
|
+
'pointer-events-auto flex w-80 items-start gap-3 rounded-md border bg-popover p-4 text-sm text-popover-foreground shadow-md transition-all';
|
|
127
|
+
|
|
128
|
+
const TYPE_ICON_COLOR: Record<ToastType, string> = {
|
|
129
|
+
default: 'text-foreground',
|
|
130
|
+
success: 'text-emerald-500',
|
|
131
|
+
error: 'text-destructive',
|
|
132
|
+
info: 'text-sky-500',
|
|
133
|
+
warning: 'text-amber-500',
|
|
134
|
+
loading: 'text-muted-foreground',
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export class UiSonner extends WebComponent {
|
|
138
|
+
static properties = {
|
|
139
|
+
position: { type: String, reflect: true },
|
|
140
|
+
};
|
|
141
|
+
declare position: SonnerPosition;
|
|
142
|
+
items = signal<ToastItem[]>([]);
|
|
143
|
+
|
|
144
|
+
constructor() {
|
|
145
|
+
super();
|
|
146
|
+
this.position = 'bottom-right';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Routing the global toast() function to this viewport. Runs in
|
|
150
|
+
// firstUpdated rather than the constructor because tests can mount
|
|
151
|
+
// multiple <ui-sonner> instances and the most recently mounted wins
|
|
152
|
+
// (matches the existing semantics).
|
|
153
|
+
firstUpdated(): void {
|
|
154
|
+
toaster.add = (t) => this._add(t);
|
|
155
|
+
toaster.remove = (id) => this._remove(id);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Publish a toast directly to THIS viewport. Use when you have a
|
|
160
|
+
* specific <ui-sonner> reference and want to bypass the singleton
|
|
161
|
+
* `toaster.add` routing (which always points to the last-mounted
|
|
162
|
+
* viewport). Primary use case: docs demos that mount one viewport
|
|
163
|
+
* per position and want each demo button to fire into its own
|
|
164
|
+
* viewport. App code should normally call the global `toast()` /
|
|
165
|
+
* `toast.success()` / etc., which route via the singleton.
|
|
166
|
+
*/
|
|
167
|
+
addToast(message: string, opts: ToastOptions = {}, type: ToastType = 'default'): string | number {
|
|
168
|
+
const id = opts.id ?? nextId++;
|
|
169
|
+
this._add({
|
|
170
|
+
id,
|
|
171
|
+
type,
|
|
172
|
+
message,
|
|
173
|
+
description: opts.description,
|
|
174
|
+
duration: opts.duration ?? (type === 'loading' ? 0 : 4000),
|
|
175
|
+
action: opts.action,
|
|
176
|
+
});
|
|
177
|
+
return id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_add(item: ToastItem): void {
|
|
181
|
+
this.items.set([...this.items.get(), item]);
|
|
182
|
+
if (item.duration && item.duration > 0) {
|
|
183
|
+
setTimeout(() => this._remove(item.id), item.duration);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
_remove(id: string | number): void {
|
|
188
|
+
this.items.set(this.items.get().filter((i) => i.id !== id));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
render() {
|
|
192
|
+
const pos = POSITIONS[this.position] ?? POSITIONS['bottom-right'];
|
|
193
|
+
return html`<div
|
|
194
|
+
data-slot="sonner"
|
|
195
|
+
class=${`pointer-events-none fixed z-[100] flex flex-col gap-2 ${pos}`}
|
|
196
|
+
>
|
|
197
|
+
${repeat(
|
|
198
|
+
this.items.get(),
|
|
199
|
+
(item) => item.id,
|
|
200
|
+
(item) => html`<div
|
|
201
|
+
class=${TOAST_ITEM_BASE}
|
|
202
|
+
data-type=${item.type}
|
|
203
|
+
role=${item.type === 'error' ? 'alert' : 'status'}
|
|
204
|
+
>
|
|
205
|
+
<div class=${`${TYPE_ICON_COLOR[item.type]} pt-0.5`}>${unsafeHTML(ICONS[item.type])}</div>
|
|
206
|
+
<div class="flex-1">
|
|
207
|
+
<div class="font-medium">${item.message}</div>
|
|
208
|
+
${item.description
|
|
209
|
+
? html`<div class="mt-1 text-xs text-muted-foreground">${item.description}</div>`
|
|
210
|
+
: ''}
|
|
211
|
+
</div>
|
|
212
|
+
${item.action
|
|
213
|
+
? html`<button
|
|
214
|
+
type="button"
|
|
215
|
+
class="rounded-md px-2 py-1 text-xs font-medium hover:bg-accent"
|
|
216
|
+
@click=${() => {
|
|
217
|
+
item.action!.onClick();
|
|
218
|
+
this._remove(item.id);
|
|
219
|
+
}}
|
|
220
|
+
>${item.action.label}</button>`
|
|
221
|
+
: ''}
|
|
222
|
+
</div>`,
|
|
223
|
+
)}
|
|
224
|
+
</div>`;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
UiSonner.register('ui-sonner');
|
|
228
|
+
|
|
229
|
+
const ICONS: Record<ToastType, string> = {
|
|
230
|
+
default: '',
|
|
231
|
+
success:
|
|
232
|
+
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>',
|
|
233
|
+
error:
|
|
234
|
+
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
|
235
|
+
info: '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>',
|
|
236
|
+
warning:
|
|
237
|
+
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>',
|
|
238
|
+
loading:
|
|
239
|
+
'<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" class="animate-spin"><line x1="12" y1="2" x2="12" y2="6"/><line x1="12" y1="18" x2="12" y2="22"/><line x1="4.93" y1="4.93" x2="7.76" y2="7.76"/><line x1="16.24" y1="16.24" x2="19.07" y2="19.07"/><line x1="2" y1="12" x2="6" y2="12"/><line x1="18" y1="12" x2="22" y2="12"/></svg>',
|
|
240
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switch: toggle styled as a sliding pill. Tier-1 class helpers. A
|
|
3
|
+
* hidden native `<input type="checkbox" role="switch">` handles form
|
|
4
|
+
* submission + keyboard; a sibling `<span>` provides the visual track +
|
|
5
|
+
* thumb (positioned via the `peer-checked:` variant).
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* Switch (size: default | sm) → switchInputClass() on a hidden <input> +
|
|
9
|
+
* switchTrackClass({ size }) on a sibling <span>
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* <label class="inline-flex items-center gap-2">
|
|
13
|
+
* <input type="checkbox" role="switch" name="notify" class=${switchInputClass()}>
|
|
14
|
+
* <span class=${switchTrackClass()}></span>
|
|
15
|
+
* <span class=${labelClass()}>Notifications</span>
|
|
16
|
+
* </label>
|
|
17
|
+
*
|
|
18
|
+
* <!-- Small size: -->
|
|
19
|
+
* <input type="checkbox" role="switch" name="x" class=${cn(switchInputClass(), 'peer/sm')}>
|
|
20
|
+
* <span class=${switchTrackClass({ size: 'sm' })}></span>
|
|
21
|
+
*
|
|
22
|
+
* Design tokens used: --primary, --input, --background, --foreground, --ring,
|
|
23
|
+
* --primary-foreground.
|
|
24
|
+
*/
|
|
25
|
+
import { cn } from '../lib/utils.ts';
|
|
26
|
+
|
|
27
|
+
/** Hidden native checkbox: handles form value + keyboard activation. */
|
|
28
|
+
export const switchInputClass = (): string => 'peer sr-only';
|
|
29
|
+
|
|
30
|
+
const TRACK_BASE =
|
|
31
|
+
'inline-flex shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none peer-focus-visible:border-ring peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 bg-input dark:bg-input/80 peer-checked:bg-primary relative';
|
|
32
|
+
|
|
33
|
+
const TRACK_SIZES = {
|
|
34
|
+
default: 'h-[1.15rem] w-8',
|
|
35
|
+
sm: 'h-3.5 w-6',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
const THUMB_SIZES = {
|
|
39
|
+
default:
|
|
40
|
+
"after:size-4 after:translate-x-px peer-checked:after:translate-x-[calc(100%-2px)]",
|
|
41
|
+
sm: "after:size-3 after:translate-x-px peer-checked:after:translate-x-[calc(100%-1px)]",
|
|
42
|
+
} as const;
|
|
43
|
+
|
|
44
|
+
const THUMB_BASE =
|
|
45
|
+
'after:pointer-events-none after:absolute after:left-0 after:rounded-full after:bg-background after:transition-transform peer-checked:after:bg-primary-foreground dark:after:bg-foreground';
|
|
46
|
+
|
|
47
|
+
export type SwitchSize = keyof typeof TRACK_SIZES;
|
|
48
|
+
|
|
49
|
+
export function switchTrackClass(opts: { size?: SwitchSize } = {}): string {
|
|
50
|
+
const size = opts.size ?? 'default';
|
|
51
|
+
return cn(TRACK_BASE, TRACK_SIZES[size], THUMB_BASE, THUMB_SIZES[size]);
|
|
52
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table: semantic data table with shadcn styling. Tier-1 class helpers;
|
|
3
|
+
* compose with native `<table>`, `<thead>`, `<tbody>`, `<tfoot>`, `<tr>`,
|
|
4
|
+
* `<th>`, `<td>`, `<caption>`. Native semantics + accessibility tree
|
|
5
|
+
* work out of the box.
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* Table container (scroll wrapper) → tableContainerClass()
|
|
9
|
+
* Table → tableClass()
|
|
10
|
+
* TableHeader / TableBody / TableFooter
|
|
11
|
+
* → tableHeaderClass() / tableBodyClass() / tableFooterClass()
|
|
12
|
+
* TableRow → tableRowClass()
|
|
13
|
+
* TableHead / TableCell / TableCaption
|
|
14
|
+
* → tableHeadClass() / tableCellClass() / tableCaptionClass()
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* <div class=${tableContainerClass()}>
|
|
18
|
+
* <table class=${tableClass()}>
|
|
19
|
+
* <thead class=${tableHeaderClass()}>
|
|
20
|
+
* <tr class=${tableRowClass()}>
|
|
21
|
+
* <th class=${tableHeadClass()}>Name</th>
|
|
22
|
+
* <th class=${tableHeadClass()}>Status</th>
|
|
23
|
+
* </tr>
|
|
24
|
+
* </thead>
|
|
25
|
+
* <tbody class=${tableBodyClass()}>
|
|
26
|
+
* <tr class=${tableRowClass()}>
|
|
27
|
+
* <td class=${tableCellClass()}>Vivek</td>
|
|
28
|
+
* <td class=${tableCellClass()}>Active</td>
|
|
29
|
+
* </tr>
|
|
30
|
+
* </tbody>
|
|
31
|
+
* <caption class=${tableCaptionClass()}>Users</caption>
|
|
32
|
+
* </table>
|
|
33
|
+
* </div>
|
|
34
|
+
*
|
|
35
|
+
* Design tokens used: --muted, --muted-foreground, --foreground.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export const tableContainerClass = (): string => 'relative w-full overflow-x-auto';
|
|
39
|
+
|
|
40
|
+
export const tableClass = (): string => 'w-full caption-bottom text-sm';
|
|
41
|
+
|
|
42
|
+
export const tableHeaderClass = (): string => '[&_tr]:border-b';
|
|
43
|
+
|
|
44
|
+
export const tableBodyClass = (): string => '[&_tr:last-child]:border-0';
|
|
45
|
+
|
|
46
|
+
export const tableFooterClass = (): string =>
|
|
47
|
+
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0';
|
|
48
|
+
|
|
49
|
+
export const tableRowClass = (): string =>
|
|
50
|
+
'border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted';
|
|
51
|
+
|
|
52
|
+
export const tableHeadClass = (): string =>
|
|
53
|
+
'h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]';
|
|
54
|
+
|
|
55
|
+
export const tableCellClass = (): string =>
|
|
56
|
+
'p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]';
|
|
57
|
+
|
|
58
|
+
export const tableCaptionClass = (): string => 'mt-4 text-sm text-muted-foreground';
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tabs: sectioned content with keyboard navigation. Tier-2. The parent
|
|
3
|
+
* <ui-tabs> owns `value` + `orientation`; triggers and panels read from
|
|
4
|
+
* it via `closest('ui-tabs')` and re-render when value changes.
|
|
5
|
+
*
|
|
6
|
+
* APG pattern: https://www.w3.org/WAI/ARIA/apg/patterns/tabs/
|
|
7
|
+
*
|
|
8
|
+
* shadcn parity:
|
|
9
|
+
* Tabs → <ui-tabs value orientation>
|
|
10
|
+
* TabsList → <ui-tabs-list variant>
|
|
11
|
+
* TabsTrigger → <ui-tabs-trigger value>
|
|
12
|
+
* TabsContent → <ui-tabs-content value>
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* <ui-tabs value="account">
|
|
16
|
+
* <ui-tabs-list>
|
|
17
|
+
* <ui-tabs-trigger value="account">Account</ui-tabs-trigger>
|
|
18
|
+
* <ui-tabs-trigger value="password">Password</ui-tabs-trigger>
|
|
19
|
+
* </ui-tabs-list>
|
|
20
|
+
* <ui-tabs-content value="account">…</ui-tabs-content>
|
|
21
|
+
* <ui-tabs-content value="password">…</ui-tabs-content>
|
|
22
|
+
* </ui-tabs>
|
|
23
|
+
*
|
|
24
|
+
* Attributes on <ui-tabs>:
|
|
25
|
+
* `value`: string (reflected, controlled). Active tab value.
|
|
26
|
+
* `orientation`: "horizontal" (default) | "vertical".
|
|
27
|
+
*
|
|
28
|
+
* Attributes on <ui-tabs-list>:
|
|
29
|
+
* `variant`: "default" (default, pill-on-muted) | "underline".
|
|
30
|
+
*
|
|
31
|
+
* Attributes on <ui-tabs-trigger> / <ui-tabs-content>:
|
|
32
|
+
* `value`: string. Identifier this trigger / panel pair shares.
|
|
33
|
+
*
|
|
34
|
+
* Events:
|
|
35
|
+
* `ui-value-change` on <ui-tabs>: `{ detail: { value } }` after a change.
|
|
36
|
+
*
|
|
37
|
+
* Keyboard (on focused trigger):
|
|
38
|
+
* ArrowRight / ArrowLeft next / previous (horizontal)
|
|
39
|
+
* ArrowDown / ArrowUp next / previous (vertical)
|
|
40
|
+
* Home / End first / last
|
|
41
|
+
* Enter / Space activate (native button activation)
|
|
42
|
+
*
|
|
43
|
+
* Design tokens used: --muted, --muted-foreground, --foreground, --background,
|
|
44
|
+
* --input, --ring.
|
|
45
|
+
*/
|
|
46
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
47
|
+
import { cn } from '../lib/utils.ts';
|
|
48
|
+
|
|
49
|
+
// --------------------------------------------------------------------------
|
|
50
|
+
// Class helpers
|
|
51
|
+
// --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
const TABS_BASE = 'group/tabs flex gap-2 data-[orientation=horizontal]:flex-col';
|
|
54
|
+
|
|
55
|
+
const TABS_LIST_BASE =
|
|
56
|
+
'group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=underline]:rounded-none';
|
|
57
|
+
|
|
58
|
+
const TABS_LIST_VARIANTS = {
|
|
59
|
+
default: 'bg-muted',
|
|
60
|
+
underline: 'gap-1 bg-transparent',
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
export type TabsListVariant = keyof typeof TABS_LIST_VARIANTS;
|
|
64
|
+
|
|
65
|
+
/** Optional helper exposing the tabs-list class for advanced overrides. */
|
|
66
|
+
export function tabsListClass(opts: { variant?: TabsListVariant } = {}): string {
|
|
67
|
+
const variant = opts.variant ?? 'default';
|
|
68
|
+
return cn(TABS_LIST_BASE, TABS_LIST_VARIANTS[variant]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The trigger is rendered as a native <button role="tab">, which gives
|
|
72
|
+
// us cursor-pointer + Enter/Space activation + focus for free. The class
|
|
73
|
+
// here is essentially shadcn's tabs.tsx trigger output.
|
|
74
|
+
const TABS_TRIGGER_CLASS = [
|
|
75
|
+
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer select-none items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=underline]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
76
|
+
// These mirror shadcn's tabs.tsx (modulo variant=line → variant=underline).
|
|
77
|
+
// Light-mode active state uses bg-background + shadow only; dark mode
|
|
78
|
+
// additionally borders. Adding light-mode border made the underline
|
|
79
|
+
// variant look like outline and made default look like two buttons.
|
|
80
|
+
'group-data-[variant=underline]/tabs-list:bg-transparent group-data-[variant=underline]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=underline]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=underline]/tabs-list:data-[state=active]:bg-transparent',
|
|
81
|
+
'data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground',
|
|
82
|
+
'after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=underline]/tabs-list:data-[state=active]:after:opacity-100',
|
|
83
|
+
].join(' ');
|
|
84
|
+
|
|
85
|
+
const TABS_CONTENT_CLASS = 'flex-1 outline-none';
|
|
86
|
+
|
|
87
|
+
// --------------------------------------------------------------------------
|
|
88
|
+
// <ui-tabs> owns active `value` + `orientation`. Children read its state
|
|
89
|
+
// via closest('ui-tabs'); when value changes, the parent fires a
|
|
90
|
+
// `tabs-value-change-internal` event that descendants listen for via
|
|
91
|
+
// the bubbling event on their own host. Each descendant then calls
|
|
92
|
+
// requestUpdate() to re-render against the new parent value.
|
|
93
|
+
// --------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
export class UiTabs extends WebComponent {
|
|
96
|
+
static properties = {
|
|
97
|
+
value: { type: String, reflect: true },
|
|
98
|
+
orientation: { type: String, reflect: true },
|
|
99
|
+
};
|
|
100
|
+
declare value: string;
|
|
101
|
+
declare orientation: 'horizontal' | 'vertical';
|
|
102
|
+
|
|
103
|
+
constructor() {
|
|
104
|
+
super();
|
|
105
|
+
this.value = '';
|
|
106
|
+
this.orientation = 'horizontal';
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
render() {
|
|
110
|
+
return html`<div
|
|
111
|
+
data-slot="tabs"
|
|
112
|
+
data-orientation=${this.orientation}
|
|
113
|
+
class=${TABS_BASE}
|
|
114
|
+
><slot></slot></div>`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
118
|
+
if (!changedProperties.has('value')) return;
|
|
119
|
+
const prev = (changedProperties.get('value') ?? '') as string;
|
|
120
|
+
// Skip the initial undefined -> '' (or '' -> '') no-op set; only
|
|
121
|
+
// dispatch + broadcast on real value transitions.
|
|
122
|
+
if (prev === '' && this.value === '') return;
|
|
123
|
+
queueMicrotask(() => {
|
|
124
|
+
this.dispatchEvent(
|
|
125
|
+
new CustomEvent('ui-value-change', { detail: { value: this.value }, bubbles: true }),
|
|
126
|
+
);
|
|
127
|
+
this._broadcast();
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
_broadcast(): void {
|
|
132
|
+
this.querySelectorAll<WebComponent>(
|
|
133
|
+
'ui-tabs-trigger, ui-tabs-content',
|
|
134
|
+
).forEach((el) => el.requestUpdate?.());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
UiTabs.register('ui-tabs');
|
|
138
|
+
|
|
139
|
+
// --------------------------------------------------------------------------
|
|
140
|
+
// <ui-tabs-list>
|
|
141
|
+
// --------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export class UiTabsList extends WebComponent {
|
|
144
|
+
static properties = {
|
|
145
|
+
variant: { type: String, reflect: true },
|
|
146
|
+
};
|
|
147
|
+
declare variant: TabsListVariant;
|
|
148
|
+
|
|
149
|
+
constructor() {
|
|
150
|
+
super();
|
|
151
|
+
this.variant = 'default';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render() {
|
|
155
|
+
return html`<div
|
|
156
|
+
data-slot="tabs-list"
|
|
157
|
+
role="tablist"
|
|
158
|
+
data-variant=${this.variant}
|
|
159
|
+
class=${tabsListClass({ variant: this.variant })}
|
|
160
|
+
><slot></slot></div>`;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
UiTabsList.register('ui-tabs-list');
|
|
164
|
+
|
|
165
|
+
// --------------------------------------------------------------------------
|
|
166
|
+
// <ui-tabs-trigger value="...">
|
|
167
|
+
// --------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
export class UiTabsTrigger extends WebComponent {
|
|
170
|
+
static properties = {
|
|
171
|
+
value: { type: String, reflect: true },
|
|
172
|
+
};
|
|
173
|
+
declare value: string;
|
|
174
|
+
|
|
175
|
+
constructor() {
|
|
176
|
+
super();
|
|
177
|
+
this.value = '';
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// render() runs server-side too; linkedom doesn't implement closest()
|
|
181
|
+
// on custom elements. Return null during SSR; the client re-renders
|
|
182
|
+
// with the parent reference after hydration.
|
|
183
|
+
get _tabs(): UiTabs | null {
|
|
184
|
+
if (typeof this.closest !== 'function') return null;
|
|
185
|
+
return this.closest('ui-tabs') as UiTabs | null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
render() {
|
|
189
|
+
const tabs = this._tabs;
|
|
190
|
+
const active = !!tabs && tabs.value === this.value && this.value !== '';
|
|
191
|
+
return html`<button
|
|
192
|
+
type="button"
|
|
193
|
+
role="tab"
|
|
194
|
+
data-slot="tabs-trigger"
|
|
195
|
+
data-state=${active ? 'active' : 'inactive'}
|
|
196
|
+
aria-selected=${String(active)}
|
|
197
|
+
tabindex=${active ? '0' : '-1'}
|
|
198
|
+
class=${TABS_TRIGGER_CLASS}
|
|
199
|
+
@click=${this._onClick}
|
|
200
|
+
@keydown=${this._onKeyDown}
|
|
201
|
+
><slot></slot></button>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
_onClick = (): void => {
|
|
205
|
+
if (this.value) this._tabs?.setAttribute('value', this.value);
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
_onKeyDown = (e: KeyboardEvent): void => {
|
|
209
|
+
const tabs = this._tabs;
|
|
210
|
+
if (!tabs) return;
|
|
211
|
+
const orientation = tabs.orientation;
|
|
212
|
+
const triggers = Array.from(tabs.querySelectorAll<UiTabsTrigger>('ui-tabs-trigger'));
|
|
213
|
+
const idx = triggers.indexOf(this);
|
|
214
|
+
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
215
|
+
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
216
|
+
|
|
217
|
+
let target: UiTabsTrigger | null = null;
|
|
218
|
+
if (e.key === nextKey) target = triggers[(idx + 1) % triggers.length] ?? null;
|
|
219
|
+
else if (e.key === prevKey) target = triggers[(idx - 1 + triggers.length) % triggers.length] ?? null;
|
|
220
|
+
else if (e.key === 'Home') target = triggers[0] ?? null;
|
|
221
|
+
else if (e.key === 'End') target = triggers[triggers.length - 1] ?? null;
|
|
222
|
+
// Enter/Space already handled natively by <button>; no preventDefault needed.
|
|
223
|
+
|
|
224
|
+
if (target) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
const v = target.value;
|
|
227
|
+
if (v) tabs.setAttribute('value', v);
|
|
228
|
+
target.focus();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
UiTabsTrigger.register('ui-tabs-trigger');
|
|
233
|
+
|
|
234
|
+
// --------------------------------------------------------------------------
|
|
235
|
+
// <ui-tabs-content value="...">
|
|
236
|
+
// --------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
export class UiTabsContent extends WebComponent {
|
|
239
|
+
static properties = {
|
|
240
|
+
value: { type: String, reflect: true },
|
|
241
|
+
};
|
|
242
|
+
declare value: string;
|
|
243
|
+
|
|
244
|
+
constructor() {
|
|
245
|
+
super();
|
|
246
|
+
this.value = '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get _tabs(): UiTabs | null {
|
|
250
|
+
if (typeof this.closest !== 'function') return null;
|
|
251
|
+
return this.closest('ui-tabs') as UiTabs | null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
render() {
|
|
255
|
+
const tabs = this._tabs;
|
|
256
|
+
const active = !!tabs && tabs.value === this.value && this.value !== '';
|
|
257
|
+
// The host needs to be hidden when inactive so its rendered <section>
|
|
258
|
+
// is removed from layout (light DOM has no :host CSS to scope this).
|
|
259
|
+
// Use the native `hidden` IDL property rather than imperative
|
|
260
|
+
// setAttribute, so it reads as a property assignment.
|
|
261
|
+
this.hidden = !active;
|
|
262
|
+
return html`<section
|
|
263
|
+
data-slot="tabs-content"
|
|
264
|
+
role="tabpanel"
|
|
265
|
+
tabindex="0"
|
|
266
|
+
data-state=${active ? 'active' : 'inactive'}
|
|
267
|
+
class=${TABS_CONTENT_CLASS}
|
|
268
|
+
><slot></slot></section>`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
UiTabsContent.register('ui-tabs-content');
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Textarea: styled native `<textarea>`. Tier-1 class helper. Real
|
|
3
|
+
* multi-line input: form submission, autosize via `field-sizing:
|
|
4
|
+
* content`, and native validation all work.
|
|
5
|
+
*
|
|
6
|
+
* shadcn parity:
|
|
7
|
+
* Textarea → textareaClass()
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <textarea class=${textareaClass()} name="message" rows="4" placeholder="Your message…">
|
|
11
|
+
* </textarea>
|
|
12
|
+
*
|
|
13
|
+
* Pair with `<label class=${labelClass()} for="...">` and wrap in
|
|
14
|
+
* `<div class=${fieldClass()}>` for the canonical field rhythm.
|
|
15
|
+
*
|
|
16
|
+
* Design tokens used: --input, --background, --muted-foreground, --ring,
|
|
17
|
+
* --destructive.
|
|
18
|
+
*/
|
|
19
|
+
import { cn } from '../lib/utils.ts';
|
|
20
|
+
|
|
21
|
+
const TEXTAREA_BASE =
|
|
22
|
+
'flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:aria-invalid:ring-destructive/40';
|
|
23
|
+
|
|
24
|
+
/** Compose Tailwind classes for a native `<textarea>`. */
|
|
25
|
+
export function textareaClass(): string {
|
|
26
|
+
return cn(TEXTAREA_BASE);
|
|
27
|
+
}
|