@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,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ToggleGroup: group of toggles with single- or multiple-selection.
|
|
3
|
+
* Tier-2; items coordinate active state across the group, so this is
|
|
4
|
+
* a custom element (not a class helper). Items are styled via
|
|
5
|
+
* `toggleClass()` from `./toggle.ts` so the visual matches a single
|
|
6
|
+
* toggle exactly.
|
|
7
|
+
*
|
|
8
|
+
* shadcn parity:
|
|
9
|
+
* ToggleGroup (type: single | multiple)
|
|
10
|
+
* (variant: default | outline)
|
|
11
|
+
* (size: default | sm | lg)
|
|
12
|
+
* → <ui-toggle-group type variant size value>
|
|
13
|
+
* ToggleGroupItem → <ui-toggle-group-item value>
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* <ui-toggle-group type="single" value="bold">
|
|
17
|
+
* <ui-toggle-group-item value="bold" aria-label="Bold"><b>B</b></ui-toggle-group-item>
|
|
18
|
+
* <ui-toggle-group-item value="italic" aria-label="Italic"><i>I</i></ui-toggle-group-item>
|
|
19
|
+
* <ui-toggle-group-item value="underline" aria-label="Underline"><u>U</u></ui-toggle-group-item>
|
|
20
|
+
* </ui-toggle-group>
|
|
21
|
+
*
|
|
22
|
+
* <!-- Multiple selection (comma-separated value): -->
|
|
23
|
+
* <ui-toggle-group type="multiple" value="bold,italic">
|
|
24
|
+
* <ui-toggle-group-item value="bold">B</ui-toggle-group-item>
|
|
25
|
+
* <ui-toggle-group-item value="italic">I</ui-toggle-group-item>
|
|
26
|
+
* </ui-toggle-group>
|
|
27
|
+
*
|
|
28
|
+
* Attributes on <ui-toggle-group>:
|
|
29
|
+
* `type`: "single" (default) | "multiple".
|
|
30
|
+
* `value`: string. Selected value(s). Single: a single value;
|
|
31
|
+
* multiple: comma-separated values.
|
|
32
|
+
* `variant`: "default" (default) | "outline".
|
|
33
|
+
* `size`: "default" (default) | "sm" | "lg".
|
|
34
|
+
* `spacing`: "0" (default, joined corners) | "default" (gapped).
|
|
35
|
+
* `orientation`: "horizontal" (default) | "vertical".
|
|
36
|
+
*
|
|
37
|
+
* Attributes on <ui-toggle-group-item>:
|
|
38
|
+
* `value`: string. Identifier this item contributes when selected.
|
|
39
|
+
* `pressed`: boolean (reflected). Mirrors the group's selection for this item.
|
|
40
|
+
*
|
|
41
|
+
* Events:
|
|
42
|
+
* `ui-value-change` on <ui-toggle-group>: `{ detail: { value } }` after selection changes.
|
|
43
|
+
*
|
|
44
|
+
* Keyboard: Enter / Space toggles the focused item (native button activation).
|
|
45
|
+
*
|
|
46
|
+
* Design tokens used: inherited from toggleClass (--muted, --accent, --ring,
|
|
47
|
+
* --input, --destructive).
|
|
48
|
+
*/
|
|
49
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
50
|
+
import { cn } from '../lib/utils.ts';
|
|
51
|
+
import { toggleClass, type ToggleVariant, type ToggleSize } from './toggle.ts';
|
|
52
|
+
|
|
53
|
+
const ROOT_BASE =
|
|
54
|
+
'group/toggle-group flex w-fit items-center rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch';
|
|
55
|
+
|
|
56
|
+
const ITEM_EXTRA =
|
|
57
|
+
'w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10 data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l';
|
|
58
|
+
|
|
59
|
+
// --------------------------------------------------------------------------
|
|
60
|
+
// <ui-toggle-group>
|
|
61
|
+
// Renders a wrapping <div role="group"> with the @ui-toggle-item-click
|
|
62
|
+
// listener bound declaratively. Children project through the slot. Item
|
|
63
|
+
// state (data-state, aria-pressed) is reflected from updated() so the
|
|
64
|
+
// effect runs after the host's commit. A queueMicrotask defer inside
|
|
65
|
+
// gives the descendant <ui-toggle-group-item> components time to commit
|
|
66
|
+
// their own renders before we read / write their state.
|
|
67
|
+
// --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
export class UiToggleGroup extends WebComponent {
|
|
70
|
+
static properties = {
|
|
71
|
+
value: { type: String, reflect: true },
|
|
72
|
+
type: { type: String, reflect: true },
|
|
73
|
+
variant: { type: String, reflect: true },
|
|
74
|
+
size: { type: String, reflect: true },
|
|
75
|
+
spacing: { type: String, reflect: true },
|
|
76
|
+
orientation: { type: String, reflect: true },
|
|
77
|
+
};
|
|
78
|
+
declare value: string;
|
|
79
|
+
declare type: 'single' | 'multiple';
|
|
80
|
+
declare variant: ToggleVariant;
|
|
81
|
+
declare size: ToggleSize;
|
|
82
|
+
declare spacing: string;
|
|
83
|
+
declare orientation: 'horizontal' | 'vertical';
|
|
84
|
+
|
|
85
|
+
constructor() {
|
|
86
|
+
super();
|
|
87
|
+
this.value = '';
|
|
88
|
+
this.type = 'single';
|
|
89
|
+
this.variant = 'default';
|
|
90
|
+
this.size = 'default';
|
|
91
|
+
this.spacing = '0';
|
|
92
|
+
this.orientation = 'horizontal';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get _values(): Set<string> {
|
|
96
|
+
const raw = this.value ?? '';
|
|
97
|
+
return new Set(raw ? raw.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
render() {
|
|
101
|
+
const gap = this.spacing === '0' ? '' : 'gap-1';
|
|
102
|
+
return html`<div
|
|
103
|
+
data-slot="toggle-group"
|
|
104
|
+
role="group"
|
|
105
|
+
class=${cn(ROOT_BASE, gap)}
|
|
106
|
+
data-variant=${this.variant}
|
|
107
|
+
data-size=${this.size}
|
|
108
|
+
data-spacing=${this.spacing}
|
|
109
|
+
data-orientation=${this.orientation}
|
|
110
|
+
@ui-toggle-item-click=${this._onItemClick}
|
|
111
|
+
><slot></slot></div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
updated(): void {
|
|
115
|
+
// Reflect group state onto each <ui-toggle-group-item>. One microtask
|
|
116
|
+
// gives the items time to commit their own renders first.
|
|
117
|
+
queueMicrotask(() => this._reflectItems());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_reflectItems(): void {
|
|
121
|
+
const values = this._values;
|
|
122
|
+
this.querySelectorAll<UiToggleGroupItem>('ui-toggle-group-item').forEach((item) => {
|
|
123
|
+
const on = !!item.value && values.has(item.value);
|
|
124
|
+
// Reflect both on the host (for CSS sibling selectors like
|
|
125
|
+
// data-[spacing=0]:first:rounded-l-md that need to target the host
|
|
126
|
+
// as a sibling of other items) and as a reactive prop so the
|
|
127
|
+
// item's render() refreshes its inner styling.
|
|
128
|
+
item.pressed = on;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_onItemClick = (e: Event): void => {
|
|
133
|
+
const v = (e as CustomEvent).detail?.value as string | undefined;
|
|
134
|
+
if (!v) return;
|
|
135
|
+
const values = this._values;
|
|
136
|
+
if (this.type === 'single') {
|
|
137
|
+
values.clear();
|
|
138
|
+
values.add(v);
|
|
139
|
+
} else if (values.has(v)) {
|
|
140
|
+
values.delete(v);
|
|
141
|
+
} else {
|
|
142
|
+
values.add(v);
|
|
143
|
+
}
|
|
144
|
+
const next = Array.from(values).join(',');
|
|
145
|
+
this.value = this.type === 'single' ? (next.split(',')[0] ?? '') : next;
|
|
146
|
+
this.dispatchEvent(
|
|
147
|
+
new CustomEvent('ui-value-change', { detail: { value: this.value }, bubbles: true }),
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
UiToggleGroup.register('ui-toggle-group');
|
|
152
|
+
|
|
153
|
+
// --------------------------------------------------------------------------
|
|
154
|
+
// <ui-toggle-group-item>
|
|
155
|
+
// Renders a native <button> styled via toggleClass; emits a bubbling
|
|
156
|
+
// `ui-toggle-item-click` event with detail.value so the group can
|
|
157
|
+
// coordinate selection. Variant / size / spacing read from the group
|
|
158
|
+
// at render time (data-* attributes on the host carry them for
|
|
159
|
+
// Tailwind variant selectors on the joined-spacing rounded corners).
|
|
160
|
+
// --------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export class UiToggleGroupItem extends WebComponent {
|
|
163
|
+
static properties = {
|
|
164
|
+
value: { type: String, reflect: true },
|
|
165
|
+
pressed: { type: Boolean, reflect: true },
|
|
166
|
+
};
|
|
167
|
+
declare value: string;
|
|
168
|
+
declare pressed: boolean;
|
|
169
|
+
|
|
170
|
+
constructor() {
|
|
171
|
+
super();
|
|
172
|
+
this.value = '';
|
|
173
|
+
this.pressed = false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// render() runs server-side too. linkedom doesn't implement closest()
|
|
177
|
+
// on custom elements, so guard it; the client re-renders with the
|
|
178
|
+
// real parent reference after hydration.
|
|
179
|
+
get _group(): UiToggleGroup | null {
|
|
180
|
+
if (typeof this.closest !== 'function') return null;
|
|
181
|
+
return this.closest('ui-toggle-group') as UiToggleGroup | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Compound-component caveat: the host element carries the visual
|
|
185
|
+
// class + data-* attributes (not an inner <button>) so CSS sibling
|
|
186
|
+
// selectors like `data-[spacing=0]:first:rounded-l-md` match it as
|
|
187
|
+
// a sibling of other items in the group. Light DOM has no :host CSS
|
|
188
|
+
// and no way to bind host attributes from a render() template, so
|
|
189
|
+
// ARIA + static markup attributes go in connectedCallback (set once)
|
|
190
|
+
// and the parent-derived data-* + class string get refreshed in
|
|
191
|
+
// render(). Click + keyboard listeners live on the host because the
|
|
192
|
+
// click target IS the host (the styled element under the cursor).
|
|
193
|
+
connectedCallback(): void {
|
|
194
|
+
this.dataset.slot = 'toggle-group-item';
|
|
195
|
+
this.role = 'button';
|
|
196
|
+
this.tabIndex = 0;
|
|
197
|
+
this.addEventListener('click', this._onClick);
|
|
198
|
+
this.addEventListener('keydown', this._onKeyDown);
|
|
199
|
+
super.connectedCallback?.();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
disconnectedCallback(): void {
|
|
203
|
+
this.removeEventListener('click', this._onClick);
|
|
204
|
+
this.removeEventListener('keydown', this._onKeyDown);
|
|
205
|
+
super.disconnectedCallback?.();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
render() {
|
|
209
|
+
const group = this._group;
|
|
210
|
+
const variant = (group?.variant ?? 'default') as ToggleVariant;
|
|
211
|
+
const size = (group?.size ?? 'default') as ToggleSize;
|
|
212
|
+
const spacing = group?.spacing ?? '0';
|
|
213
|
+
this.dataset.variant = variant;
|
|
214
|
+
this.dataset.size = size;
|
|
215
|
+
this.dataset.spacing = spacing;
|
|
216
|
+
this.dataset.state = this.pressed ? 'on' : 'off';
|
|
217
|
+
this.ariaPressed = String(this.pressed);
|
|
218
|
+
this.className = cn(toggleClass({ variant, size }), ITEM_EXTRA);
|
|
219
|
+
return html`<slot></slot>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_onClick = (): void => {
|
|
223
|
+
if (!this.value) return;
|
|
224
|
+
this.dispatchEvent(
|
|
225
|
+
new CustomEvent('ui-toggle-item-click', { detail: { value: this.value }, bubbles: true }),
|
|
226
|
+
);
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
_onKeyDown = (e: KeyboardEvent): void => {
|
|
230
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
this._onClick();
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
UiToggleGroupItem.register('ui-toggle-group-item');
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toggle: pressable on / off button. Ships both as a Tier-1 class helper
|
|
3
|
+
* (for callers that want to own pressed state on a native `<button>`)
|
|
4
|
+
* and as the Tier-2 `<ui-toggle>` custom element (for callers that want
|
|
5
|
+
* state managed for them).
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* Toggle (variant: default | outline)
|
|
9
|
+
* (size: default | sm | lg)
|
|
10
|
+
* → toggleClass({ variant, size }) (class helper)
|
|
11
|
+
* → <ui-toggle pressed variant size> (custom element)
|
|
12
|
+
*
|
|
13
|
+
* Usage (Tier-1 class helper, caller owns state):
|
|
14
|
+
* <button class=${toggleClass()} data-state="off" aria-pressed="false"
|
|
15
|
+
* onclick="this.dataset.state = this.dataset.state==='on'?'off':'on'">
|
|
16
|
+
* <svg>…</svg>
|
|
17
|
+
* </button>
|
|
18
|
+
*
|
|
19
|
+
* Usage (Tier-2 custom element, state managed):
|
|
20
|
+
* <ui-toggle aria-label="Toggle bold">
|
|
21
|
+
* <svg>…</svg>
|
|
22
|
+
* </ui-toggle>
|
|
23
|
+
*
|
|
24
|
+
* <!-- Controlled / initial: -->
|
|
25
|
+
* <ui-toggle variant="outline" size="sm" pressed>B</ui-toggle>
|
|
26
|
+
*
|
|
27
|
+
* Attributes on <ui-toggle>:
|
|
28
|
+
* `pressed`: boolean (reflected). Active state.
|
|
29
|
+
* `variant`: "default" (default) | "outline".
|
|
30
|
+
* `size`: "default" (default) | "sm" | "lg".
|
|
31
|
+
* `disabled`: boolean (reflected). Disables click + focus.
|
|
32
|
+
*
|
|
33
|
+
* Events:
|
|
34
|
+
* `ui-pressed-change` on <ui-toggle>: `{ detail: { pressed } }` after a click.
|
|
35
|
+
*
|
|
36
|
+
* Keyboard: native button — Enter / Space activates (via the inner <button>).
|
|
37
|
+
*
|
|
38
|
+
* Design tokens used: --muted, --muted-foreground, --accent, --accent-foreground,
|
|
39
|
+
* --input, --background, --ring, --destructive.
|
|
40
|
+
*/
|
|
41
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
42
|
+
import { cn } from '../lib/utils.ts';
|
|
43
|
+
|
|
44
|
+
// cursor-pointer + select-none on BASE for both call sites: the
|
|
45
|
+
// class-helper applied to a native <button> (where shadcn's upstream
|
|
46
|
+
// also omits it; see the same convention the button fix applies) and
|
|
47
|
+
// the <ui-toggle> custom element. select-none prevents drag-selecting
|
|
48
|
+
// icon/label glyphs that aren't meant to be selectable. disabled:
|
|
49
|
+
// pointer-events-none below already suppresses cursor for disabled buttons.
|
|
50
|
+
const BASE =
|
|
51
|
+
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap select-none transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
52
|
+
|
|
53
|
+
const VARIANTS = {
|
|
54
|
+
default: 'bg-transparent',
|
|
55
|
+
outline:
|
|
56
|
+
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
const SIZES = {
|
|
60
|
+
default: 'h-9 min-w-9 px-2',
|
|
61
|
+
sm: 'h-8 min-w-8 px-1.5',
|
|
62
|
+
lg: 'h-10 min-w-10 px-2.5',
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
export type ToggleVariant = keyof typeof VARIANTS;
|
|
66
|
+
export type ToggleSize = keyof typeof SIZES;
|
|
67
|
+
|
|
68
|
+
export function toggleClass(opts: { variant?: ToggleVariant; size?: ToggleSize } = {}): string {
|
|
69
|
+
return cn(BASE, VARIANTS[opts.variant ?? 'default'], SIZES[opts.size ?? 'default']);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --------------------------------------------------------------------------
|
|
73
|
+
// <ui-toggle> wraps a native <button> and tracks pressed state. Native
|
|
74
|
+
// <button> handles Enter/Space → click + focus + disabled semantics for
|
|
75
|
+
// free; we only own the pressed-state toggle on click. Authored children
|
|
76
|
+
// project through the default slot inside the inner button.
|
|
77
|
+
// --------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
export class UiToggle extends WebComponent {
|
|
80
|
+
static properties = {
|
|
81
|
+
pressed: { type: Boolean, reflect: true },
|
|
82
|
+
variant: { type: String, reflect: true },
|
|
83
|
+
size: { type: String, reflect: true },
|
|
84
|
+
disabled: { type: Boolean, reflect: true },
|
|
85
|
+
};
|
|
86
|
+
declare pressed: boolean;
|
|
87
|
+
declare variant: ToggleVariant;
|
|
88
|
+
declare size: ToggleSize;
|
|
89
|
+
declare disabled: boolean;
|
|
90
|
+
|
|
91
|
+
constructor() {
|
|
92
|
+
super();
|
|
93
|
+
this.pressed = false;
|
|
94
|
+
this.variant = 'default';
|
|
95
|
+
this.size = 'default';
|
|
96
|
+
this.disabled = false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
render() {
|
|
100
|
+
return html`<button
|
|
101
|
+
type="button"
|
|
102
|
+
data-slot="toggle"
|
|
103
|
+
class=${toggleClass({ variant: this.variant, size: this.size })}
|
|
104
|
+
aria-pressed=${String(this.pressed)}
|
|
105
|
+
data-state=${this.pressed ? 'on' : 'off'}
|
|
106
|
+
?disabled=${this.disabled}
|
|
107
|
+
@click=${this._onClick}
|
|
108
|
+
><slot></slot></button>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_onClick = (): void => {
|
|
112
|
+
this.pressed = !this.pressed;
|
|
113
|
+
this.dispatchEvent(
|
|
114
|
+
new CustomEvent('ui-pressed-change', { detail: { pressed: this.pressed }, bubbles: true }),
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
UiToggle.register('ui-toggle');
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tooltip: hover- / focus-triggered floating tip. Tier-2. The content
|
|
3
|
+
* uses the native Popover API in `popover="manual"` mode for top-layer
|
|
4
|
+
* rendering (no z-index wars); the custom element owns the
|
|
5
|
+
* hover-with-delay state machine and JS positioning.
|
|
6
|
+
*
|
|
7
|
+
* shadcn parity:
|
|
8
|
+
* Tooltip → <ui-tooltip delay-duration skip-delay-duration>
|
|
9
|
+
* TooltipTrigger → <ui-tooltip-trigger>
|
|
10
|
+
* TooltipContent → <ui-tooltip-content side align side-offset align-offset>
|
|
11
|
+
* TooltipProvider → not needed; delay state is per-tooltip (and globally
|
|
12
|
+
* shared via `skip-delay-duration` cooldown).
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* <ui-tooltip delay-duration="500" skip-delay-duration="300">
|
|
16
|
+
* <ui-tooltip-trigger>
|
|
17
|
+
* <button class=${buttonClass({ size: 'icon', variant: 'ghost' })} aria-label="Help">?</button>
|
|
18
|
+
* </ui-tooltip-trigger>
|
|
19
|
+
* <ui-tooltip-content side="top">Helpful tip</ui-tooltip-content>
|
|
20
|
+
* </ui-tooltip>
|
|
21
|
+
*
|
|
22
|
+
* Attributes on <ui-tooltip>:
|
|
23
|
+
* `open`: boolean (reflected). Open state.
|
|
24
|
+
* `delay-duration`: ms, default 700. Initial hover delay before opening.
|
|
25
|
+
* `skip-delay-duration`: ms, default 300. Window after a tooltip closes
|
|
26
|
+
* during which the next tooltip skips its
|
|
27
|
+
* `delay-duration` (so moving between adjacent
|
|
28
|
+
* triggers feels instant).
|
|
29
|
+
*
|
|
30
|
+
* Attributes on <ui-tooltip-content>:
|
|
31
|
+
* `side`: "top" (default) | "right" | "bottom" | "left".
|
|
32
|
+
* `align`: "center" (default) | "start" | "end".
|
|
33
|
+
* `side-offset`: number, default 4. Pixels between trigger and content.
|
|
34
|
+
* `align-offset`: number, default 0. Pixels of cross-axis shift.
|
|
35
|
+
*
|
|
36
|
+
* Events: none dispatched at present (hover state changes are local;
|
|
37
|
+
* use the reflected `open` attribute to observe state from CSS).
|
|
38
|
+
*
|
|
39
|
+
* Programmatic API on <ui-tooltip>: `.show()` · `.hide()`.
|
|
40
|
+
*
|
|
41
|
+
* Design tokens used: --foreground, --background.
|
|
42
|
+
*/
|
|
43
|
+
import { WebComponent, html } from '@webjsdev/core';
|
|
44
|
+
import { positionFloating, type PopoverSide, type PopoverAlign } from './popover.ts';
|
|
45
|
+
|
|
46
|
+
// UA `[popover]` defaults paint a bordered, padded panel centered with
|
|
47
|
+
// `margin: auto`; the class string opts out via `m-0` + `border-0` and
|
|
48
|
+
// layers the shadcn look on top. `fixed` keeps JS-computed top/left in
|
|
49
|
+
// top layer. UA `[popover]:not(:popover-open) { display: none }` keeps
|
|
50
|
+
// the closed state hidden.
|
|
51
|
+
export const tooltipContentClass = (): string =>
|
|
52
|
+
'fixed z-50 w-fit m-0 border-0 overflow-visible rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background';
|
|
53
|
+
|
|
54
|
+
// Module-level "last close" timestamp, shared across every <ui-tooltip>
|
|
55
|
+
// on the page. When the next tooltip is hovered within
|
|
56
|
+
// `skip-delay-duration` ms of this stamp, it skips its delay-duration
|
|
57
|
+
// wait and opens immediately. Matches shadcn TooltipProvider.skipDelayDuration.
|
|
58
|
+
let lastTooltipHideAt = 0;
|
|
59
|
+
|
|
60
|
+
// --------------------------------------------------------------------------
|
|
61
|
+
// <ui-tooltip>
|
|
62
|
+
// --------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
export class UiTooltip extends WebComponent {
|
|
65
|
+
static properties = {
|
|
66
|
+
open: { type: Boolean, reflect: true },
|
|
67
|
+
};
|
|
68
|
+
declare open: boolean;
|
|
69
|
+
|
|
70
|
+
_showTimer: number | undefined;
|
|
71
|
+
_hideTimer: number | undefined;
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
super();
|
|
75
|
+
this.open = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Back-compat getter for tests + external code that read `el.isOpen`
|
|
79
|
+
// alongside the reactive `open` prop.
|
|
80
|
+
get isOpen(): boolean { return this.open; }
|
|
81
|
+
|
|
82
|
+
show(): void {
|
|
83
|
+
clearTimeout(this._showTimer);
|
|
84
|
+
clearTimeout(this._hideTimer);
|
|
85
|
+
const delay = Number(this.getAttribute('delay-duration') ?? 700);
|
|
86
|
+
const skipDelay = Number(this.getAttribute('skip-delay-duration') ?? 300);
|
|
87
|
+
const sinceLastHide = Date.now() - lastTooltipHideAt;
|
|
88
|
+
if (lastTooltipHideAt > 0 && sinceLastHide < skipDelay) {
|
|
89
|
+
this.open = true;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
this._showTimer = window.setTimeout(() => { this.open = true; }, delay);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hide(): void {
|
|
96
|
+
clearTimeout(this._showTimer);
|
|
97
|
+
this._hideTimer = window.setTimeout(() => {
|
|
98
|
+
this.open = false;
|
|
99
|
+
lastTooltipHideAt = Date.now();
|
|
100
|
+
}, 100);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
render() {
|
|
104
|
+
return html`<div
|
|
105
|
+
data-slot="tooltip"
|
|
106
|
+
data-state=${this.open ? 'open' : 'closed'}
|
|
107
|
+
><slot></slot></div>`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
updated(changedProperties: Map<string, unknown>): void {
|
|
111
|
+
if (!changedProperties.has('open')) return;
|
|
112
|
+
// Skip the constructor's initial open=false set.
|
|
113
|
+
if (changedProperties.get('open') === undefined) return;
|
|
114
|
+
// Defer one microtask so the content child's [popover] inner element
|
|
115
|
+
// has committed; we drive its showPopover() / hidePopover() from here.
|
|
116
|
+
queueMicrotask(() => this._syncContent());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_syncContent(): void {
|
|
120
|
+
// <ui-tooltip-content> renders a `<div popover="manual">` inside its
|
|
121
|
+
// slot output; the popover API lives on that inner div, not the
|
|
122
|
+
// host. Query past the host to the actual popover element.
|
|
123
|
+
const popover = this.querySelector<HTMLElement>('ui-tooltip-content [popover]');
|
|
124
|
+
const host = this.querySelector<HTMLElement>('ui-tooltip-content');
|
|
125
|
+
if (!popover || !popover.isConnected) return;
|
|
126
|
+
const p = popover as HTMLElement & {
|
|
127
|
+
showPopover?: () => void;
|
|
128
|
+
hidePopover?: () => void;
|
|
129
|
+
matches: (s: string) => boolean;
|
|
130
|
+
};
|
|
131
|
+
if (typeof p.showPopover !== 'function') return;
|
|
132
|
+
if (this.open) {
|
|
133
|
+
if (!p.matches(':popover-open')) p.showPopover();
|
|
134
|
+
if (host) this._reposition(host, popover);
|
|
135
|
+
} else if (p.matches(':popover-open')) {
|
|
136
|
+
p.hidePopover();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
_reposition(contentHost: HTMLElement, popover: HTMLElement): void {
|
|
141
|
+
const trigger = this.querySelector<HTMLElement>('ui-tooltip-trigger');
|
|
142
|
+
if (!trigger) return;
|
|
143
|
+
// Placement attributes live on the <ui-tooltip-content> host (the
|
|
144
|
+
// public API surface); positioning targets the inner popover element.
|
|
145
|
+
positionFloating(trigger, popover, {
|
|
146
|
+
side: (contentHost.getAttribute('side') ?? 'top') as PopoverSide,
|
|
147
|
+
align: (contentHost.getAttribute('align') ?? 'center') as PopoverAlign,
|
|
148
|
+
sideOffset: Number(contentHost.getAttribute('side-offset') ?? 4),
|
|
149
|
+
alignOffset: Number(contentHost.getAttribute('align-offset') ?? 0),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
UiTooltip.register('ui-tooltip');
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------------------------------------
|
|
156
|
+
// <ui-tooltip-trigger>
|
|
157
|
+
// Wraps the user's focusable element. Hover/focus handlers live on the
|
|
158
|
+
// rendered wrapper via declarative bindings; no manual addEventListener
|
|
159
|
+
// or disconnect cleanup is needed.
|
|
160
|
+
// --------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
export class UiTooltipTrigger extends WebComponent {
|
|
163
|
+
render() {
|
|
164
|
+
return html`<div
|
|
165
|
+
data-slot="tooltip-trigger"
|
|
166
|
+
@mouseenter=${this._onEnter}
|
|
167
|
+
@mouseleave=${this._onLeave}
|
|
168
|
+
@focusin=${this._onEnter}
|
|
169
|
+
@focusout=${this._onLeave}
|
|
170
|
+
><slot></slot></div>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
_onEnter = (): void => (this.closest('ui-tooltip') as UiTooltip | null)?.show();
|
|
174
|
+
_onLeave = (): void => (this.closest('ui-tooltip') as UiTooltip | null)?.hide();
|
|
175
|
+
}
|
|
176
|
+
UiTooltipTrigger.register('ui-tooltip-trigger');
|
|
177
|
+
|
|
178
|
+
// --------------------------------------------------------------------------
|
|
179
|
+
// <ui-tooltip-content>
|
|
180
|
+
// Renders a `<div popover="manual" role="tooltip">` (the floating panel).
|
|
181
|
+
// The popover attribute is declarative on the inner element, so the
|
|
182
|
+
// browser registers it as a popover before the parent calls showPopover.
|
|
183
|
+
// --------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
export class UiTooltipContent extends WebComponent {
|
|
186
|
+
render() {
|
|
187
|
+
return html`<div
|
|
188
|
+
data-slot="tooltip-content"
|
|
189
|
+
role="tooltip"
|
|
190
|
+
popover="manual"
|
|
191
|
+
class=${tooltipContentClass()}
|
|
192
|
+
><slot></slot></div>`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
UiTooltipContent.register('ui-tooltip-content');
|