@rtzll/reflow 0.3.0 → 0.5.0
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/dist/basic.js +3 -8
- package/dist/breadcrumb.css +10 -0
- package/dist/breadcrumb.js +38 -40
- package/dist/compositions.css +3 -3
- package/dist/drawer.css +240 -0
- package/dist/drawer.d.ts +45 -0
- package/dist/drawer.js +228 -0
- package/dist/foundations.css +27 -14
- package/dist/hover-card.d.ts +32 -0
- package/dist/hover-card.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/overlays.js +14 -2
- package/dist/popover.css +69 -0
- package/dist/popover.d.ts +38 -0
- package/dist/popover.js +57 -0
- package/dist/segmented-control.css +172 -0
- package/dist/segmented-control.d.ts +34 -0
- package/dist/segmented-control.js +38 -0
- package/dist/sidebar.css +14 -57
- package/dist/sidebar.js +9 -13
- package/dist/status-icon.d.ts +3 -0
- package/dist/status-icon.js +7 -0
- package/dist/styles.css +4 -0
- package/dist/systems.js +5 -1
- package/dist/toaster.css +268 -0
- package/dist/toaster.d.ts +79 -0
- package/dist/toaster.js +418 -0
- package/package.json +1 -1
package/dist/drawer.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
|
|
2
|
+
import { on, ref } from 'remix/component';
|
|
3
|
+
import { Button } from './basic.js';
|
|
4
|
+
import { Icon } from './icon.js';
|
|
5
|
+
// A release past this share of the panel's size, or faster than this many
|
|
6
|
+
// pixels per millisecond, closes it; anything less springs back.
|
|
7
|
+
const closeShare = 0.25;
|
|
8
|
+
const flickVelocity = 0.4;
|
|
9
|
+
const interactive = 'button, a, input, select, textarea, label, [contenteditable], [role="slider"]';
|
|
10
|
+
/**
|
|
11
|
+
* A panel that slides in from an edge of the screen, for navigation on small
|
|
12
|
+
* screens, filters, details or a short task. It is a modal dialog built on
|
|
13
|
+
* the native element, like Dialog: it traps focus, closes on Escape or a
|
|
14
|
+
* click on the backdrop, and returns focus to its trigger. It follows a drag
|
|
15
|
+
* toward its edge and closes past a quarter of its size or on a quick flick;
|
|
16
|
+
* a drag the other way resists.
|
|
17
|
+
*/
|
|
18
|
+
export function Drawer(handle) {
|
|
19
|
+
let dialog = null;
|
|
20
|
+
let invoker = null;
|
|
21
|
+
let internalOpen = handle.props.defaultOpen ?? false;
|
|
22
|
+
let pressedBackdrop = false;
|
|
23
|
+
let drag;
|
|
24
|
+
const id = handle.id;
|
|
25
|
+
const titleId = `${id}-title`;
|
|
26
|
+
const descriptionId = `${id}-description`;
|
|
27
|
+
const open = () => handle.props.open ?? internalOpen;
|
|
28
|
+
const side = () => handle.props.side ?? 'bottom';
|
|
29
|
+
const dismissible = () => handle.props.dismissible ?? true;
|
|
30
|
+
function requestOpen(next) {
|
|
31
|
+
if (open() === next)
|
|
32
|
+
return;
|
|
33
|
+
internalOpen = next;
|
|
34
|
+
handle.props.onOpenChange?.(next);
|
|
35
|
+
void handle.update();
|
|
36
|
+
}
|
|
37
|
+
function close() {
|
|
38
|
+
requestOpen(false);
|
|
39
|
+
}
|
|
40
|
+
function sync() {
|
|
41
|
+
if (!dialog?.isConnected || dialog.open === open())
|
|
42
|
+
return;
|
|
43
|
+
if (open()) {
|
|
44
|
+
setDrag(dialog, 0);
|
|
45
|
+
dialog.showModal();
|
|
46
|
+
}
|
|
47
|
+
else
|
|
48
|
+
dialog.close();
|
|
49
|
+
}
|
|
50
|
+
function setDrag(element, offset, size = 1) {
|
|
51
|
+
element.style.setProperty('--rf-drawer-drag', `${Math.round(offset)}px`);
|
|
52
|
+
element.style.setProperty('--rf-drawer-progress', String(Math.min(1, Math.max(0, offset / size))));
|
|
53
|
+
}
|
|
54
|
+
function outsidePanel(event, element) {
|
|
55
|
+
if (event.target !== element)
|
|
56
|
+
return false;
|
|
57
|
+
const rect = element.getBoundingClientRect();
|
|
58
|
+
return (event.clientX < rect.left ||
|
|
59
|
+
event.clientX > rect.right ||
|
|
60
|
+
event.clientY < rect.top ||
|
|
61
|
+
event.clientY > rect.bottom);
|
|
62
|
+
}
|
|
63
|
+
// Whether the content under the pointer would scroll instead: a drag
|
|
64
|
+
// toward the edge belongs to a scroller that is not at its start.
|
|
65
|
+
function scrollsFirst(target, element) {
|
|
66
|
+
const vertical = side() === 'bottom' || side() === 'top';
|
|
67
|
+
for (let node = target; node; node = node.parentElement) {
|
|
68
|
+
if (node instanceof HTMLElement) {
|
|
69
|
+
const style = getComputedStyle(node);
|
|
70
|
+
const overflow = vertical ? style.overflowY : style.overflowX;
|
|
71
|
+
if (/auto|scroll/.test(overflow)) {
|
|
72
|
+
if (side() === 'bottom' && node.scrollTop > 0)
|
|
73
|
+
return true;
|
|
74
|
+
if (side() === 'top' && node.scrollTop + node.clientHeight < node.scrollHeight - 1)
|
|
75
|
+
return true;
|
|
76
|
+
if (side() === 'right' && node.scrollLeft > 0)
|
|
77
|
+
return true;
|
|
78
|
+
if (side() === 'left' && node.scrollLeft + node.clientWidth < node.scrollWidth - 1)
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (node === element)
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
// How far the pointer has moved toward the drawer's edge, and across it.
|
|
88
|
+
function travel(event, start) {
|
|
89
|
+
const dx = event.clientX - start.x;
|
|
90
|
+
const dy = event.clientY - start.y;
|
|
91
|
+
switch (side()) {
|
|
92
|
+
case 'bottom':
|
|
93
|
+
return [dy, dx];
|
|
94
|
+
case 'top':
|
|
95
|
+
return [-dy, dx];
|
|
96
|
+
case 'right':
|
|
97
|
+
return [dx, dy];
|
|
98
|
+
case 'left':
|
|
99
|
+
return [-dx, dy];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function endDrag(event, element) {
|
|
103
|
+
if (!drag || drag.pointer !== event.pointerId)
|
|
104
|
+
return;
|
|
105
|
+
const current = drag;
|
|
106
|
+
drag = undefined;
|
|
107
|
+
if (!current.started)
|
|
108
|
+
return;
|
|
109
|
+
element.removeAttribute('data-dragging');
|
|
110
|
+
const now = performance.now();
|
|
111
|
+
// Held still before letting go, the throw has no speed left.
|
|
112
|
+
const velocity = now - current.time > 100
|
|
113
|
+
? 0
|
|
114
|
+
: (current.offset - current.previousOffset) /
|
|
115
|
+
Math.max(1, current.time - current.previousTime);
|
|
116
|
+
if (current.offset > 0 &&
|
|
117
|
+
(current.offset > current.size * closeShare || velocity > flickVelocity)) {
|
|
118
|
+
close();
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
setDrag(element, 0);
|
|
122
|
+
}
|
|
123
|
+
const dialogMix = ref((element, signal) => {
|
|
124
|
+
if (!(element instanceof HTMLDialogElement))
|
|
125
|
+
return;
|
|
126
|
+
dialog = element;
|
|
127
|
+
const options = { signal };
|
|
128
|
+
element.addEventListener('cancel', (event) => {
|
|
129
|
+
event.preventDefault();
|
|
130
|
+
if (dismissible())
|
|
131
|
+
close();
|
|
132
|
+
}, options);
|
|
133
|
+
element.addEventListener('close', () => {
|
|
134
|
+
if (element.open)
|
|
135
|
+
return;
|
|
136
|
+
close();
|
|
137
|
+
const target = invoker;
|
|
138
|
+
invoker = null;
|
|
139
|
+
if (target?.isConnected &&
|
|
140
|
+
element.ownerDocument.activeElement === element.ownerDocument.body)
|
|
141
|
+
target.focus({ preventScroll: true });
|
|
142
|
+
}, options);
|
|
143
|
+
element.addEventListener('pointerdown', (event) => {
|
|
144
|
+
pressedBackdrop = outsidePanel(event, element);
|
|
145
|
+
if (pressedBackdrop || !dismissible() || event.button !== 0)
|
|
146
|
+
return;
|
|
147
|
+
const target = event.target;
|
|
148
|
+
if (!(target instanceof Element) || target.closest(interactive))
|
|
149
|
+
return;
|
|
150
|
+
if (scrollsFirst(target, element))
|
|
151
|
+
return;
|
|
152
|
+
const rect = element.getBoundingClientRect();
|
|
153
|
+
const now = performance.now();
|
|
154
|
+
drag = {
|
|
155
|
+
pointer: event.pointerId,
|
|
156
|
+
x: event.clientX,
|
|
157
|
+
y: event.clientY,
|
|
158
|
+
started: false,
|
|
159
|
+
offset: 0,
|
|
160
|
+
size: side() === 'bottom' || side() === 'top' ? rect.height : rect.width,
|
|
161
|
+
time: now,
|
|
162
|
+
previousTime: now,
|
|
163
|
+
previousOffset: 0,
|
|
164
|
+
};
|
|
165
|
+
}, options);
|
|
166
|
+
element.addEventListener('pointermove', (event) => {
|
|
167
|
+
if (!drag || drag.pointer !== event.pointerId)
|
|
168
|
+
return;
|
|
169
|
+
const [along, across] = travel(event, drag);
|
|
170
|
+
if (!drag.started) {
|
|
171
|
+
if (Math.abs(along) < 4 && Math.abs(across) < 4)
|
|
172
|
+
return;
|
|
173
|
+
// Sideways first, it is a selection or a scroll, not a drag.
|
|
174
|
+
if (Math.abs(across) > Math.abs(along)) {
|
|
175
|
+
drag = undefined;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
drag.started = true;
|
|
179
|
+
element.setAttribute('data-dragging', '');
|
|
180
|
+
try {
|
|
181
|
+
element.setPointerCapture(event.pointerId);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Synthetic pointers have nothing to capture.
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Toward the edge it follows the pointer; away from it, it resists.
|
|
188
|
+
const offset = along >= 0 ? along : along / (1 + Math.abs(along) / 24);
|
|
189
|
+
drag.previousOffset = drag.offset;
|
|
190
|
+
drag.previousTime = drag.time;
|
|
191
|
+
drag.offset = offset;
|
|
192
|
+
drag.time = performance.now();
|
|
193
|
+
setDrag(element, offset, drag.size);
|
|
194
|
+
}, options);
|
|
195
|
+
element.addEventListener('pointerup', (event) => endDrag(event, element), options);
|
|
196
|
+
element.addEventListener('pointercancel', (event) => endDrag(event, element), options);
|
|
197
|
+
element.addEventListener('click', (event) => {
|
|
198
|
+
if (pressedBackdrop && outsidePanel(event, element) && dismissible())
|
|
199
|
+
close();
|
|
200
|
+
pressedBackdrop = false;
|
|
201
|
+
}, options);
|
|
202
|
+
signal.addEventListener('abort', () => {
|
|
203
|
+
if (dialog === element)
|
|
204
|
+
dialog = null;
|
|
205
|
+
}, { once: true });
|
|
206
|
+
});
|
|
207
|
+
return () => {
|
|
208
|
+
const props = handle.props;
|
|
209
|
+
handle.queueTask(sync);
|
|
210
|
+
const footer = typeof props.footer === 'function' ? props.footer(close) : props.footer;
|
|
211
|
+
const vertical = side() === 'bottom' || side() === 'top';
|
|
212
|
+
const showHandle = props.handle ?? (vertical && dismissible());
|
|
213
|
+
const showClose = props.showCloseButton ?? (!vertical && !props.flush);
|
|
214
|
+
const triggerProps = {
|
|
215
|
+
'aria-haspopup': 'dialog',
|
|
216
|
+
'aria-expanded': open(),
|
|
217
|
+
'aria-controls': id,
|
|
218
|
+
mix: on('click', (event) => {
|
|
219
|
+
if (event.currentTarget instanceof HTMLElement)
|
|
220
|
+
invoker = event.currentTarget;
|
|
221
|
+
requestOpen(true);
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
return (_jsxs(_Fragment, { children: [props.renderTrigger
|
|
225
|
+
? props.renderTrigger(triggerProps)
|
|
226
|
+
: props.trigger != null && (_jsx(Button, { variant: props.triggerVariant ?? 'tertiary', size: props.triggerSize, ...triggerProps, children: props.trigger })), _jsxs("dialog", { id: id, class: props.class ? `rf-drawer ${props.class}` : 'rf-drawer', "data-side": side(), "data-flush": props.flush || undefined, "aria-modal": "true", "aria-labelledby": titleId, "aria-describedby": props.description ? descriptionId : undefined, mix: dialogMix, children: [showHandle && _jsx("div", { class: "rf-drawer-handle", "aria-hidden": "true" }), _jsxs("header", { class: "rf-drawer-header", children: [_jsx("h2", { id: titleId, children: props.title }), props.description && _jsx("p", { id: descriptionId, children: props.description })] }), props.children != null && _jsx("div", { class: "rf-drawer-body", children: props.children }), footer != null && _jsx("footer", { class: "rf-drawer-footer", children: footer }), showClose && (_jsx(Button, { variant: "ghost", size: "icon-compact", class: "rf-drawer-close", "aria-label": "Close drawer", mix: on('click', close), children: _jsx(Icon, { name: "x", size: 14 }) }))] })] }));
|
|
227
|
+
};
|
|
228
|
+
}
|
package/dist/foundations.css
CHANGED
|
@@ -198,6 +198,12 @@
|
|
|
198
198
|
--rf-exit-fast: 60ms ease-out;
|
|
199
199
|
--rf-exit-moderate: 120ms ease-out;
|
|
200
200
|
--rf-exit-slow: 160ms ease-out;
|
|
201
|
+
/* Piles: QueuedStack and the Toaster share these. Each card behind the
|
|
202
|
+
front one peeks out by the peek and narrows by the scale step; spread
|
|
203
|
+
out, cards sit a gap apart. */
|
|
204
|
+
--rf-stack-peek: 6px;
|
|
205
|
+
--rf-stack-gap: 5px;
|
|
206
|
+
--rf-stack-scale-step: 0.035;
|
|
201
207
|
}
|
|
202
208
|
|
|
203
209
|
@media (prefers-reduced-motion: reduce) {
|
|
@@ -441,38 +447,45 @@
|
|
|
441
447
|
position: absolute;
|
|
442
448
|
top: 50%;
|
|
443
449
|
left: 50%;
|
|
444
|
-
width: var(--rf-
|
|
445
|
-
height: var(--rf-
|
|
450
|
+
width: var(--rf-icon-size);
|
|
451
|
+
height: var(--rf-icon-size);
|
|
446
452
|
transform: translate(-50%, -50%);
|
|
447
453
|
pointer-events: none;
|
|
448
454
|
}
|
|
449
455
|
|
|
450
|
-
.rf-button-spinner
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
456
|
+
.rf-button-spinner g {
|
|
457
|
+
transform-box: fill-box;
|
|
458
|
+
transform-origin: center;
|
|
459
|
+
animation: rf-button-spinner-turn 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.rf-button-spinner circle {
|
|
463
|
+
/* A nonzero dash keeps Safari's round caps visible as dots. */
|
|
464
|
+
stroke-dasharray: 0.01 49.99;
|
|
465
|
+
stroke-dashoffset: -25;
|
|
466
|
+
animation: rf-button-spinner-breathe 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
|
|
455
467
|
}
|
|
456
468
|
|
|
457
|
-
@keyframes rf-button-spinner {
|
|
469
|
+
@keyframes rf-button-spinner-turn {
|
|
458
470
|
to {
|
|
459
|
-
|
|
471
|
+
transform: rotate(360deg);
|
|
460
472
|
}
|
|
461
473
|
}
|
|
462
474
|
|
|
463
|
-
@keyframes rf-button-spinner-
|
|
475
|
+
@keyframes rf-button-spinner-breathe {
|
|
464
476
|
0%,
|
|
465
477
|
100% {
|
|
466
|
-
stroke-dasharray:
|
|
478
|
+
stroke-dasharray: 0.01 49.99;
|
|
479
|
+
stroke-dashoffset: -25;
|
|
467
480
|
}
|
|
468
481
|
50% {
|
|
469
|
-
stroke-dasharray:
|
|
482
|
+
stroke-dasharray: 26 24;
|
|
483
|
+
stroke-dashoffset: -12;
|
|
470
484
|
}
|
|
471
485
|
}
|
|
472
486
|
|
|
473
487
|
@media (prefers-reduced-motion: reduce) {
|
|
474
|
-
.rf-button-spinner
|
|
475
|
-
stroke-dasharray: none;
|
|
488
|
+
.rf-button-spinner :is(g, circle) {
|
|
476
489
|
animation: none;
|
|
477
490
|
}
|
|
478
491
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Handle, type MixInput, type RemixNode } from 'remix/component';
|
|
2
|
+
import type { PopoverPlacement } from './popover.tsx';
|
|
3
|
+
export interface HoverCardTriggerProps {
|
|
4
|
+
mix: MixInput<HTMLElement>;
|
|
5
|
+
}
|
|
6
|
+
export interface HoverCardProps {
|
|
7
|
+
/** The card's content. */
|
|
8
|
+
children?: RemixNode;
|
|
9
|
+
/** Renders the trigger, usually a link. Spread the provided props onto it. */
|
|
10
|
+
renderTrigger: (props: HoverCardTriggerProps) => RemixNode;
|
|
11
|
+
open?: boolean;
|
|
12
|
+
defaultOpen?: boolean;
|
|
13
|
+
onOpenChange?: (open: boolean) => void;
|
|
14
|
+
/** Which side of the trigger the card opens on. Default bottom. */
|
|
15
|
+
placement?: PopoverPlacement;
|
|
16
|
+
/** Distance from the trigger in pixels. Default 8. */
|
|
17
|
+
offset?: number;
|
|
18
|
+
/** Milliseconds the pointer rests on the trigger, or focus stays on it, before the card opens. Default 500. */
|
|
19
|
+
openDelay?: number;
|
|
20
|
+
/** Milliseconds after the pointer leaves the trigger and the card before it closes. Default 200. */
|
|
21
|
+
closeDelay?: number;
|
|
22
|
+
size?: 'default' | 'compact';
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A preview card for a link or a name, such as a profile or a page, shown
|
|
26
|
+
* while the pointer rests on its trigger or keyboard focus is on it. It is
|
|
27
|
+
* supplementary: the trigger still works on its own, a tap on a touch screen
|
|
28
|
+
* follows it, and the card never takes focus or locks scrolling. The pointer
|
|
29
|
+
* can cross into the card and use its links; it closes once the pointer has
|
|
30
|
+
* left both, or on Escape. It shares Popover's surface and motion.
|
|
31
|
+
*/
|
|
32
|
+
export declare function HoverCard(handle: Handle<HoverCardProps>): () => import("remix/component").RemixElement;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
|
|
2
|
+
import { ref } from 'remix/component';
|
|
3
|
+
import { anchor } from '@remix-run/ui/anchor';
|
|
4
|
+
/**
|
|
5
|
+
* A preview card for a link or a name, such as a profile or a page, shown
|
|
6
|
+
* while the pointer rests on its trigger or keyboard focus is on it. It is
|
|
7
|
+
* supplementary: the trigger still works on its own, a tap on a touch screen
|
|
8
|
+
* follows it, and the card never takes focus or locks scrolling. The pointer
|
|
9
|
+
* can cross into the card and use its links; it closes once the pointer has
|
|
10
|
+
* left both, or on Escape. It shares Popover's surface and motion.
|
|
11
|
+
*/
|
|
12
|
+
export function HoverCard(handle) {
|
|
13
|
+
let trigger = null;
|
|
14
|
+
let card = null;
|
|
15
|
+
let localOpen = handle.props.defaultOpen ?? false;
|
|
16
|
+
let stopAnchor;
|
|
17
|
+
let openTimer;
|
|
18
|
+
let closeTimer;
|
|
19
|
+
const isOpen = () => handle.props.open ?? localOpen;
|
|
20
|
+
function setOpen(next) {
|
|
21
|
+
clearTimeout(openTimer);
|
|
22
|
+
clearTimeout(closeTimer);
|
|
23
|
+
if (isOpen() === next)
|
|
24
|
+
return;
|
|
25
|
+
localOpen = next;
|
|
26
|
+
handle.props.onOpenChange?.(next);
|
|
27
|
+
void handle.update();
|
|
28
|
+
}
|
|
29
|
+
function scheduleOpen() {
|
|
30
|
+
clearTimeout(closeTimer);
|
|
31
|
+
if (isOpen())
|
|
32
|
+
return;
|
|
33
|
+
clearTimeout(openTimer);
|
|
34
|
+
openTimer = setTimeout(() => setOpen(true), Math.max(0, handle.props.openDelay ?? 500));
|
|
35
|
+
}
|
|
36
|
+
function scheduleClose() {
|
|
37
|
+
clearTimeout(openTimer);
|
|
38
|
+
clearTimeout(closeTimer);
|
|
39
|
+
if (!isOpen())
|
|
40
|
+
return;
|
|
41
|
+
closeTimer = setTimeout(() => setOpen(false), Math.max(0, handle.props.closeDelay ?? 200));
|
|
42
|
+
}
|
|
43
|
+
// Shows or hides the card to match the open state, after each render.
|
|
44
|
+
function sync() {
|
|
45
|
+
if (!trigger?.isConnected || !card?.isConnected)
|
|
46
|
+
return;
|
|
47
|
+
const showing = card.matches(':popover-open');
|
|
48
|
+
if (isOpen() && !showing) {
|
|
49
|
+
card.showPopover?.();
|
|
50
|
+
stopAnchor = anchor(card, trigger, {
|
|
51
|
+
placement: handle.props.placement ?? 'bottom',
|
|
52
|
+
offset: handle.props.offset ?? 8,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
else if (!isOpen() && showing) {
|
|
56
|
+
stopAnchor?.();
|
|
57
|
+
stopAnchor = undefined;
|
|
58
|
+
if (card.contains(document.activeElement))
|
|
59
|
+
trigger.focus({ preventScroll: true });
|
|
60
|
+
card.hidePopover?.();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const triggerMix = ref((element, signal) => {
|
|
64
|
+
if (!(element instanceof HTMLElement))
|
|
65
|
+
return;
|
|
66
|
+
trigger = element;
|
|
67
|
+
const options = { signal };
|
|
68
|
+
element.addEventListener('pointerenter', (event) => {
|
|
69
|
+
if (event.pointerType !== 'touch')
|
|
70
|
+
scheduleOpen();
|
|
71
|
+
}, options);
|
|
72
|
+
element.addEventListener('pointerleave', (event) => {
|
|
73
|
+
if (event.pointerType !== 'touch')
|
|
74
|
+
scheduleClose();
|
|
75
|
+
}, options);
|
|
76
|
+
element.addEventListener('focus', scheduleOpen, options);
|
|
77
|
+
element.addEventListener('blur', (event) => {
|
|
78
|
+
// Tabbing on into the card keeps it open.
|
|
79
|
+
if (!(event.relatedTarget instanceof Node && card?.contains(event.relatedTarget)))
|
|
80
|
+
scheduleClose();
|
|
81
|
+
}, options);
|
|
82
|
+
element.ownerDocument.addEventListener('keydown', (event) => {
|
|
83
|
+
if (event.key !== 'Escape' || !isOpen())
|
|
84
|
+
return;
|
|
85
|
+
setOpen(false);
|
|
86
|
+
}, options);
|
|
87
|
+
signal.addEventListener('abort', () => {
|
|
88
|
+
clearTimeout(openTimer);
|
|
89
|
+
clearTimeout(closeTimer);
|
|
90
|
+
stopAnchor?.();
|
|
91
|
+
if (trigger === element)
|
|
92
|
+
trigger = null;
|
|
93
|
+
}, { once: true });
|
|
94
|
+
});
|
|
95
|
+
const cardMix = ref((element, signal) => {
|
|
96
|
+
if (!(element instanceof HTMLElement))
|
|
97
|
+
return;
|
|
98
|
+
card = element;
|
|
99
|
+
const options = { signal };
|
|
100
|
+
element.addEventListener('pointerenter', () => clearTimeout(closeTimer), options);
|
|
101
|
+
element.addEventListener('pointerleave', (event) => {
|
|
102
|
+
if (event.pointerType !== 'touch')
|
|
103
|
+
scheduleClose();
|
|
104
|
+
}, options);
|
|
105
|
+
element.addEventListener('focusout', (event) => {
|
|
106
|
+
const next = event.relatedTarget;
|
|
107
|
+
if (!(next instanceof Node && (element.contains(next) || next === trigger)))
|
|
108
|
+
scheduleClose();
|
|
109
|
+
}, options);
|
|
110
|
+
signal.addEventListener('abort', () => {
|
|
111
|
+
if (card === element)
|
|
112
|
+
card = null;
|
|
113
|
+
}, { once: true });
|
|
114
|
+
});
|
|
115
|
+
return () => {
|
|
116
|
+
handle.queueTask(sync);
|
|
117
|
+
return (_jsxs(_Fragment, { children: [handle.props.renderTrigger({ mix: triggerMix }), _jsx("div", { class: "rf-menu-surface rf-popover-surface rf-hover-card", popover: "manual", "data-density": handle.props.size, mix: cardMix, children: handle.props.children })] }));
|
|
118
|
+
};
|
|
119
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,11 @@ export * from './flows.tsx';
|
|
|
10
10
|
export * from './range-color.tsx';
|
|
11
11
|
export * from './color-picker.tsx';
|
|
12
12
|
export * from './breadcrumb.tsx';
|
|
13
|
+
export * from './segmented-control.tsx';
|
|
14
|
+
export * from './popover.tsx';
|
|
15
|
+
export * from './hover-card.tsx';
|
|
16
|
+
export * from './toaster.tsx';
|
|
17
|
+
export * from './drawer.tsx';
|
|
13
18
|
export * from './systems.ts';
|
|
14
19
|
export * from './queued-stack.tsx';
|
|
15
20
|
export * from './scroll-area.tsx';
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,11 @@ export * from './flows.js';
|
|
|
10
10
|
export * from './range-color.js';
|
|
11
11
|
export * from './color-picker.js';
|
|
12
12
|
export * from './breadcrumb.js';
|
|
13
|
+
export * from './segmented-control.js';
|
|
14
|
+
export * from './popover.js';
|
|
15
|
+
export * from './hover-card.js';
|
|
16
|
+
export * from './toaster.js';
|
|
17
|
+
export * from './drawer.js';
|
|
13
18
|
export * from './systems.js';
|
|
14
19
|
export * from './queued-stack.js';
|
|
15
20
|
export * from './scroll-area.js';
|
package/dist/overlays.js
CHANGED
|
@@ -134,10 +134,14 @@ export function Tooltip(handle) {
|
|
|
134
134
|
internalOpen = next;
|
|
135
135
|
handle.props.onOpenChange?.(next);
|
|
136
136
|
}
|
|
137
|
+
function menuIsOpen() {
|
|
138
|
+
return (trigger?.getAttribute('aria-haspopup') === 'menu' &&
|
|
139
|
+
trigger.getAttribute('aria-expanded') === 'true');
|
|
140
|
+
}
|
|
137
141
|
// The tooltip is shown when `open` forces it, otherwise when hover or focus asks for it.
|
|
138
142
|
function sync() {
|
|
139
143
|
const visible = handle.props.open ?? internalOpen;
|
|
140
|
-
if (visible)
|
|
144
|
+
if (visible && !menuIsOpen())
|
|
141
145
|
open();
|
|
142
146
|
else
|
|
143
147
|
close();
|
|
@@ -150,7 +154,7 @@ export function Tooltip(handle) {
|
|
|
150
154
|
}
|
|
151
155
|
function show() {
|
|
152
156
|
clearTimeout(hideTimer);
|
|
153
|
-
if (!trigger?.isConnected || !popup?.isConnected || dismissed)
|
|
157
|
+
if (!trigger?.isConnected || !popup?.isConnected || dismissed || menuIsOpen())
|
|
154
158
|
return;
|
|
155
159
|
setInternal(true);
|
|
156
160
|
sync();
|
|
@@ -199,6 +203,14 @@ export function Tooltip(handle) {
|
|
|
199
203
|
if (!(element instanceof HTMLElement))
|
|
200
204
|
return;
|
|
201
205
|
trigger = element;
|
|
206
|
+
if (element.getAttribute('aria-haspopup') === 'menu') {
|
|
207
|
+
const observer = new MutationObserver(() => {
|
|
208
|
+
if (menuIsOpen())
|
|
209
|
+
hide();
|
|
210
|
+
});
|
|
211
|
+
observer.observe(element, { attributeFilter: ['aria-expanded'] });
|
|
212
|
+
signal.addEventListener('abort', () => observer.disconnect(), { once: true });
|
|
213
|
+
}
|
|
202
214
|
element.addEventListener('pointerenter', (event) => {
|
|
203
215
|
if (event.pointerType === 'touch')
|
|
204
216
|
return;
|
package/dist/popover.css
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Popover: a panel anchored to its trigger. It shares the menu surfaces'
|
|
2
|
+
elevation and motion: it drops 4px and grows from 96% on the fast spring,
|
|
3
|
+
toward the trigger's side, and leaves on the fast exit. */
|
|
4
|
+
.rf-popover {
|
|
5
|
+
display: inline-block;
|
|
6
|
+
max-inline-size: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rf-popover-surface {
|
|
10
|
+
gap: 12px;
|
|
11
|
+
inline-size: max-content;
|
|
12
|
+
min-inline-size: 200px;
|
|
13
|
+
max-inline-size: min(320px, calc(100vw - 16px));
|
|
14
|
+
padding: 12px;
|
|
15
|
+
border: 0;
|
|
16
|
+
outline: none;
|
|
17
|
+
background: var(--rf-surface-3, white);
|
|
18
|
+
box-shadow:
|
|
19
|
+
inset 0 1px light-dark(transparent, rgb(255 255 255 / 2%)),
|
|
20
|
+
inset 0 0 0 1px light-dark(transparent, rgb(255 255 255 / 2%)),
|
|
21
|
+
0 0 0 1px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 12%)),
|
|
22
|
+
0 1px 1px -0.5px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 18%)),
|
|
23
|
+
0 3px 3px -1.5px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 18%)),
|
|
24
|
+
0 12px 28px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 24%));
|
|
25
|
+
font-size: var(--rf-text-body, 13px);
|
|
26
|
+
line-height: var(--rf-leading-body, 20px);
|
|
27
|
+
}
|
|
28
|
+
/* Only while open: a display value on the closed panel would override the
|
|
29
|
+
browser's hiding, leaving an invisible panel that catches clicks. */
|
|
30
|
+
.rf-popover-surface:popover-open {
|
|
31
|
+
display: grid;
|
|
32
|
+
}
|
|
33
|
+
.rf-popover-surface[data-density='compact'] {
|
|
34
|
+
gap: 8px;
|
|
35
|
+
padding: 10px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Beside the trigger, the panel slides in sideways instead. */
|
|
39
|
+
.rf-popover-surface[data-anchor-placement^='left'] {
|
|
40
|
+
--rf-menu-rest-transform: translateX(4px) scaleX(0.96);
|
|
41
|
+
transform-origin: right;
|
|
42
|
+
}
|
|
43
|
+
.rf-popover-surface[data-anchor-placement^='right'] {
|
|
44
|
+
--rf-menu-rest-transform: translateX(-4px) scaleX(0.96);
|
|
45
|
+
transform-origin: left;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.rf-popover-header {
|
|
49
|
+
display: grid;
|
|
50
|
+
gap: 4px;
|
|
51
|
+
}
|
|
52
|
+
.rf-popover-title {
|
|
53
|
+
margin: 0;
|
|
54
|
+
color: var(--rf-fg);
|
|
55
|
+
font-size: inherit;
|
|
56
|
+
line-height: inherit;
|
|
57
|
+
font-variation-settings: var(--rf-weight-strong);
|
|
58
|
+
}
|
|
59
|
+
.rf-popover-description {
|
|
60
|
+
margin: 0;
|
|
61
|
+
color: var(--rf-muted);
|
|
62
|
+
font-size: var(--rf-text-caption, 12px);
|
|
63
|
+
line-height: var(--rf-leading-caption, 16px);
|
|
64
|
+
}
|
|
65
|
+
.rf-popover-body {
|
|
66
|
+
display: grid;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
min-inline-size: 0;
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Handle, type MixInput, type RemixNode } from 'remix/component';
|
|
2
|
+
import { type ButtonProps } from './basic.tsx';
|
|
3
|
+
export type PopoverPlacement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end';
|
|
4
|
+
export interface PopoverTriggerProps {
|
|
5
|
+
'aria-haspopup': 'dialog';
|
|
6
|
+
'aria-expanded': boolean;
|
|
7
|
+
'aria-controls': string;
|
|
8
|
+
mix: MixInput<HTMLElement>;
|
|
9
|
+
}
|
|
10
|
+
export interface PopoverProps {
|
|
11
|
+
/** Names the panel when it has no visible title. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** A visible heading that names the panel. */
|
|
14
|
+
title?: RemixNode;
|
|
15
|
+
description?: RemixNode;
|
|
16
|
+
children?: RemixNode;
|
|
17
|
+
/** Contents of the button that opens the panel. */
|
|
18
|
+
trigger?: RemixNode;
|
|
19
|
+
triggerVariant?: ButtonProps['variant'];
|
|
20
|
+
triggerSize?: ButtonProps['size'];
|
|
21
|
+
/** Renders your own trigger. Spread the provided props onto a focusable element. */
|
|
22
|
+
renderTrigger?: (props: PopoverTriggerProps) => RemixNode;
|
|
23
|
+
open?: boolean;
|
|
24
|
+
defaultOpen?: boolean;
|
|
25
|
+
onOpenChange?: (open: boolean) => void;
|
|
26
|
+
/** Which side of the trigger the panel opens on. Default bottom. */
|
|
27
|
+
placement?: PopoverPlacement;
|
|
28
|
+
/** Distance from the trigger in pixels. Default 8. */
|
|
29
|
+
offset?: number;
|
|
30
|
+
size?: 'default' | 'compact';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A panel anchored to a trigger, for content that needs more room than a
|
|
34
|
+
* tooltip: a filter, inline settings, a share sheet. A click opens it; Escape
|
|
35
|
+
* and a click outside close it. It is a non-modal dialog: focus moves to its
|
|
36
|
+
* first field, or to the panel, on open and returns to the trigger on close.
|
|
37
|
+
*/
|
|
38
|
+
export declare function Popover(handle: Handle<PopoverProps>): () => import("remix/component").RemixElement;
|