@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.
- 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 +607 -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,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover: floating panel anchored to a trigger button, built on the
|
|
3
|
+
* native HTML Popover API (`popover` attribute + `popovertarget`).
|
|
4
|
+
*
|
|
5
|
+
* Tier-1 (no custom element). The browser handles open / close state,
|
|
6
|
+
* top-layer rendering, light-dismiss on outside click (with
|
|
7
|
+
* `popover="auto"`), Escape-to-close, focus restoration to the invoker,
|
|
8
|
+
* and CSS Anchor Positioning's implicit anchor (a popover invoked via
|
|
9
|
+
* `popovertarget` is automatically anchored to its invoker, no
|
|
10
|
+
* `anchor-name` / `position-anchor` inline style needed for the common
|
|
11
|
+
* case). CSS Anchor Positioning ships in Chrome 125+, Safari 26+,
|
|
12
|
+
* Firefox 134+.
|
|
13
|
+
*
|
|
14
|
+
* shadcn parity:
|
|
15
|
+
* Popover → <button popovertarget="id"> + <div id="id" popover>
|
|
16
|
+
* PopoverContent → popoverContentClass({ side, align, sideOffset, alignOffset })
|
|
17
|
+
* PopoverHeader → popoverHeaderClass()
|
|
18
|
+
* PopoverTitle → popoverTitleClass()
|
|
19
|
+
* PopoverDescription → popoverDescriptionClass()
|
|
20
|
+
*
|
|
21
|
+
* Usage (single invoker, implicit anchor, zero inline style):
|
|
22
|
+
* <button popovertarget="filter" class=${buttonClass({ variant: 'outline' })}>Filter</button>
|
|
23
|
+
* <div id="filter" popover
|
|
24
|
+
* class=${popoverContentClass({ side: 'bottom', align: 'start', sideOffset: 4 })}>
|
|
25
|
+
* <div class=${popoverHeaderClass()}>
|
|
26
|
+
* <h3 class=${popoverTitleClass()}>Filter posts</h3>
|
|
27
|
+
* <p class=${popoverDescriptionClass()}>By tag and status.</p>
|
|
28
|
+
* </div>
|
|
29
|
+
* </div>
|
|
30
|
+
*
|
|
31
|
+
* <!-- Explicit anchor (multiple invokers / anchoring to a different element): -->
|
|
32
|
+
* <span style="anchor-name: --picker">@vivek</span>
|
|
33
|
+
* <button popovertarget="profile">Show</button>
|
|
34
|
+
* <div id="profile" popover style="position-anchor: --picker"
|
|
35
|
+
* class=${popoverContentClass({ side: 'bottom' })}>…</div>
|
|
36
|
+
*
|
|
37
|
+
* The `positionFloating` helper is also exported (used by the Tier-2
|
|
38
|
+
* tooltip / hover-card / dropdown-menu components for imperative
|
|
39
|
+
* positioning where CSS Anchor Positioning isn't enough). Migrated from
|
|
40
|
+
* the prior <ui-popover> custom element set.
|
|
41
|
+
*
|
|
42
|
+
* Design tokens used: --popover, --popover-foreground, --border.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
// --------------------------------------------------------------------------
|
|
46
|
+
// Class helpers
|
|
47
|
+
// --------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Popover content options, mirror shadcn's `<PopoverContent>` props.
|
|
51
|
+
* `side` and `align` map to CSS Anchor Positioning's `position-area`;
|
|
52
|
+
* `sideOffset` maps to a directional margin so the popover sits a few
|
|
53
|
+
* pixels off the anchor's edge.
|
|
54
|
+
*
|
|
55
|
+
* `sideOffset` is restricted to a discrete set so Tailwind 4's static
|
|
56
|
+
* scanner sees each emitted class literal. For arbitrary values,
|
|
57
|
+
* override the margin via inline style on the popover element.
|
|
58
|
+
*/
|
|
59
|
+
export type PopoverSideOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24;
|
|
60
|
+
export type PopoverAlignOffset = 0 | 2 | 4 | 6 | 8 | 12 | 16 | 20 | 24;
|
|
61
|
+
|
|
62
|
+
export interface PopoverContentOptions {
|
|
63
|
+
/** Which side of the trigger the popover appears on. Default 'bottom'. */
|
|
64
|
+
side?: PopoverSide;
|
|
65
|
+
/** Alignment along the chosen side. Default 'center'. */
|
|
66
|
+
align?: PopoverAlign;
|
|
67
|
+
/** Pixels between the trigger and the popover. Default 4 (shadcn default). */
|
|
68
|
+
sideOffset?: PopoverSideOffset;
|
|
69
|
+
/**
|
|
70
|
+
* Pixels offset along the align axis. Positive values push away from
|
|
71
|
+
* the aligned edge toward the opposite edge. No-op for align='center'.
|
|
72
|
+
* Default 0 (matches shadcn).
|
|
73
|
+
*/
|
|
74
|
+
alignOffset?: PopoverAlignOffset;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// position-area combinations baked as literal class strings so Tailwind 4's
|
|
78
|
+
// scanner generates CSS for every (side, align) pair the helper can return.
|
|
79
|
+
// Underscores become spaces in arbitrary CSS values.
|
|
80
|
+
const POSITION_AREA_CLASS: Record<string, string> = {
|
|
81
|
+
'top-start': '[position-area:top_span-right]',
|
|
82
|
+
'top-center': '[position-area:top]',
|
|
83
|
+
'top-end': '[position-area:top_span-left]',
|
|
84
|
+
'bottom-start': '[position-area:bottom_span-right]',
|
|
85
|
+
'bottom-center': '[position-area:bottom]',
|
|
86
|
+
'bottom-end': '[position-area:bottom_span-left]',
|
|
87
|
+
'left-start': '[position-area:left_span-bottom]',
|
|
88
|
+
'left-center': '[position-area:left]',
|
|
89
|
+
'left-end': '[position-area:left_span-top]',
|
|
90
|
+
'right-start': '[position-area:right_span-bottom]',
|
|
91
|
+
'right-center': '[position-area:right]',
|
|
92
|
+
'right-end': '[position-area:right_span-top]',
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Align-axis translate classes. For align='start', positive offset moves
|
|
96
|
+
// the popover AWAY from the start edge (toward the end edge); align='end'
|
|
97
|
+
// reverses. align='center' is a no-op. The axis is perpendicular to the
|
|
98
|
+
// side: top/bottom sides translate X; left/right sides translate Y.
|
|
99
|
+
// All 36 (4 axis-direction combos × 9 offset values) appear literally so
|
|
100
|
+
// Tailwind 4 generates each class.
|
|
101
|
+
const ALIGN_OFFSET_CLASS: Record<string, Record<PopoverAlignOffset, string>> = {
|
|
102
|
+
'horizontal-start': {
|
|
103
|
+
0: 'translate-x-[0px]', 2: 'translate-x-[2px]', 4: 'translate-x-[4px]',
|
|
104
|
+
6: 'translate-x-[6px]', 8: 'translate-x-[8px]', 12: 'translate-x-[12px]',
|
|
105
|
+
16: 'translate-x-[16px]', 20: 'translate-x-[20px]', 24: 'translate-x-[24px]',
|
|
106
|
+
},
|
|
107
|
+
'horizontal-end': {
|
|
108
|
+
0: 'translate-x-[0px]', 2: 'translate-x-[-2px]', 4: 'translate-x-[-4px]',
|
|
109
|
+
6: 'translate-x-[-6px]', 8: 'translate-x-[-8px]', 12: 'translate-x-[-12px]',
|
|
110
|
+
16: 'translate-x-[-16px]', 20: 'translate-x-[-20px]', 24: 'translate-x-[-24px]',
|
|
111
|
+
},
|
|
112
|
+
'vertical-start': {
|
|
113
|
+
0: 'translate-y-[0px]', 2: 'translate-y-[2px]', 4: 'translate-y-[4px]',
|
|
114
|
+
6: 'translate-y-[6px]', 8: 'translate-y-[8px]', 12: 'translate-y-[12px]',
|
|
115
|
+
16: 'translate-y-[16px]', 20: 'translate-y-[20px]', 24: 'translate-y-[24px]',
|
|
116
|
+
},
|
|
117
|
+
'vertical-end': {
|
|
118
|
+
0: 'translate-y-[0px]', 2: 'translate-y-[-2px]', 4: 'translate-y-[-4px]',
|
|
119
|
+
6: 'translate-y-[-6px]', 8: 'translate-y-[-8px]', 12: 'translate-y-[-12px]',
|
|
120
|
+
16: 'translate-y-[-16px]', 20: 'translate-y-[-20px]', 24: 'translate-y-[-24px]',
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Per-side offset margin classes. Side 'bottom' wants margin-top, etc.
|
|
125
|
+
// Each value of PopoverSideOffset appears literally for Tailwind's scanner.
|
|
126
|
+
const MARGIN_OFFSET_CLASS: Record<PopoverSide, Record<PopoverSideOffset, string>> = {
|
|
127
|
+
top: {
|
|
128
|
+
0: '[margin-bottom:0px]', 2: '[margin-bottom:2px]', 4: '[margin-bottom:4px]',
|
|
129
|
+
6: '[margin-bottom:6px]', 8: '[margin-bottom:8px]', 12: '[margin-bottom:12px]',
|
|
130
|
+
16: '[margin-bottom:16px]', 20: '[margin-bottom:20px]', 24: '[margin-bottom:24px]',
|
|
131
|
+
},
|
|
132
|
+
bottom: {
|
|
133
|
+
0: '[margin-top:0px]', 2: '[margin-top:2px]', 4: '[margin-top:4px]',
|
|
134
|
+
6: '[margin-top:6px]', 8: '[margin-top:8px]', 12: '[margin-top:12px]',
|
|
135
|
+
16: '[margin-top:16px]', 20: '[margin-top:20px]', 24: '[margin-top:24px]',
|
|
136
|
+
},
|
|
137
|
+
left: {
|
|
138
|
+
0: '[margin-right:0px]', 2: '[margin-right:2px]', 4: '[margin-right:4px]',
|
|
139
|
+
6: '[margin-right:6px]', 8: '[margin-right:8px]', 12: '[margin-right:12px]',
|
|
140
|
+
16: '[margin-right:16px]', 20: '[margin-right:20px]', 24: '[margin-right:24px]',
|
|
141
|
+
},
|
|
142
|
+
right: {
|
|
143
|
+
0: '[margin-left:0px]', 2: '[margin-left:2px]', 4: '[margin-left:4px]',
|
|
144
|
+
6: '[margin-left:6px]', 8: '[margin-left:8px]', 12: '[margin-left:12px]',
|
|
145
|
+
16: '[margin-left:16px]', 20: '[margin-left:20px]', 24: '[margin-left:24px]',
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Popover content class. `side` and `align` cover the shadcn
|
|
151
|
+
* `<PopoverContent>` placement props; `sideOffset` sets the gap to
|
|
152
|
+
* the anchor. The visual layer (border, bg, padding, shadow) is
|
|
153
|
+
* fixed to match shadcn's default; width is opinionated at `w-72`
|
|
154
|
+
* (override at the call site).
|
|
155
|
+
*
|
|
156
|
+
* `m-0` clears the UA `margin: auto` so anchor positioning isn't
|
|
157
|
+
* fighting auto-centering, then a single directional margin sets the
|
|
158
|
+
* sideOffset gap.
|
|
159
|
+
*/
|
|
160
|
+
export function popoverContentClass(opts: PopoverContentOptions = {}): string {
|
|
161
|
+
const side = opts.side ?? 'bottom';
|
|
162
|
+
const align = opts.align ?? 'center';
|
|
163
|
+
const sideOffset = opts.sideOffset ?? 4;
|
|
164
|
+
const alignOffset = opts.alignOffset ?? 0;
|
|
165
|
+
// align='center' has no align axis to offset along, skip the translate.
|
|
166
|
+
let alignClass = '';
|
|
167
|
+
if (align !== 'center' && alignOffset !== 0) {
|
|
168
|
+
const axis = side === 'top' || side === 'bottom' ? 'horizontal' : 'vertical';
|
|
169
|
+
alignClass = ALIGN_OFFSET_CLASS[`${axis}-${align}`][alignOffset];
|
|
170
|
+
}
|
|
171
|
+
return [
|
|
172
|
+
'w-72 m-0 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden',
|
|
173
|
+
POSITION_AREA_CLASS[`${side}-${align}`],
|
|
174
|
+
MARGIN_OFFSET_CLASS[side][sideOffset],
|
|
175
|
+
alignClass,
|
|
176
|
+
].filter(Boolean).join(' ');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export const popoverHeaderClass = (): string => 'flex flex-col gap-1 text-sm';
|
|
180
|
+
export const popoverTitleClass = (): string => 'font-medium';
|
|
181
|
+
export const popoverDescriptionClass = (): string => 'text-muted-foreground';
|
|
182
|
+
|
|
183
|
+
// --------------------------------------------------------------------------
|
|
184
|
+
// Imperative positioning helper. Still exported for the tier-2 tooltip /
|
|
185
|
+
// hover-card / dropdown-menu components, which need exact placement before
|
|
186
|
+
// CSS anchor positioning is universally available.
|
|
187
|
+
// --------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
export type PopoverSide = 'top' | 'bottom' | 'left' | 'right';
|
|
190
|
+
export type PopoverAlign = 'start' | 'center' | 'end';
|
|
191
|
+
|
|
192
|
+
export function positionFloating(
|
|
193
|
+
trigger: HTMLElement,
|
|
194
|
+
content: HTMLElement,
|
|
195
|
+
opts: {
|
|
196
|
+
side?: PopoverSide;
|
|
197
|
+
align?: PopoverAlign;
|
|
198
|
+
sideOffset?: number;
|
|
199
|
+
alignOffset?: number;
|
|
200
|
+
} = {},
|
|
201
|
+
): void {
|
|
202
|
+
const side = opts.side ?? 'bottom';
|
|
203
|
+
const align = opts.align ?? 'center';
|
|
204
|
+
const sideOffset = opts.sideOffset ?? 4;
|
|
205
|
+
const alignOffset = opts.alignOffset ?? 0;
|
|
206
|
+
const tr = trigger.getBoundingClientRect();
|
|
207
|
+
const cr = content.getBoundingClientRect();
|
|
208
|
+
const vw = window.innerWidth;
|
|
209
|
+
const vh = window.innerHeight;
|
|
210
|
+
|
|
211
|
+
let top = 0;
|
|
212
|
+
let left = 0;
|
|
213
|
+
let actualSide = side;
|
|
214
|
+
|
|
215
|
+
const fitsBottom = tr.bottom + sideOffset + cr.height <= vh;
|
|
216
|
+
const fitsTop = tr.top - sideOffset - cr.height >= 0;
|
|
217
|
+
const fitsRight = tr.right + sideOffset + cr.width <= vw;
|
|
218
|
+
const fitsLeft = tr.left - sideOffset - cr.width >= 0;
|
|
219
|
+
|
|
220
|
+
if (side === 'bottom' && !fitsBottom && fitsTop) actualSide = 'top';
|
|
221
|
+
else if (side === 'top' && !fitsTop && fitsBottom) actualSide = 'bottom';
|
|
222
|
+
else if (side === 'right' && !fitsRight && fitsLeft) actualSide = 'left';
|
|
223
|
+
else if (side === 'left' && !fitsLeft && fitsRight) actualSide = 'right';
|
|
224
|
+
|
|
225
|
+
if (actualSide === 'bottom') top = tr.bottom + sideOffset;
|
|
226
|
+
else if (actualSide === 'top') top = tr.top - sideOffset - cr.height;
|
|
227
|
+
else if (actualSide === 'right' || actualSide === 'left') {
|
|
228
|
+
if (align === 'start') top = tr.top;
|
|
229
|
+
else if (align === 'end') top = tr.bottom - cr.height;
|
|
230
|
+
else top = tr.top + (tr.height - cr.height) / 2;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (actualSide === 'right') left = tr.right + sideOffset;
|
|
234
|
+
else if (actualSide === 'left') left = tr.left - sideOffset - cr.width;
|
|
235
|
+
else {
|
|
236
|
+
if (align === 'start') left = tr.left;
|
|
237
|
+
else if (align === 'end') left = tr.right - cr.width;
|
|
238
|
+
else left = tr.left + (tr.width - cr.width) / 2;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// alignOffset shifts the popover along the align axis. For align='start',
|
|
242
|
+
// positive shifts AWAY from the start edge; align='end' reverses; center
|
|
243
|
+
// is a no-op. Axis is perpendicular to the chosen side.
|
|
244
|
+
if (align !== 'center' && alignOffset !== 0) {
|
|
245
|
+
const dir = align === 'start' ? 1 : -1;
|
|
246
|
+
if (actualSide === 'top' || actualSide === 'bottom') {
|
|
247
|
+
left += dir * alignOffset;
|
|
248
|
+
} else {
|
|
249
|
+
top += dir * alignOffset;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
left = Math.max(8, Math.min(left, vw - cr.width - 8));
|
|
254
|
+
top = Math.max(8, Math.min(top, vh - cr.height - 8));
|
|
255
|
+
|
|
256
|
+
content.style.top = `${top}px`;
|
|
257
|
+
content.style.left = `${left}px`;
|
|
258
|
+
content.setAttribute('data-side', actualSide);
|
|
259
|
+
content.setAttribute('data-align', align);
|
|
260
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Progress: progress bar. Tier-1 class helper over the native
|
|
3
|
+
* `<progress>` element. The native element supplies the `progressbar`
|
|
4
|
+
* role and `aria-valuenow` automatically from its `value` / `max`
|
|
5
|
+
* attributes, so no JS, no custom element.
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* Progress → progressClass() (visual: 2px track + animated fill,
|
|
9
|
+
* styled via `::-webkit-progress-bar`,
|
|
10
|
+
* `::-webkit-progress-value`, and
|
|
11
|
+
* `::-moz-progress-bar`)
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* <progress value="42" max="100" class=${progressClass()}></progress>
|
|
15
|
+
*
|
|
16
|
+
* <!-- Indeterminate (no `value` attribute): -->
|
|
17
|
+
* <progress class=${progressClass()}></progress>
|
|
18
|
+
*
|
|
19
|
+
* Design tokens used: --primary.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Class for the native `<progress>` element. The track + fill colours
|
|
24
|
+
* come from Tailwind utilities. The browser handles the actual bar
|
|
25
|
+
* rendering through the `::-webkit-progress-value` and
|
|
26
|
+
* `::-moz-progress-bar` pseudo-elements. We expose them via the
|
|
27
|
+
* `[&::-webkit-progress-value]:bg-primary` and
|
|
28
|
+
* `[&::-moz-progress-bar]:bg-primary` Tailwind variants.
|
|
29
|
+
*
|
|
30
|
+
* Indeterminate state (no `value` attribute on the element) gets a
|
|
31
|
+
* pulse animation via `:indeterminate { animate-pulse }`.
|
|
32
|
+
*/
|
|
33
|
+
export const progressClass = (): string =>
|
|
34
|
+
[
|
|
35
|
+
// Reset native styling. WebKit / Blink draw a default 3D bar that we
|
|
36
|
+
// strip via appearance-none + classic clear of border/bg.
|
|
37
|
+
'block h-2 w-full overflow-hidden rounded-full',
|
|
38
|
+
'appearance-none border-0 bg-primary/20 [&::-webkit-progress-bar]:bg-primary/20',
|
|
39
|
+
// Bar fill: blink/webkit + firefox both via Tailwind 4's arbitrary-
|
|
40
|
+
// pseudo variant. Smooth animation on width change matches shadcn.
|
|
41
|
+
"[&::-webkit-progress-value]:bg-primary [&::-webkit-progress-value]:transition-all",
|
|
42
|
+
"[&::-moz-progress-bar]:bg-primary",
|
|
43
|
+
// Indeterminate state animates the track itself (no value -> no bar
|
|
44
|
+
// to color, so we pulse the bg).
|
|
45
|
+
'indeterminate:animate-pulse',
|
|
46
|
+
].join(' ');
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadioGroup: group of native radio inputs. Tier-1 class helpers;
|
|
3
|
+
* compose with native `<input type="radio" name="...">` sharing a name.
|
|
4
|
+
* The browser handles keyboard nav (Arrow keys), single-selection, and
|
|
5
|
+
* form submission. No JS.
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* RadioGroup (orientation: horizontal | vertical)
|
|
9
|
+
* → radioGroupClass({ orientation }) on a <div role="radiogroup">
|
|
10
|
+
* RadioGroupItem → radioClass() on a native <input type="radio">
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* <div role="radiogroup" class=${radioGroupClass()}>
|
|
14
|
+
* <div class="flex items-center gap-2">
|
|
15
|
+
* <input type="radio" name="plan" value="basic" id="plan-basic" class=${radioClass()}>
|
|
16
|
+
* <label class=${labelClass()} for="plan-basic">Basic</label>
|
|
17
|
+
* </div>
|
|
18
|
+
* <div class="flex items-center gap-2">
|
|
19
|
+
* <input type="radio" name="plan" value="pro" id="plan-pro" class=${radioClass()}>
|
|
20
|
+
* <label class=${labelClass()} for="plan-pro">Pro</label>
|
|
21
|
+
* </div>
|
|
22
|
+
* </div>
|
|
23
|
+
*
|
|
24
|
+
* Design tokens used: --input, --primary, --ring, --destructive.
|
|
25
|
+
*/
|
|
26
|
+
import { cn } from '../lib/utils.ts';
|
|
27
|
+
|
|
28
|
+
// Two SVGs, one per theme. The dot needs to contrast with the radio's
|
|
29
|
+
// inner surface: and that surface flips between themes:
|
|
30
|
+
//
|
|
31
|
+
// light: input bg is `bg-transparent` (so the page bg shows through;
|
|
32
|
+
// page bg is near-white) → dot must be DARK to be visible.
|
|
33
|
+
// dark : input bg is `dark:bg-input/30` (a translucent white over the
|
|
34
|
+
// near-black page bg; resolves to near-black) → dot must be
|
|
35
|
+
// LIGHT to be visible.
|
|
36
|
+
//
|
|
37
|
+
// Hardcoding a single dark-coloured dot (the original implementation)
|
|
38
|
+
// painted invisibly on the near-black dark-mode surface, making the
|
|
39
|
+
// :checked state indistinguishable from unchecked. Same pattern the
|
|
40
|
+
// checkbox fix uses (CHECKMARK_LIGHT / CHECKMARK_DARK): `currentColor`
|
|
41
|
+
// in a data:url SVG used as background-image does not inherit from the
|
|
42
|
+
// host element, so we ship two SVGs and toggle them via a theme
|
|
43
|
+
// selector.
|
|
44
|
+
const DOT_LIGHT =
|
|
45
|
+
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><circle cx='10' cy='10' r='5' fill='oklch(0.205 0 0)'/></svg>\")";
|
|
46
|
+
const DOT_DARK =
|
|
47
|
+
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><circle cx='10' cy='10' r='5' fill='oklch(0.985 0 0)'/></svg>\")";
|
|
48
|
+
|
|
49
|
+
const RADIO_CLASS =
|
|
50
|
+
'aspect-square size-4 shrink-0 appearance-none rounded-full border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none 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 checked:border-primary checked:bg-no-repeat checked:bg-center dark:bg-input/30 dark:aria-invalid:ring-destructive/40';
|
|
51
|
+
|
|
52
|
+
// Three sibling rule blocks for theme selection: mirrors the
|
|
53
|
+
// checkbox.ts pattern in this same registry:
|
|
54
|
+
// - prefers-color-scheme: dark (OS preference, gated by
|
|
55
|
+
// :not([data-theme='light']):not(.light) so an explicit-light
|
|
56
|
+
// toggle still wins over the OS)
|
|
57
|
+
// - :root[data-theme='dark'] (explicit data-attribute toggle)
|
|
58
|
+
// - :root.dark (explicit class toggle, shadcn convention)
|
|
59
|
+
const STYLES = `
|
|
60
|
+
input[type="radio"][data-slot="radio"]:checked {
|
|
61
|
+
background-image: ${DOT_LIGHT};
|
|
62
|
+
background-size: 100%;
|
|
63
|
+
}
|
|
64
|
+
@media (prefers-color-scheme: dark) {
|
|
65
|
+
:root:not([data-theme='light']):not(.light) input[type="radio"][data-slot="radio"]:checked {
|
|
66
|
+
background-image: ${DOT_DARK};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
:root[data-theme='dark'] input[type="radio"][data-slot="radio"]:checked,
|
|
70
|
+
:root.dark input[type="radio"][data-slot="radio"]:checked {
|
|
71
|
+
background-image: ${DOT_DARK};
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
75
|
+
let installed = false;
|
|
76
|
+
export function installRadioStyles(): void {
|
|
77
|
+
if (installed || typeof document === 'undefined') return;
|
|
78
|
+
if (document.getElementById('ui-radio-styles')) {
|
|
79
|
+
installed = true;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const style = document.createElement('style');
|
|
83
|
+
style.id = 'ui-radio-styles';
|
|
84
|
+
style.textContent = STYLES;
|
|
85
|
+
document.head.appendChild(style);
|
|
86
|
+
installed = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (typeof document !== 'undefined') installRadioStyles();
|
|
90
|
+
|
|
91
|
+
/** Tailwind classes for a `<div role="radiogroup">` container. */
|
|
92
|
+
export type RadioGroupOrientation = 'vertical' | 'horizontal';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Container for a group of native <input type="radio"> elements.
|
|
96
|
+
*
|
|
97
|
+
* Vertical (default): stacked column, gap-3. Matches Radix
|
|
98
|
+
* RadioGroup.Root's default + the most common shadcn snippet.
|
|
99
|
+
*
|
|
100
|
+
* Horizontal: flex row that wraps. Picks `flex flex-wrap gap-x-6
|
|
101
|
+
* gap-y-3` so multi-line wraps still have vertical breathing room.
|
|
102
|
+
*/
|
|
103
|
+
export function radioGroupClass(opts: { orientation?: RadioGroupOrientation } = {}): string {
|
|
104
|
+
const orientation = opts.orientation ?? 'vertical';
|
|
105
|
+
return orientation === 'horizontal'
|
|
106
|
+
? 'flex flex-wrap gap-x-6 gap-y-3'
|
|
107
|
+
: 'grid gap-3';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Tailwind classes for a styled `<input type="radio">`. Add `data-slot="radio"` for the indicator dot. */
|
|
111
|
+
export function radioClass(): string {
|
|
112
|
+
return cn(RADIO_CLASS);
|
|
113
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Separator: horizontal or vertical divider. Tier-1 class helper. For
|
|
3
|
+
* accessibility, set `role="separator"` + `aria-orientation` on the
|
|
4
|
+
* element (or `role="none"` for purely decorative use).
|
|
5
|
+
*
|
|
6
|
+
* shadcn parity:
|
|
7
|
+
* Separator (orientation: horizontal | vertical, decorative: bool)
|
|
8
|
+
* → separatorClass({ orientation }) + role + data-orientation
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* <div role="none" class=${separatorClass()} data-orientation="horizontal"></div>
|
|
12
|
+
* <div role="separator" aria-orientation="vertical"
|
|
13
|
+
* class=${separatorClass({ orientation: 'vertical' })}
|
|
14
|
+
* data-orientation="vertical"></div>
|
|
15
|
+
*
|
|
16
|
+
* Design tokens used: --border.
|
|
17
|
+
*/
|
|
18
|
+
import { cn } from '../lib/utils.ts';
|
|
19
|
+
|
|
20
|
+
const BASE =
|
|
21
|
+
'shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px';
|
|
22
|
+
|
|
23
|
+
export type SeparatorOrientation = 'horizontal' | 'vertical';
|
|
24
|
+
|
|
25
|
+
export function separatorClass(_opts: { orientation?: SeparatorOrientation } = {}): string {
|
|
26
|
+
// Orientation is driven by the `data-orientation` attribute on the element,
|
|
27
|
+
// not by class variants: matches shadcn. The opts arg is reserved for
|
|
28
|
+
// future variants (e.g. dashed) and to keep the signature stable.
|
|
29
|
+
return cn(BASE);
|
|
30
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skeleton: loading placeholder. Tier-1 class helper. Sizing comes from
|
|
3
|
+
* caller-supplied utilities; `skeletonClass()` only provides the
|
|
4
|
+
* animation + base look.
|
|
5
|
+
*
|
|
6
|
+
* shadcn parity:
|
|
7
|
+
* Skeleton → skeletonClass() (visual: animated rounded muted block)
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* <div class=${cn(skeletonClass(), 'h-4 w-32')}></div>
|
|
11
|
+
* <div class=${cn(skeletonClass(), 'h-12 w-12 rounded-full')}></div>
|
|
12
|
+
*
|
|
13
|
+
* Design tokens used: --accent.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const skeletonClass = (): string => 'animate-pulse rounded-md bg-accent';
|