@neovici/cosmoz-tooltip 1.0.0 → 1.0.1

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/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # cosmoz-tooltip
2
+
3
+ Tooltip web component using modern CSS APIs (CSS Anchor Positioning, Popover API) built with [@pionjs/pion](https://github.com/nicolo-ribaudo/pion).
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @neovici/cosmoz-tooltip
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Wrapping mode
14
+
15
+ ```html
16
+ <script type="module">
17
+ import '@neovici/cosmoz-tooltip';
18
+ </script>
19
+
20
+ <cosmoz-tooltip heading="Help" description="Click to submit the form">
21
+ <button>Submit</button>
22
+ </cosmoz-tooltip>
23
+ ```
24
+
25
+ ### For attribute mode
26
+
27
+ Target elements by their `name` attribute within the same document/shadow root:
28
+
29
+ ```html
30
+ <input name="email" type="email" />
31
+ <cosmoz-tooltip
32
+ for="email"
33
+ heading="Email format"
34
+ description="Enter a valid email like name@domain.com"
35
+ ></cosmoz-tooltip>
36
+ ```
37
+
38
+ ### Rich content (wrapping mode only)
39
+
40
+ ```html
41
+ <cosmoz-tooltip>
42
+ <button>Info</button>
43
+ <div slot="content">
44
+ <strong>Custom HTML</strong>
45
+ <ul>
46
+ <li>First item</li>
47
+ <li>Second item</li>
48
+ </ul>
49
+ </div>
50
+ </cosmoz-tooltip>
51
+ ```
52
+
53
+ ## API
54
+
55
+ | Attribute | Type | Default | Description |
56
+ | ------------- | ------ | ------- | ------------------------------------------------------------------------- |
57
+ | `heading` | string | - | Bold heading text |
58
+ | `description` | string | - | Secondary description text |
59
+ | `for` | string | - | Target element's `name` attribute |
60
+ | `placement` | string | `top` | Position: `top`, `bottom`, `left`, `right`, `top center`, `bottom center` |
61
+ | `delay` | number | `300` | Delay before showing tooltip (ms) |
62
+
63
+ ### Slots
64
+
65
+ | Slot | Description |
66
+ | --------- | -------------------------------------------------- |
67
+ | (default) | Trigger element (wrapping mode) |
68
+ | `content` | Rich HTML content for tooltip (wrapping mode only) |
69
+
70
+ ## Features
71
+
72
+ - **CSS Anchor Positioning** — tooltip automatically anchors to trigger element
73
+ - **Popover API** — proper layering without z-index hacks
74
+ - **Automatic flip** — repositions when constrained by viewport via `position-try-fallbacks`
75
+ - **Smooth animations** — uses `@starting-style` and `allow-discrete` transitions
76
+ - **Non-blocking** — `pointer-events: none` ensures tooltips never block interactions
77
+ - **Keyboard accessible** — shows on focus, hides on blur
78
+
79
+ ## Design decisions
80
+
81
+ ### No arrow/caret
82
+
83
+ CSS Anchor Positioning's `position-try-fallbacks` can flip the tooltip to the opposite side when constrained by the viewport. There is no pure CSS way to detect when a flip occurs, so an arrow's direction cannot reliably match the actual tooltip position.
84
+
85
+ ### No rich content in `for=""` mode
86
+
87
+ The `for=""` mode creates the popover in the light DOM (required for CSS Anchor Positioning to work across elements). Since there's no shadow boundary, slot projection isn't available — content is limited to `heading` and `description` attributes.
88
+
89
+ ## Browser support
90
+
91
+ This component uses [CSS Anchor Positioning](https://caniuse.com/css-anchor-positioning) and [Popover API](https://caniuse.com/mdn-api_htmlelement_popover), both Baseline 2026.
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ npm install
97
+ npm run storybook:start # Start Storybook on port 8000
98
+ npm test # Run tests
99
+ npm run build # Build for production
100
+ ```
101
+
102
+ ## License
103
+
104
+ Apache-2.0
105
+
106
+ ---
107
+
108
+ Built with [@pionjs/pion](https://github.com/pionjs/pion)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { normalize } from '@neovici/cosmoz-tokens/normalize';
2
+ import { component, css } from '@pionjs/pion';
3
+ import { html } from 'lit-html';
4
+ const style = css `
5
+ :host {
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: var(--cz-spacing);
9
+ font-family: var(--cz-font-body);
10
+ font-size: var(--cz-text-xs);
11
+ line-height: var(--cz-text-xs-line-height);
12
+ }
13
+
14
+ ::slotted([slot='heading']) {
15
+ font-weight: var(--cz-font-weight-semibold);
16
+ display: block;
17
+ }
18
+
19
+ ::slotted([slot='description']) {
20
+ margin: 0;
21
+ color: var(--cz-color-gray-300);
22
+ }
23
+ `;
24
+ customElements.define('cosmoz-tooltip-content', component(() => html `
25
+ <slot name="heading"></slot>
26
+ <slot name="description"></slot>
27
+ <slot></slot>
28
+ `, { styleSheets: [normalize, style] }));
@@ -0,0 +1,10 @@
1
+ import './cosmoz-tooltip-content.js';
2
+ interface TooltipProps {
3
+ heading?: string;
4
+ description?: string;
5
+ for?: string;
6
+ placement?: string;
7
+ delay?: number;
8
+ }
9
+ declare const CosmozTooltip: (host: HTMLElement & TooltipProps) => import("lit-html").TemplateResult<1>;
10
+ export { CosmozTooltip };
@@ -0,0 +1,76 @@
1
+ import { normalize } from '@neovici/cosmoz-tokens/normalize';
2
+ import { component, css, useCallback, useRef } from '@pionjs/pion';
3
+ import { html } from 'lit-html';
4
+ import { ref } from 'lit-html/directives/ref.js';
5
+ import { when } from 'lit-html/directives/when.js';
6
+ import './cosmoz-tooltip-content.js';
7
+ import { popoverStyle } from './popover-style.js';
8
+ import { useForTooltip } from './use-for-tooltip.js';
9
+ /**
10
+ * Host-specific styles (shadow DOM only).
11
+ * The wrapping mode popover binds to the host's anchor name.
12
+ */
13
+ const style = css `
14
+ :host {
15
+ display: inline-block;
16
+ anchor-name: --tooltip-anchor;
17
+ }
18
+
19
+ :host([for]) {
20
+ display: contents;
21
+ anchor-name: unset;
22
+ }
23
+
24
+ .cosmoz-tooltip-popover {
25
+ position-anchor: --tooltip-anchor;
26
+ }
27
+ `;
28
+ const CosmozTooltip = (host) => {
29
+ const { heading, description, for: forAttr, placement = 'top', delay = 300, } = host;
30
+ const popover = useRef();
31
+ const timeoutId = useRef();
32
+ const show = useCallback(() => {
33
+ clearTimeout(timeoutId.current);
34
+ timeoutId.current = window.setTimeout(() => {
35
+ popover.current?.showPopover();
36
+ }, delay);
37
+ }, [delay]);
38
+ const hide = useCallback(() => {
39
+ clearTimeout(timeoutId.current);
40
+ popover.current?.hidePopover();
41
+ }, []);
42
+ // Delegate for="" mode to hook
43
+ useForTooltip(host, { for: forAttr, heading, description, placement, delay });
44
+ // For attribute mode: nothing to render in shadow DOM
45
+ if (forAttr)
46
+ return html ``;
47
+ // Wrapping mode: render slot + popover in shadow DOM
48
+ return html `
49
+ <slot
50
+ @pointerenter=${show}
51
+ @pointerleave=${hide}
52
+ @focusin=${show}
53
+ @focusout=${hide}
54
+ ></slot>
55
+ <div
56
+ class="cosmoz-tooltip-popover"
57
+ popover="manual"
58
+ role="tooltip"
59
+ style="position-area: ${placement}"
60
+ ${ref((el) => {
61
+ popover.current = el;
62
+ })}
63
+ >
64
+ <cosmoz-tooltip-content>
65
+ ${when(heading, () => html `<strong slot="heading">${heading}</strong>`)}
66
+ ${when(description, () => html `<p slot="description">${description}</p>`)}
67
+ <slot name="content"></slot>
68
+ </cosmoz-tooltip-content>
69
+ </div>
70
+ `;
71
+ };
72
+ customElements.define('cosmoz-tooltip', component(CosmozTooltip, {
73
+ styleSheets: [normalize, popoverStyle, style],
74
+ observedAttributes: ['heading', 'description', 'for', 'placement', 'delay'],
75
+ }));
76
+ export { CosmozTooltip };
@@ -0,0 +1 @@
1
+ export { CosmozTooltip } from './cosmoz-tooltip';
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { CosmozTooltip } from './cosmoz-tooltip';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Shared popover styles used in both shadow DOM (wrapping mode)
3
+ * and light DOM (for="" mode).
4
+ *
5
+ * Design decision: No arrow/caret pointing to the anchor.
6
+ *
7
+ * CSS Anchor Positioning's position-try-fallbacks can flip the tooltip
8
+ * to the opposite side when constrained by the viewport. There is no
9
+ * pure CSS way to detect when a flip occurs (@position-try descriptors
10
+ * cannot set custom properties or pseudo-element styles), so the arrow
11
+ * direction cannot reliably match the actual tooltip position.
12
+ * A JS-based detection (getBoundingClientRect) was considered but
13
+ * rejected to keep the component purely CSS-positioned.
14
+ */
15
+ export declare const popoverStyle: CSSStyleSheet;
@@ -0,0 +1,65 @@
1
+ import { css, sheet } from '@pionjs/pion';
2
+ /**
3
+ * Shared popover styles used in both shadow DOM (wrapping mode)
4
+ * and light DOM (for="" mode).
5
+ *
6
+ * Design decision: No arrow/caret pointing to the anchor.
7
+ *
8
+ * CSS Anchor Positioning's position-try-fallbacks can flip the tooltip
9
+ * to the opposite side when constrained by the viewport. There is no
10
+ * pure CSS way to detect when a flip occurs (@position-try descriptors
11
+ * cannot set custom properties or pseudo-element styles), so the arrow
12
+ * direction cannot reliably match the actual tooltip position.
13
+ * A JS-based detection (getBoundingClientRect) was considered but
14
+ * rejected to keep the component purely CSS-positioned.
15
+ */
16
+ export const popoverStyle = sheet(css `
17
+ .cosmoz-tooltip-popover {
18
+ position: fixed;
19
+ inset: unset;
20
+ pointer-events: none;
21
+ text-align: left;
22
+ margin: calc(var(--cz-spacing) * 2);
23
+ position-try-fallbacks:
24
+ flip-block,
25
+ flip-inline,
26
+ flip-block flip-inline;
27
+
28
+ /* Reset popover defaults */
29
+ border: none;
30
+ padding: calc(var(--cz-spacing) * 2) calc(var(--cz-spacing) * 3);
31
+ background: var(--cz-color-gray-900);
32
+ color: var(--cz-color-white);
33
+ border-radius: var(--cz-radius-sm);
34
+ max-width: 20rem;
35
+ box-shadow: var(--cz-shadow-lg);
36
+
37
+ /* Animation - open state */
38
+ opacity: 1;
39
+ transform: translateY(0) scale(1);
40
+
41
+ transition:
42
+ opacity 150ms ease-out,
43
+ transform 150ms ease-out,
44
+ overlay 150ms ease-out allow-discrete,
45
+ display 150ms ease-out allow-discrete;
46
+ }
47
+
48
+ @starting-style {
49
+ .cosmoz-tooltip-popover:popover-open {
50
+ opacity: 0;
51
+ transform: translateY(4px) scale(0.96);
52
+ }
53
+ }
54
+
55
+ .cosmoz-tooltip-popover:not(:popover-open) {
56
+ opacity: 0;
57
+ transform: translateY(4px) scale(0.96);
58
+ }
59
+
60
+ @media (prefers-reduced-motion: reduce) {
61
+ .cosmoz-tooltip-popover {
62
+ transition: none;
63
+ }
64
+ }
65
+ `);
@@ -0,0 +1,17 @@
1
+ import './cosmoz-tooltip-content.js';
2
+ declare global {
3
+ interface CSSStyleDeclaration {
4
+ anchorName: string;
5
+ positionAnchor: string;
6
+ positionArea: string;
7
+ }
8
+ }
9
+ interface ForTooltipOptions {
10
+ for?: string;
11
+ heading?: string;
12
+ description?: string;
13
+ placement?: string;
14
+ delay?: number;
15
+ }
16
+ export declare const useForTooltip: (host: HTMLElement, opts: ForTooltipOptions) => void;
17
+ export {};
@@ -0,0 +1,106 @@
1
+ import { render, useEffect, useRef } from '@pionjs/pion';
2
+ import { html } from 'lit-html';
3
+ import { when } from 'lit-html/directives/when.js';
4
+ import './cosmoz-tooltip-content.js';
5
+ import { popoverStyle } from './popover-style.js';
6
+ /**
7
+ * Design decision: No rich content support in for="" mode.
8
+ *
9
+ * Wrapping mode can use <slot name="content"> to project arbitrary HTML
10
+ * into the tooltip popover. The for="" mode popover lives in the light DOM
11
+ * (outside the component's shadow root), so slots are not available —
12
+ * there is no shadow boundary to project through. Content is limited to
13
+ * the heading and description attributes.
14
+ */
15
+ const renderContent = (popoverEl, heading, description) => render(html `<cosmoz-tooltip-content>
16
+ ${when(heading, () => html `<strong slot="heading">${heading}</strong>`)}
17
+ ${when(description, () => html `<p slot="description">${description}</p>`)}
18
+ </cosmoz-tooltip-content>`, popoverEl);
19
+ export const useForTooltip = (host, opts) => {
20
+ const { for: forAttr, heading, description, placement = 'top', delay = 300, } = opts;
21
+ const popover = useRef();
22
+ // eslint-disable-next-line max-statements
23
+ useEffect(() => {
24
+ if (!forAttr)
25
+ return;
26
+ const root = host.getRootNode();
27
+ // Adopt shared popover stylesheet on root (idempotent)
28
+ const sheets = root.adoptedStyleSheets ?? [];
29
+ if (!sheets.includes(popoverStyle)) {
30
+ root.adoptedStyleSheets = [...sheets, popoverStyle];
31
+ }
32
+ // Create light-DOM popover element
33
+ const popoverEl = document.createElement('div');
34
+ popoverEl.setAttribute('popover', 'manual');
35
+ popoverEl.setAttribute('role', 'tooltip');
36
+ popoverEl.classList.add('cosmoz-tooltip-popover');
37
+ // Insert after host to keep it in the same container scope
38
+ host.after(popoverEl);
39
+ popover.current = popoverEl;
40
+ // Render initial content
41
+ renderContent(popoverEl, heading, description);
42
+ const selector = `[name="${forAttr}"]`;
43
+ const anchorName = `--tooltip-anchor-${forAttr}`;
44
+ let showTimeout;
45
+ const showForTarget = (target) => {
46
+ clearTimeout(showTimeout);
47
+ // Set CSS anchor positioning
48
+ target.style.anchorName = anchorName;
49
+ popoverEl.style.positionAnchor = anchorName;
50
+ popoverEl.style.positionArea = placement;
51
+ showTimeout = window.setTimeout(() => popoverEl.showPopover(), delay);
52
+ };
53
+ const hidePopover = () => {
54
+ clearTimeout(showTimeout);
55
+ popoverEl.hidePopover();
56
+ };
57
+ const onPointerover = (e) => {
58
+ const target = e.target.closest?.(selector);
59
+ if (!target)
60
+ return;
61
+ showForTarget(target);
62
+ };
63
+ const onPointerout = (e) => {
64
+ const target = e.target.closest?.(selector);
65
+ if (!target)
66
+ return;
67
+ const related = e.relatedTarget;
68
+ // Still inside the target element? Ignore.
69
+ if (related && target.contains(related))
70
+ return;
71
+ hidePopover();
72
+ };
73
+ const onFocusin = (e) => {
74
+ const target = e.target.closest?.(selector);
75
+ if (!target)
76
+ return;
77
+ showForTarget(target);
78
+ };
79
+ const onFocusout = (e) => {
80
+ const target = e.target.closest?.(selector);
81
+ if (!target)
82
+ return;
83
+ hidePopover();
84
+ };
85
+ root.addEventListener('pointerover', onPointerover);
86
+ root.addEventListener('pointerout', onPointerout);
87
+ root.addEventListener('focusin', onFocusin);
88
+ root.addEventListener('focusout', onFocusout);
89
+ return () => {
90
+ clearTimeout(showTimeout);
91
+ root.removeEventListener('pointerover', onPointerover);
92
+ root.removeEventListener('pointerout', onPointerout);
93
+ root.removeEventListener('focusin', onFocusin);
94
+ root.removeEventListener('focusout', onFocusout);
95
+ popoverEl.hidePopover();
96
+ popoverEl.remove();
97
+ popover.current = undefined;
98
+ };
99
+ }, [forAttr, placement, delay]);
100
+ // Re-render light-DOM popover content when heading/description change
101
+ useEffect(() => {
102
+ if (!forAttr || !popover.current)
103
+ return;
104
+ renderContent(popover.current, heading, description);
105
+ }, [heading, description, forAttr]);
106
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neovici/cosmoz-tooltip",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Tooltip component using CSS Anchor Positioning and Popover API",
5
5
  "keywords": [
6
6
  "web-components",