@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 +108 -0
- package/dist/cosmoz-tooltip-content.d.ts +1 -0
- package/dist/cosmoz-tooltip-content.js +28 -0
- package/dist/cosmoz-tooltip.d.ts +10 -0
- package/dist/cosmoz-tooltip.js +76 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/popover-style.d.ts +15 -0
- package/dist/popover-style.js +65 -0
- package/dist/use-for-tooltip.d.ts +17 -0
- package/dist/use-for-tooltip.js +106 -0
- package/package.json +1 -1
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 };
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
};
|