@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.
Files changed (39) hide show
  1. package/package.json +4 -3
  2. package/packages/registry/README.md +35 -0
  3. package/packages/registry/components/accordion.ts +74 -0
  4. package/packages/registry/components/alert-dialog.ts +359 -0
  5. package/packages/registry/components/alert.ts +51 -0
  6. package/packages/registry/components/aspect-ratio.ts +22 -0
  7. package/packages/registry/components/avatar.ts +52 -0
  8. package/packages/registry/components/badge.ts +40 -0
  9. package/packages/registry/components/breadcrumb.ts +43 -0
  10. package/packages/registry/components/button.ts +72 -0
  11. package/packages/registry/components/card.ts +86 -0
  12. package/packages/registry/components/checkbox.ts +97 -0
  13. package/packages/registry/components/collapsible.ts +60 -0
  14. package/packages/registry/components/dialog.ts +378 -0
  15. package/packages/registry/components/dropdown-menu.ts +591 -0
  16. package/packages/registry/components/hover-card.ts +175 -0
  17. package/packages/registry/components/input.ts +36 -0
  18. package/packages/registry/components/kbd.ts +25 -0
  19. package/packages/registry/components/label.ts +23 -0
  20. package/packages/registry/components/native-select.ts +110 -0
  21. package/packages/registry/components/pagination.ts +45 -0
  22. package/packages/registry/components/popover.ts +260 -0
  23. package/packages/registry/components/progress.ts +46 -0
  24. package/packages/registry/components/radio-group.ts +113 -0
  25. package/packages/registry/components/separator.ts +30 -0
  26. package/packages/registry/components/skeleton.ts +16 -0
  27. package/packages/registry/components/sonner.ts +240 -0
  28. package/packages/registry/components/switch.ts +52 -0
  29. package/packages/registry/components/table.ts +58 -0
  30. package/packages/registry/components/tabs.ts +271 -0
  31. package/packages/registry/components/textarea.ts +27 -0
  32. package/packages/registry/components/toggle-group.ts +236 -0
  33. package/packages/registry/components/toggle.ts +118 -0
  34. package/packages/registry/components/tooltip.ts +195 -0
  35. package/packages/registry/lib/utils.ts +241 -0
  36. package/packages/registry/package.json +7 -0
  37. package/packages/registry/registry.json +479 -0
  38. package/packages/registry/themes/base-colors.js +193 -0
  39. package/packages/registry/themes/index.css +141 -0
@@ -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');