@lumiose/ui 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +53 -2
- package/dist/accordion/accordion.css +20 -0
- package/dist/accordion/accordion.script.ts +66 -0
- package/dist/accordion/accordion.tsx +288 -0
- package/dist/animation/animation.ts +34 -0
- package/dist/autocomplete/autocomplete.tsx +239 -0
- package/dist/avatar/avatar.css +54 -0
- package/dist/avatar/avatar.script.ts +96 -0
- package/dist/avatar/avatar.tsx +110 -0
- package/dist/button/button.script.ts +35 -0
- package/dist/button/button.tsx +40 -0
- package/dist/checkbox/checkbox.script.ts +225 -0
- package/dist/checkbox/checkbox.tsx +156 -0
- package/dist/checkbox/context.ts +17 -0
- package/dist/checkbox-group/checkbox-group.tsx +111 -0
- package/dist/collapsible/collapsible.css +12 -0
- package/dist/collapsible/collapsible.script.ts +7 -0
- package/dist/collapsible/collapsible.tsx +153 -0
- package/dist/collapsible/panel.ts +115 -0
- package/dist/combobox/combobox.css +37 -0
- package/dist/combobox/combobox.script.ts +974 -0
- package/dist/combobox/combobox.tsx +556 -0
- package/dist/combobox/context.ts +46 -0
- package/dist/context-menu/context-menu.css +16 -0
- package/dist/context-menu/context-menu.script.ts +172 -0
- package/dist/context-menu/context-menu.tsx +178 -0
- package/dist/dialog/dialog.css +35 -0
- package/dist/dialog/dialog.script.ts +269 -0
- package/dist/dialog/dialog.tsx +214 -0
- package/dist/drawer/drawer.css +38 -0
- package/dist/drawer/drawer.script.ts +207 -0
- package/dist/drawer/drawer.tsx +113 -0
- package/dist/field/context.ts +156 -0
- package/dist/field/field.css +16 -0
- package/dist/field/field.script.ts +422 -0
- package/dist/field/field.tsx +465 -0
- package/dist/fieldset/fieldset.tsx +41 -0
- package/dist/form/form.tsx +50 -0
- package/dist/form/native.ts +21 -0
- package/dist/form/reset.ts +53 -0
- package/dist/index.ts +261 -0
- package/dist/input/input.tsx +67 -0
- package/dist/layer/layer.ts +191 -0
- package/dist/locale/digits.ts +21 -0
- package/dist/menu/menu.css +22 -0
- package/dist/menu/menu.script.ts +591 -0
- package/dist/menu/menu.tsx +520 -0
- package/dist/menubar/menubar.css +16 -0
- package/dist/menubar/menubar.script.ts +195 -0
- package/dist/menubar/menubar.tsx +67 -0
- package/dist/meter/meter.tsx +134 -0
- package/dist/navigation-menu/navigation-menu.css +14 -0
- package/dist/navigation-menu/navigation-menu.script.ts +466 -0
- package/dist/navigation-menu/navigation-menu.tsx +266 -0
- package/dist/number-field/format.ts +70 -0
- package/dist/number-field/number-field.css +38 -0
- package/dist/number-field/number-field.script.ts +490 -0
- package/dist/number-field/number-field.tsx +403 -0
- package/dist/otp-field/otp-field.css +45 -0
- package/dist/otp-field/otp-field.script.ts +330 -0
- package/dist/otp-field/otp-field.tsx +320 -0
- package/dist/placement/place.ts +148 -0
- package/dist/placement/placement.css +155 -0
- package/dist/placement/placement.ts +60 -0
- package/dist/popover/hover.ts +178 -0
- package/dist/popover/popover.css +20 -0
- package/dist/popover/popover.script.ts +256 -0
- package/dist/popover/popover.tsx +317 -0
- package/dist/preview-card/preview-card.css +7 -0
- package/dist/preview-card/preview-card.script.ts +136 -0
- package/dist/preview-card/preview-card.tsx +128 -0
- package/dist/progress/progress.tsx +155 -0
- package/dist/progress/value.ts +23 -0
- package/dist/radio-group/radio-group.tsx +156 -0
- package/dist/scroll-area/scroll-area.css +72 -0
- package/dist/scroll-area/scroll-area.script.ts +281 -0
- package/dist/scroll-area/scroll-area.tsx +118 -0
- package/dist/select/select.css +25 -0
- package/dist/select/select.tsx +171 -0
- package/dist/separator/separator.css +12 -0
- package/dist/separator/separator.tsx +38 -0
- package/dist/slider/format.ts +33 -0
- package/dist/slider/slider.css +93 -0
- package/dist/slider/slider.script.ts +355 -0
- package/dist/slider/slider.tsx +374 -0
- package/dist/styles.css +30 -0
- package/dist/switch/switch.tsx +92 -0
- package/dist/tabs/tabs.css +54 -0
- package/dist/tabs/tabs.script.ts +225 -0
- package/dist/tabs/tabs.tsx +272 -0
- package/dist/themes/default.css +2111 -0
- package/dist/toast/command.ts +3 -0
- package/dist/toast/toast.css +68 -0
- package/dist/toast/toast.script.ts +516 -0
- package/dist/toast/toast.tsx +432 -0
- package/dist/toggle/context.ts +16 -0
- package/dist/toggle/toggle.tsx +99 -0
- package/dist/toggle-group/toggle-group.script.ts +166 -0
- package/dist/toggle-group/toggle-group.tsx +117 -0
- package/dist/toolbar/context.ts +18 -0
- package/dist/toolbar/toolbar.css +12 -0
- package/dist/toolbar/toolbar.script.ts +123 -0
- package/dist/toolbar/toolbar.tsx +180 -0
- package/dist/tooltip/tooltip.css +8 -0
- package/dist/tooltip/tooltip.script.ts +222 -0
- package/dist/tooltip/tooltip.tsx +149 -0
- package/docs/README.md +174 -0
- package/docs/accordion.md +208 -0
- package/docs/autocomplete.md +161 -0
- package/docs/avatar.md +198 -0
- package/docs/button.md +98 -0
- package/docs/checkbox-group.md +112 -0
- package/docs/checkbox.md +133 -0
- package/docs/collapsible.md +165 -0
- package/docs/combobox.md +369 -0
- package/docs/context-menu.md +197 -0
- package/docs/dialog.md +256 -0
- package/docs/drawer.md +204 -0
- package/docs/field.md +295 -0
- package/docs/fieldset.md +92 -0
- package/docs/form.md +131 -0
- package/docs/input.md +87 -0
- package/docs/installation.md +65 -0
- package/docs/llms-full.txt +6923 -0
- package/docs/llms.txt +53 -0
- package/docs/menu.md +365 -0
- package/docs/menubar.md +186 -0
- package/docs/meter.md +103 -0
- package/docs/navigation-menu.md +257 -0
- package/docs/number-field.md +181 -0
- package/docs/otp-field.md +160 -0
- package/docs/popover.md +271 -0
- package/docs/preview-card.md +130 -0
- package/docs/progress.md +115 -0
- package/docs/radio-group.md +127 -0
- package/docs/scroll-area.md +147 -0
- package/docs/select.md +236 -0
- package/docs/separator.md +116 -0
- package/docs/slider.md +184 -0
- package/docs/switch.md +98 -0
- package/docs/tabs.md +254 -0
- package/docs/theming.md +132 -0
- package/docs/toast.md +311 -0
- package/docs/toggle-group.md +119 -0
- package/docs/toggle.md +94 -0
- package/docs/toolbar.md +138 -0
- package/docs/tooltip.md +128 -0
- package/package.json +102 -3
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Places an anchored popup next to its trigger where CSS anchor positioning is missing (Firefox
|
|
2
|
+
// before 147, Safari before 26): the same sides, alignment, offsets and flips as placement.css,
|
|
3
|
+
// from the popup's `data-side` and `data-align`. Client scripts call it while a popup is open. A
|
|
4
|
+
// popup taller than the room on both sides takes the roomier one, and `--lu-available-height`
|
|
5
|
+
// caps it there (placement.css lets it scroll, TASK-428).
|
|
6
|
+
//
|
|
7
|
+
// It also watches the trigger of every open anchored popover (not a modal one): scrolled out of
|
|
8
|
+
// view, of the page or of a scroller around it, the popup gets `data-anchor-hidden`, which
|
|
9
|
+
// placement.css hides until the trigger is back (TASK-453 L28). CSS alone (`position-visibility:
|
|
10
|
+
// anchors-visible`) covers inner scrollers, and in Chromium only. The trigger is the element whose
|
|
11
|
+
// `anchor-name` the popup's `position-anchor` names, or the one `place` was given: under a strict
|
|
12
|
+
// CSP a browser without anchor positioning cannot read either name (TASK-475).
|
|
13
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
14
|
+
|
|
15
|
+
export const anchoring = typeof CSS !== "undefined" && CSS.supports("anchor-name: --a");
|
|
16
|
+
|
|
17
|
+
const px = (style: CSSStyleDeclaration, name: string) =>
|
|
18
|
+
Number.parseFloat(style.getPropertyValue(name)) || 0;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sets the popup's position from its trigger's box, in viewport pixels. An open popover also
|
|
22
|
+
* starts watching the trigger, which its `anchor-name` may not name where the browser lacks
|
|
23
|
+
* anchor positioning and a strict CSP moved `style` into a stylesheet (TASK-475).
|
|
24
|
+
*/
|
|
25
|
+
export function place(popup: HTMLElement, trigger: Element) {
|
|
26
|
+
if (trigger instanceof Element && popup.matches("[popover][data-side]:popover-open")) {
|
|
27
|
+
watch(popup, trigger);
|
|
28
|
+
}
|
|
29
|
+
const style = getComputedStyle(popup);
|
|
30
|
+
const rtl = style.direction === "rtl";
|
|
31
|
+
const gap = px(style, "--lu-side-offset");
|
|
32
|
+
const shift = px(style, "--lu-align-offset");
|
|
33
|
+
const align = popup.dataset.align ?? "center";
|
|
34
|
+
let side = popup.dataset.side ?? "bottom";
|
|
35
|
+
if (side === "inline-start") side = rtl ? "right" : "left";
|
|
36
|
+
if (side === "inline-end") side = rtl ? "left" : "right";
|
|
37
|
+
|
|
38
|
+
const a = trigger.getBoundingClientRect();
|
|
39
|
+
// Measured at its full height: the room it is given below comes from where it goes.
|
|
40
|
+
popup.style.removeProperty("--lu-available-height");
|
|
41
|
+
const { offsetWidth: w } = popup;
|
|
42
|
+
let h = popup.offsetHeight;
|
|
43
|
+
const { innerWidth: vw, innerHeight: vh } = window;
|
|
44
|
+
const below = vh - a.bottom - gap;
|
|
45
|
+
const above = a.top - gap;
|
|
46
|
+
// Flip to the opposite side when the popup does not fit and fits there, or has more room.
|
|
47
|
+
if (side === "bottom" && h > below && (h <= above || above > below)) side = "top";
|
|
48
|
+
else if (side === "top" && h > above && (h <= below || below > above)) side = "bottom";
|
|
49
|
+
else if (side === "right" && a.right + gap + w > vw && a.left - gap - w >= 0) side = "left";
|
|
50
|
+
else if (side === "left" && a.left - gap - w < 0 && a.right + gap + w <= vw) side = "right";
|
|
51
|
+
// Beside the trigger on neither side: below it, or above it, centred.
|
|
52
|
+
let centred = align === "center";
|
|
53
|
+
if ((side === "left" || side === "right") && a.left - gap - w < 0 && a.right + gap + w > vw) {
|
|
54
|
+
side = a.bottom + gap + h <= vh || a.top - gap - h < 0 ? "bottom" : "top";
|
|
55
|
+
centred = true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let x: number;
|
|
59
|
+
let y: number;
|
|
60
|
+
if (side === "top" || side === "bottom") {
|
|
61
|
+
const room = side === "bottom" ? below : above;
|
|
62
|
+
if (h > room) {
|
|
63
|
+
popup.style.setProperty("--lu-available-height", `${Math.max(room, 0)}px`);
|
|
64
|
+
h = popup.offsetHeight;
|
|
65
|
+
}
|
|
66
|
+
y = side === "bottom" ? a.bottom + gap : a.top - gap - h;
|
|
67
|
+
const start = rtl ? a.right - w - shift : a.left + shift;
|
|
68
|
+
const end = rtl ? a.left + shift : a.right - w - shift;
|
|
69
|
+
x = centred ? a.left + (a.width - w) / 2 + shift : align === "start" ? start : end;
|
|
70
|
+
x = Math.min(Math.max(x, 0), vw - w);
|
|
71
|
+
} else {
|
|
72
|
+
x = side === "right" ? a.right + gap : a.left - gap - w;
|
|
73
|
+
y =
|
|
74
|
+
align === "start"
|
|
75
|
+
? a.top + shift
|
|
76
|
+
: align === "end"
|
|
77
|
+
? a.bottom - h - shift
|
|
78
|
+
: a.top + (a.height - h) / 2 + shift;
|
|
79
|
+
if (h > vh) {
|
|
80
|
+
popup.style.setProperty("--lu-available-height", `${vh}px`);
|
|
81
|
+
h = popup.offsetHeight;
|
|
82
|
+
}
|
|
83
|
+
y = Math.min(Math.max(y, 0), vh - h);
|
|
84
|
+
}
|
|
85
|
+
Object.assign(popup.style, { inset: "auto", margin: "0", left: `${x}px`, top: `${y}px` });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The open popups that each watched trigger anchors. */
|
|
89
|
+
const anchored = new Map<Element, Set<HTMLElement>>();
|
|
90
|
+
let watcher: IntersectionObserver | undefined;
|
|
91
|
+
|
|
92
|
+
/** The value of `property` in `element`'s style attribute, read raw: a browser may not know it. */
|
|
93
|
+
const inline = (element: Element, property: string) =>
|
|
94
|
+
new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`)
|
|
95
|
+
.exec(element.getAttribute("style") ?? "")?.[1]
|
|
96
|
+
?.trim();
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The value of `property` on `element`: from its style attribute, else computed. Under a strict
|
|
100
|
+
* CSP the server moves `style` into a stylesheet rule (`data-l-v`), which only a browser that
|
|
101
|
+
* knows the property keeps (TASK-475).
|
|
102
|
+
*/
|
|
103
|
+
const declared = (element: Element, property: string) =>
|
|
104
|
+
inline(element, property) || getComputedStyle(element).getPropertyValue(property).trim();
|
|
105
|
+
|
|
106
|
+
/** The trigger whose `anchor-name` the popup's `position-anchor` names. */
|
|
107
|
+
function anchorOf(popup: HTMLElement): Element | undefined {
|
|
108
|
+
const name = declared(popup, "position-anchor");
|
|
109
|
+
if (!name || name === "auto") return undefined;
|
|
110
|
+
return [...document.querySelectorAll("[style*='anchor-name'], [data-l-v]")].find(
|
|
111
|
+
(element) => element !== popup && declared(element, "anchor-name") === name,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Watches `anchor` while `popup` is open: off screen, it hides the popup. */
|
|
116
|
+
function watch(popup: HTMLElement, anchor: Element): void {
|
|
117
|
+
if (typeof IntersectionObserver !== "function" || anchored.get(anchor)?.has(popup)) return;
|
|
118
|
+
watcher ??= new IntersectionObserver((entries) => {
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
for (const each of anchored.get(entry.target) ?? []) {
|
|
121
|
+
each.toggleAttribute("data-anchor-hidden", !entry.isIntersecting);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const popups = anchored.get(anchor) ?? new Set();
|
|
126
|
+
popups.add(popup);
|
|
127
|
+
anchored.set(anchor, popups);
|
|
128
|
+
watcher.observe(anchor);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function onToggle(event: Event): void {
|
|
132
|
+
const popup = event.target;
|
|
133
|
+
if (!(popup instanceof HTMLElement) || !popup.matches("[popover][data-side]")) return;
|
|
134
|
+
if ((event as ToggleEvent).newState === "open") {
|
|
135
|
+
const anchor = anchorOf(popup);
|
|
136
|
+
if (anchor) watch(popup, anchor);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
popup.removeAttribute("data-anchor-hidden");
|
|
140
|
+
for (const [anchor, popups] of anchored) {
|
|
141
|
+
if (!popups.delete(popup) || popups.size > 0) continue;
|
|
142
|
+
anchored.delete(anchor);
|
|
143
|
+
watcher?.unobserve(anchor);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// `toggle` does not bubble: the capture phase sees every popup's.
|
|
148
|
+
if (typeof document !== "undefined") document.addEventListener("toggle", onToggle, true);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Anchored popups (Popover, Tooltip): a `[popover]`, or a modal popover's `<dialog>`, with
|
|
3
|
+
* `data-side` and `data-align` is placed next to the trigger its inline `position-anchor` names,
|
|
4
|
+
* with CSS anchor positioning, from the server HTML on. It flips to the opposite side when it does
|
|
5
|
+
* not fit, and hides while its trigger is scrolled out of view (`data-anchor-hidden`, which the
|
|
6
|
+
* scripts' place.ts sets, as CSS covers inner scrollers in Chromium only); one beside the trigger that fits
|
|
7
|
+
* on neither side goes below or above it. A popup taller than the space on both sides takes the
|
|
8
|
+
* side with more room and shrinks to it, from its trigger to the viewport's edge, and scrolls
|
|
9
|
+
* (TASK-428). `--lu-side-offset` is the gap to the trigger and
|
|
10
|
+
* `--lu-align-offset` a shift along the side (the `sideOffset` and `alignOffset` props set them; a
|
|
11
|
+
* theme may too). Without anchor positioning the popup's client script places it instead
|
|
12
|
+
* (placement/place.ts), and writes the room it has as `--lu-available-height`.
|
|
13
|
+
*/
|
|
14
|
+
@layer lu.core, lu.theme;
|
|
15
|
+
|
|
16
|
+
@layer lu.core {
|
|
17
|
+
/* Too tall for the room beside its trigger, a popup scrolls. */
|
|
18
|
+
:is([popover], dialog)[data-side] {
|
|
19
|
+
max-block-size: var(--lu-available-height, none);
|
|
20
|
+
overflow: auto;
|
|
21
|
+
}
|
|
22
|
+
/* Its trigger scrolled out of view, a popover waits, hidden (place.ts sets it, TASK-453 L28). */
|
|
23
|
+
[popover][data-side][data-anchor-hidden] {
|
|
24
|
+
visibility: hidden;
|
|
25
|
+
}
|
|
26
|
+
@supports (anchor-name: --a) {
|
|
27
|
+
/*
|
|
28
|
+
* The fallbacks: the flips while the popup fits at its size, then the popup shrunk to the
|
|
29
|
+
* space below or above its trigger (one with at least 8rem, or 40% of the viewport, of room).
|
|
30
|
+
*/
|
|
31
|
+
:is([popover], dialog)[data-side] {
|
|
32
|
+
inset: auto;
|
|
33
|
+
margin: 0;
|
|
34
|
+
position-try-fallbacks:
|
|
35
|
+
flip-block,
|
|
36
|
+
flip-inline,
|
|
37
|
+
flip-block flip-inline,
|
|
38
|
+
--lu-fit-below,
|
|
39
|
+
--lu-fit-above;
|
|
40
|
+
position-visibility: anchors-visible;
|
|
41
|
+
}
|
|
42
|
+
/*
|
|
43
|
+
* A popup beside the trigger that does not fit tries the other side, then the other block-axis
|
|
44
|
+
* alignment, then below the trigger, then above it, then shrunk beside it, below or above.
|
|
45
|
+
*/
|
|
46
|
+
:is([popover], dialog)[data-side="left"],
|
|
47
|
+
:is([popover], dialog)[data-side="right"],
|
|
48
|
+
:is([popover], dialog)[data-side^="inline-"] {
|
|
49
|
+
position-try-fallbacks:
|
|
50
|
+
flip-inline,
|
|
51
|
+
flip-block,
|
|
52
|
+
flip-block flip-inline,
|
|
53
|
+
--lu-below,
|
|
54
|
+
--lu-above,
|
|
55
|
+
--lu-fit-start,
|
|
56
|
+
--lu-fit-end,
|
|
57
|
+
--lu-fit-below,
|
|
58
|
+
--lu-fit-above;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/* The side. */
|
|
62
|
+
:is([popover], dialog)[data-side="bottom"] {
|
|
63
|
+
top: calc(anchor(bottom) + var(--lu-side-offset, 0px));
|
|
64
|
+
}
|
|
65
|
+
:is([popover], dialog)[data-side="top"] {
|
|
66
|
+
bottom: calc(anchor(top) + var(--lu-side-offset, 0px));
|
|
67
|
+
}
|
|
68
|
+
:is([popover], dialog)[data-side="right"] {
|
|
69
|
+
left: calc(anchor(right) + var(--lu-side-offset, 0px));
|
|
70
|
+
}
|
|
71
|
+
:is([popover], dialog)[data-side="left"] {
|
|
72
|
+
right: calc(anchor(left) + var(--lu-side-offset, 0px));
|
|
73
|
+
}
|
|
74
|
+
:is([popover], dialog)[data-side="inline-end"] {
|
|
75
|
+
inset-inline-start: calc(anchor(self-end) + var(--lu-side-offset, 0px));
|
|
76
|
+
}
|
|
77
|
+
:is([popover], dialog)[data-side="inline-start"] {
|
|
78
|
+
inset-inline-end: calc(anchor(self-start) + var(--lu-side-offset, 0px));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* The alignment along a top or bottom side: the inline axis, which follows the direction. */
|
|
82
|
+
:is([popover], dialog):is([data-side="top"], [data-side="bottom"])[data-align="center"] {
|
|
83
|
+
justify-self: anchor-center;
|
|
84
|
+
translate: var(--lu-align-offset, 0px);
|
|
85
|
+
}
|
|
86
|
+
:is([popover], dialog):is([data-side="top"], [data-side="bottom"])[data-align="start"] {
|
|
87
|
+
inset-inline-start: calc(anchor(self-start) + var(--lu-align-offset, 0px));
|
|
88
|
+
}
|
|
89
|
+
:is([popover], dialog):is([data-side="top"], [data-side="bottom"])[data-align="end"] {
|
|
90
|
+
inset-inline-end: calc(anchor(self-end) + var(--lu-align-offset, 0px));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* The alignment along a left, right or inline side: the block axis. */
|
|
94
|
+
:is([popover], dialog):not([data-side="top"], [data-side="bottom"])[data-align="center"] {
|
|
95
|
+
align-self: anchor-center;
|
|
96
|
+
translate: 0 var(--lu-align-offset, 0px);
|
|
97
|
+
}
|
|
98
|
+
:is([popover], dialog):not([data-side="top"], [data-side="bottom"])[data-align="start"] {
|
|
99
|
+
top: calc(anchor(top) + var(--lu-align-offset, 0px));
|
|
100
|
+
}
|
|
101
|
+
:is([popover], dialog):not([data-side="top"], [data-side="bottom"])[data-align="end"] {
|
|
102
|
+
bottom: calc(anchor(bottom) + var(--lu-align-offset, 0px));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@position-try --lu-below {
|
|
108
|
+
inset: auto;
|
|
109
|
+
top: calc(anchor(bottom) + var(--lu-side-offset, 0px));
|
|
110
|
+
justify-self: anchor-center;
|
|
111
|
+
align-self: auto;
|
|
112
|
+
}
|
|
113
|
+
@position-try --lu-above {
|
|
114
|
+
inset: auto;
|
|
115
|
+
bottom: calc(anchor(top) + var(--lu-side-offset, 0px));
|
|
116
|
+
justify-self: anchor-center;
|
|
117
|
+
align-self: auto;
|
|
118
|
+
}
|
|
119
|
+
/*
|
|
120
|
+
* Shrunk to the room from the trigger to the viewport's edge: stretched between the two insets (an
|
|
121
|
+
* auto block size, not the `fit-content` of the popover and dialog UA styles), the popup scrolls
|
|
122
|
+
* (TASK-428). Tried only after the popup fitted nowhere at its own size, and only where it keeps
|
|
123
|
+
* a usable size: a side with too little room fails, for the other one. (Not `position-try-order:
|
|
124
|
+
* most-block-size`: Chromium sorts the base position in with the fallbacks, and moves popups that
|
|
125
|
+
* fit.)
|
|
126
|
+
*/
|
|
127
|
+
@position-try --lu-fit-below {
|
|
128
|
+
top: calc(anchor(bottom) + var(--lu-side-offset, 0px));
|
|
129
|
+
bottom: 0;
|
|
130
|
+
align-self: normal;
|
|
131
|
+
block-size: auto;
|
|
132
|
+
min-block-size: min(8rem, 40dvh);
|
|
133
|
+
}
|
|
134
|
+
@position-try --lu-fit-above {
|
|
135
|
+
top: 0;
|
|
136
|
+
bottom: calc(anchor(top) + var(--lu-side-offset, 0px));
|
|
137
|
+
align-self: normal;
|
|
138
|
+
block-size: auto;
|
|
139
|
+
min-block-size: min(8rem, 40dvh);
|
|
140
|
+
}
|
|
141
|
+
/* Beside the trigger, from its top (or bottom) to the viewport's edge. */
|
|
142
|
+
@position-try --lu-fit-start {
|
|
143
|
+
top: calc(anchor(top) + var(--lu-align-offset, 0px));
|
|
144
|
+
bottom: 0;
|
|
145
|
+
align-self: normal;
|
|
146
|
+
block-size: auto;
|
|
147
|
+
min-block-size: min(8rem, 40dvh);
|
|
148
|
+
}
|
|
149
|
+
@position-try --lu-fit-end {
|
|
150
|
+
top: 0;
|
|
151
|
+
bottom: calc(anchor(bottom) + var(--lu-align-offset, 0px));
|
|
152
|
+
align-self: normal;
|
|
153
|
+
block-size: auto;
|
|
154
|
+
min-block-size: min(8rem, 40dvh);
|
|
155
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Placement of the popups anchored to a trigger (Popover, Tooltip; decision-62: placement props on
|
|
2
|
+
// `Popup`, no `Positioner`). The trigger gets an anchor name and the popup points at it, both in
|
|
3
|
+
// inline styles, so CSS anchor positioning places the popup from the server HTML on; placement.css
|
|
4
|
+
// reads the popup's `data-side` and `data-align` and the offsets' custom properties, in `lu.core`.
|
|
5
|
+
// Browsers without anchor positioning get the same placement from ./place.ts.
|
|
6
|
+
|
|
7
|
+
/** The trigger's side the popup goes on. `inline-start` and `inline-end` follow the direction. */
|
|
8
|
+
export type Side = "top" | "bottom" | "left" | "right" | "inline-start" | "inline-end";
|
|
9
|
+
/** How the popup aligns with the trigger along that side. */
|
|
10
|
+
export type Align = "start" | "center" | "end";
|
|
11
|
+
|
|
12
|
+
/** Where an anchored popup goes next to its trigger: the placement props of a `Popup`. */
|
|
13
|
+
export interface PlacementProps {
|
|
14
|
+
/** The trigger's side the popup goes on. It flips to the opposite side when it does not fit. */
|
|
15
|
+
side?: Side;
|
|
16
|
+
/** How the popup aligns with the trigger along that side. Default `"center"`. */
|
|
17
|
+
align?: Align;
|
|
18
|
+
/** The gap between the trigger and the popup, in CSS pixels. */
|
|
19
|
+
sideOffset?: number;
|
|
20
|
+
/** A shift along the side, away from the aligned edge, in CSS pixels. */
|
|
21
|
+
alignOffset?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Style = string | JSX.CSSProperties | undefined;
|
|
25
|
+
|
|
26
|
+
/** The anchor name that ties a popup to its trigger, from the id the parts share. */
|
|
27
|
+
export const anchorName = (id: string) => `--lu-${id}`;
|
|
28
|
+
|
|
29
|
+
/** `style` with `extra` in front, so the caller's own declarations win. */
|
|
30
|
+
export function withStyle(style: Style, extra: JSX.CSSProperties): string | JSX.CSSProperties {
|
|
31
|
+
if (typeof style === "string") {
|
|
32
|
+
const ours = Object.entries(extra)
|
|
33
|
+
.filter(([, value]) => value !== undefined)
|
|
34
|
+
.map(([name, value]) => `${name}: ${value}`)
|
|
35
|
+
.join("; ");
|
|
36
|
+
return `${ours}; ${style}`;
|
|
37
|
+
}
|
|
38
|
+
return { ...extra, ...style };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The anchored popup's attributes: its placement, and its anchor and offsets in `style`.
|
|
43
|
+
* `defaultSide` and `defaultAlign` apply when the props leave them out.
|
|
44
|
+
*/
|
|
45
|
+
export function placement(
|
|
46
|
+
props: PlacementProps & { style?: Style },
|
|
47
|
+
id: string,
|
|
48
|
+
defaultSide: Side,
|
|
49
|
+
defaultAlign: Align = "center",
|
|
50
|
+
) {
|
|
51
|
+
return {
|
|
52
|
+
"data-side": props.side ?? defaultSide,
|
|
53
|
+
"data-align": props.align ?? defaultAlign,
|
|
54
|
+
style: withStyle(props.style, {
|
|
55
|
+
"position-anchor": anchorName(id),
|
|
56
|
+
"--lu-side-offset": props.sideOffset === undefined ? undefined : `${props.sideOffset}px`,
|
|
57
|
+
"--lu-align-offset": props.alignOffset === undefined ? undefined : `${props.alignOffset}px`,
|
|
58
|
+
}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Open-on-hover timing, shared by Popover's hover triggers (TASK-394) and Preview Card (TASK-381):
|
|
2
|
+
// a mouse or a pen resting on a trigger opens its popup after the trigger's `data-delay`, never
|
|
3
|
+
// touch; the popup stays open while the pointer is on the trigger or on it (WCAG 1.4.13:
|
|
4
|
+
// hoverable), and closes `data-close-delay` after the pointer left both, with at least a short
|
|
5
|
+
// grace to cross the gap between them. While the pointer moves from the trigger toward the popup,
|
|
6
|
+
// inside the triangle from where it left the trigger to the popup's near side (Base UI's safe
|
|
7
|
+
// polygon, TASK-410), the grace starts over at each move, so a slow or diagonal path gets there; a
|
|
8
|
+
// stop or a move out of the triangle lets it run out. A press before the delay cancels the
|
|
9
|
+
// opening. Each client script calls `hoverIntent` once with how its triggers and popups are found
|
|
10
|
+
// and shown.
|
|
11
|
+
// It must not import `lumiose`: server-rendered pages load it without the runtime.
|
|
12
|
+
|
|
13
|
+
/** How long a hover-opened popup waits for the pointer to cross the gap from its trigger. */
|
|
14
|
+
const GRACE = 100;
|
|
15
|
+
|
|
16
|
+
/** A kind of hover trigger: how a script finds its triggers and popups and shows them. */
|
|
17
|
+
export interface HoverSource {
|
|
18
|
+
/** The trigger at or around `node`, if it is one of this kind. */
|
|
19
|
+
triggerAt(node: Element): HTMLElement | undefined;
|
|
20
|
+
/** The popup `trigger` opens. */
|
|
21
|
+
popupOf(trigger: HTMLElement): HTMLElement | undefined;
|
|
22
|
+
/** Shows `popup` for `trigger`. */
|
|
23
|
+
show(trigger: HTMLElement, popup: HTMLElement): void;
|
|
24
|
+
/** Hides `popup`, unless something else (focus) keeps it open. */
|
|
25
|
+
hide(popup: HTMLElement, trigger: HTMLElement): void;
|
|
26
|
+
/** The delays when the trigger has no `data-delay` or `data-close-delay`, in ms. */
|
|
27
|
+
delay: number;
|
|
28
|
+
closeDelay: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface HoverIntent {
|
|
32
|
+
/** The open popups the hover (or `open`) opened, with their trigger, until `forget`. */
|
|
33
|
+
hovered: Map<HTMLElement, HTMLElement>;
|
|
34
|
+
/** Opens `trigger`'s popup after its delay (also used for keyboard focus). */
|
|
35
|
+
open(trigger: HTMLElement): void;
|
|
36
|
+
/** Closes `popup` after its trigger's close delay, unless the pointer comes back. */
|
|
37
|
+
close(popup: HTMLElement): void;
|
|
38
|
+
/** Cancels a pending close of `popup`. */
|
|
39
|
+
stay(popup: HTMLElement): void;
|
|
40
|
+
/** Cancels a pending opening. */
|
|
41
|
+
cancel(): void;
|
|
42
|
+
/** Stops treating `popup` as hover-opened: it no longer closes when the pointer leaves. */
|
|
43
|
+
forget(popup: HTMLElement): void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ms = (trigger: HTMLElement, name: string, fallback: number) => {
|
|
47
|
+
const value = Number(trigger.getAttribute(name) ?? Number.NaN);
|
|
48
|
+
return Number.isFinite(value) ? value : fallback;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Whether (px, py) is inside the triangle from (x, y) to the side of `box` that faces (x, y), or in
|
|
53
|
+
* the box.
|
|
54
|
+
*/
|
|
55
|
+
export function toward(box: DOMRect, x: number, y: number, px: number, py: number): boolean {
|
|
56
|
+
if (px >= box.left && px <= box.right && py >= box.top && py <= box.bottom) return true;
|
|
57
|
+
// The corners of the side that faces (x, y).
|
|
58
|
+
const [ax, ay, bx, by] =
|
|
59
|
+
y <= box.top
|
|
60
|
+
? [box.left, box.top, box.right, box.top]
|
|
61
|
+
: y >= box.bottom
|
|
62
|
+
? [box.left, box.bottom, box.right, box.bottom]
|
|
63
|
+
: x <= box.left
|
|
64
|
+
? [box.left, box.top, box.left, box.bottom]
|
|
65
|
+
: [box.right, box.top, box.right, box.bottom];
|
|
66
|
+
const cross = (x1: number, y1: number, x2: number, y2: number) =>
|
|
67
|
+
(x2 - x1) * (py - y1) - (y2 - y1) * (px - x1);
|
|
68
|
+
const signs = [cross(x, y, ax, ay), cross(ax, ay, bx, by), cross(bx, by, x, y)];
|
|
69
|
+
return !(signs.some((d) => d < 0) && signs.some((d) => d > 0));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const on = <K extends keyof DocumentEventMap>(
|
|
73
|
+
type: K,
|
|
74
|
+
listener: (event: DocumentEventMap[K]) => void,
|
|
75
|
+
) => document.addEventListener(type, listener, true);
|
|
76
|
+
|
|
77
|
+
export function hoverIntent(source: HoverSource): HoverIntent {
|
|
78
|
+
const hovered = new Map<HTMLElement, HTMLElement>();
|
|
79
|
+
let opening: { trigger: HTMLElement; timer: ReturnType<typeof setTimeout> } | undefined;
|
|
80
|
+
const closing = new Map<HTMLElement, ReturnType<typeof setTimeout>>();
|
|
81
|
+
/** Where the pointer left a hover-opened popup's trigger, while it heads for the popup. */
|
|
82
|
+
let corridor: { popup: HTMLElement; x: number; y: number } | undefined;
|
|
83
|
+
|
|
84
|
+
const stay = (popup: HTMLElement) => {
|
|
85
|
+
clearTimeout(closing.get(popup));
|
|
86
|
+
closing.delete(popup);
|
|
87
|
+
};
|
|
88
|
+
const cancel = () => {
|
|
89
|
+
clearTimeout(opening?.timer);
|
|
90
|
+
opening = undefined;
|
|
91
|
+
};
|
|
92
|
+
const open = (trigger: HTMLElement) => {
|
|
93
|
+
const popup = source.popupOf(trigger);
|
|
94
|
+
if (!popup) return;
|
|
95
|
+
if (popup.matches(":popover-open")) {
|
|
96
|
+
stay(popup);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (opening?.trigger === trigger) return;
|
|
100
|
+
cancel();
|
|
101
|
+
opening = {
|
|
102
|
+
trigger,
|
|
103
|
+
timer: setTimeout(
|
|
104
|
+
() => {
|
|
105
|
+
opening = undefined;
|
|
106
|
+
if (popup.matches(":popover-open")) return;
|
|
107
|
+
// Before it opens: the script's `toggle` handler may look it up.
|
|
108
|
+
hovered.set(popup, trigger);
|
|
109
|
+
source.show(trigger, popup);
|
|
110
|
+
},
|
|
111
|
+
ms(trigger, "data-delay", source.delay),
|
|
112
|
+
),
|
|
113
|
+
};
|
|
114
|
+
};
|
|
115
|
+
const close = (popup: HTMLElement) => {
|
|
116
|
+
const trigger = hovered.get(popup);
|
|
117
|
+
if (!trigger) return;
|
|
118
|
+
stay(popup);
|
|
119
|
+
const delay = Math.max(ms(trigger, "data-close-delay", source.closeDelay), GRACE);
|
|
120
|
+
closing.set(
|
|
121
|
+
popup,
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
closing.delete(popup);
|
|
124
|
+
if (hovered.has(popup) && popup.matches(":popover-open")) source.hide(popup, trigger);
|
|
125
|
+
}, delay),
|
|
126
|
+
);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
on("pointerover", (event) => {
|
|
130
|
+
if (event.pointerType === "touch") return;
|
|
131
|
+
const target = event.target as Element;
|
|
132
|
+
// Back on a hover-opened popup or its trigger: it stays open.
|
|
133
|
+
for (const [popup, trigger] of hovered) {
|
|
134
|
+
if (popup.contains(target) || trigger.contains(target)) stay(popup);
|
|
135
|
+
}
|
|
136
|
+
const trigger = source.triggerAt(target);
|
|
137
|
+
if (trigger && !(trigger as HTMLButtonElement).disabled) open(trigger);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
on("pointerout", (event) => {
|
|
141
|
+
if (event.pointerType === "touch") return;
|
|
142
|
+
const from = event.target as Node;
|
|
143
|
+
const to = event.relatedTarget as Node | null;
|
|
144
|
+
if (opening?.trigger.contains(from) && !opening.trigger.contains(to)) cancel();
|
|
145
|
+
for (const [popup, trigger] of hovered) {
|
|
146
|
+
const inside = (node: Node | null) =>
|
|
147
|
+
!!node && (popup.contains(node) || trigger.contains(node));
|
|
148
|
+
if (!inside(from) || inside(to)) continue;
|
|
149
|
+
close(popup);
|
|
150
|
+
if (trigger.contains(from)) corridor = { popup, x: event.clientX, y: event.clientY };
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// On the way to the popup: each move inside the triangle starts the grace over.
|
|
155
|
+
on("pointermove", (event) => {
|
|
156
|
+
if (!corridor || event.pointerType === "touch") return;
|
|
157
|
+
const { popup, x, y } = corridor;
|
|
158
|
+
// Arrived (the popup's `pointerover` cancelled the close), or closed already.
|
|
159
|
+
if (!closing.has(popup)) corridor = undefined;
|
|
160
|
+
else if (toward(popup.getBoundingClientRect(), x, y, event.clientX, event.clientY)) close(popup);
|
|
161
|
+
else corridor = undefined;
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// A press before the delay cancels the opening (a click acts as it would without hover).
|
|
165
|
+
on("pointerdown", cancel);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
hovered,
|
|
169
|
+
open,
|
|
170
|
+
close,
|
|
171
|
+
stay,
|
|
172
|
+
cancel,
|
|
173
|
+
forget: (popup) => {
|
|
174
|
+
stay(popup);
|
|
175
|
+
hovered.delete(popup);
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Popover needs no CSS to open, close or stack: the native popover does it. What it needs is its
|
|
3
|
+
* placement next to the trigger, shared with Tooltip (placement.css). A modal popover is a modal
|
|
4
|
+
* <dialog>: the page behind it does not scroll, as behind a Dialog (without shifting the layout
|
|
5
|
+
* where the page had a classic scrollbar), and it blocks the page without dimming it, as Base UI's
|
|
6
|
+
* does (style its `::backdrop` to dim the page).
|
|
7
|
+
*/
|
|
8
|
+
@layer lu.core, lu.theme;
|
|
9
|
+
|
|
10
|
+
@import "../placement/placement.css";
|
|
11
|
+
|
|
12
|
+
@layer lu.core {
|
|
13
|
+
:root:has(dialog[data-side]:modal) {
|
|
14
|
+
overflow: hidden;
|
|
15
|
+
scrollbar-gutter: stable;
|
|
16
|
+
}
|
|
17
|
+
dialog[data-side]::backdrop {
|
|
18
|
+
background: none;
|
|
19
|
+
}
|
|
20
|
+
}
|