@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/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
+ }
@@ -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-control-height);
445
- height: var(--rf-control-height);
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 path {
451
- stroke-dasharray: 15 85;
452
- animation:
453
- rf-button-spinner 2s linear infinite,
454
- rf-button-spinner-dash 4s ease-in-out infinite;
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
- stroke-dashoffset: -100;
471
+ transform: rotate(360deg);
460
472
  }
461
473
  }
462
474
 
463
- @keyframes rf-button-spinner-dash {
475
+ @keyframes rf-button-spinner-breathe {
464
476
  0%,
465
477
  100% {
466
- stroke-dasharray: 15 85;
478
+ stroke-dasharray: 0.01 49.99;
479
+ stroke-dashoffset: -25;
467
480
  }
468
481
  50% {
469
- stroke-dasharray: 40 60;
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 path {
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;
@@ -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;