@rtzll/reflow 0.2.0 → 0.4.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/dates.css CHANGED
@@ -31,6 +31,42 @@
31
31
  'opsz' 18;
32
32
  }
33
33
 
34
+ /* With week numbers, the title belongs to the days: it starts past the
35
+ week column. */
36
+ .rf-calendar[data-week-numbers] .rf-calendar-header {
37
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75 + 8px);
38
+ }
39
+
40
+ /* Months side by side, as in the reference: each title centered over its
41
+ days, previous at the far left, next at the far right. */
42
+ .rf-calendar-header[data-centered] {
43
+ position: relative;
44
+ justify-content: center;
45
+ padding-inline: 0;
46
+ }
47
+ .rf-calendar[data-week-numbers] .rf-calendar-header[data-centered] {
48
+ padding-inline-start: calc(var(--rf-date-cell) * 0.75);
49
+ }
50
+ .rf-calendar-header[data-centered] .rf-calendar-months-label {
51
+ margin-inline-end: 0;
52
+ text-align: center;
53
+ }
54
+ .rf-calendar-header[data-centered] .rf-calendar-nav {
55
+ position: absolute;
56
+ inset-block-start: 0;
57
+ }
58
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='previous'] {
59
+ inset-inline-start: 0;
60
+ }
61
+ .rf-calendar[data-week-numbers]
62
+ .rf-calendar-header[data-centered]
63
+ .rf-calendar-nav[data-direction='previous'] {
64
+ inset-inline-start: calc(var(--rf-date-cell) * 0.75);
65
+ }
66
+ .rf-calendar-header[data-centered] .rf-calendar-nav[data-direction='next'] {
67
+ inset-inline-end: 0;
68
+ }
69
+
34
70
  .rf-calendar-nav,
35
71
  .rf-calendar-day {
36
72
  appearance: none;
@@ -102,6 +138,46 @@
102
138
  gap: 4px;
103
139
  }
104
140
 
141
+ /* Stacked months, as in the reference: one weekday row above them, kept in
142
+ view while a short popup scrolls, 16px between the months, and arrows that
143
+ page up and down. */
144
+ .rf-calendar[data-stacked] .rf-calendar-months {
145
+ gap: 16px;
146
+ }
147
+ .rf-calendar-weekdays {
148
+ position: sticky;
149
+ inset-block-start: 0;
150
+ z-index: 3;
151
+ display: flex;
152
+ margin-block-end: 16px;
153
+ background: inherit;
154
+ color: var(--rf-muted);
155
+ font-size: var(--rf-text-caption, 12px);
156
+ line-height: var(--rf-leading-caption, 16px);
157
+ }
158
+ .rf-calendar-weekdays > span {
159
+ display: grid;
160
+ flex: none;
161
+ place-items: center;
162
+ inline-size: var(--rf-date-cell);
163
+ block-size: calc(var(--rf-date-cell) * 0.8);
164
+ block-size: round(down, calc(var(--rf-date-cell) * 0.8), 1px);
165
+ }
166
+ .rf-calendar-weekdays > .rf-calendar-weekdays-week {
167
+ inline-size: calc(var(--rf-date-cell) * 0.75);
168
+ }
169
+ .rf-calendar[data-stacked] .rf-calendar-grid thead {
170
+ position: absolute;
171
+ inline-size: 1px;
172
+ block-size: 1px;
173
+ overflow: hidden;
174
+ clip-path: inset(50%);
175
+ white-space: nowrap;
176
+ }
177
+ .rf-calendar[data-stacked] .rf-calendar-nav svg {
178
+ rotate: 90deg;
179
+ }
180
+
105
181
  .rf-calendar-grid {
106
182
  border-collapse: separate;
107
183
  border-spacing: 0;
@@ -186,13 +262,42 @@
186
262
  color: var(--rf-bg);
187
263
  }
188
264
 
265
+ /* Picking a range's end, as in the reference: the proposed end is filled
266
+ like a selected day and glides to the pointer, with a faint ring 1px inside
267
+ it that goes solid while pressed. Its number draws above the fill. */
189
268
  .rf-calendar-preview-end {
190
269
  z-index: 1;
191
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
270
+ background: var(--rf-fg);
271
+ }
272
+ .rf-calendar-preview-end::before {
273
+ content: '';
274
+ position: absolute;
275
+ inset: 1px;
276
+ border: 1px solid var(--rf-bg);
277
+ border-radius: max(0px, calc(var(--rf-radius, 8px) - 1px));
278
+ opacity: 0.4;
279
+ transition: opacity 80ms ease;
280
+ }
281
+ .rf-calendar-months:has(.rf-calendar-day[data-rf-selected]:active)
282
+ .rf-calendar-preview-end::before {
283
+ opacity: 1;
284
+ }
285
+ .rf-calendar-day {
286
+ z-index: 2;
287
+ }
288
+ .rf-calendar-day[data-rf-selected] {
289
+ color: var(--rf-bg);
192
290
  }
193
-
194
291
  .rf-calendar-months:not([data-rf-has-selection]) .rf-calendar-day[data-rf-selected] {
195
- box-shadow: inset 0 0 0 1px var(--rf-accent, #6b97ff);
292
+ background: var(--rf-fg);
293
+ }
294
+ /* The same faint ring marks a filled day under the pointer. */
295
+ .rf-calendar-day[data-selected][data-rf-hovered] {
296
+ outline: 1px solid color-mix(in oklab, var(--rf-bg) 40%, transparent);
297
+ outline-offset: -2px;
298
+ }
299
+ .rf-calendar-day[data-selected][data-rf-hovered]:active {
300
+ outline-color: var(--rf-bg);
196
301
  }
197
302
 
198
303
  @media (forced-colors: active) {
package/dist/dates.js CHANGED
@@ -255,7 +255,7 @@ export function Calendar(handle) {
255
255
  }
256
256
  function monthButton(direction, disabled) {
257
257
  const label = direction === 'left' ? 'Previous month' : 'Next month';
258
- return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
258
+ return (_jsx(Tooltip, { content: label, side: "bottom", renderTrigger: (trigger) => (_jsx("button", { ...trigger, type: "button", class: "rf-calendar-nav", "data-direction": direction === 'left' ? 'previous' : 'next', "data-rf-hover-item": true, "aria-label": label, disabled: disabled, mix: [trigger.mix, on('click', () => moveMonth(direction === 'left' ? -1 : 1))], children: _jsx(DateArrow, { direction: direction }) })) }));
259
259
  }
260
260
  function onDayKey(event, day) {
261
261
  let next;
@@ -300,9 +300,19 @@ export function Calendar(handle) {
300
300
  event.preventDefault();
301
301
  focusDay(next);
302
302
  }
303
- return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
303
+ // As in the reference: side by side, each title is centered over its
304
+ // month, previous sits at the far left and next at the far right. One
305
+ // month, or months stacked, keep the title left and both arrows together
306
+ // on the first month's right.
307
+ const sideBySide = months.length > 1 && orientation !== 'vertical';
308
+ // Stacked months share one weekday row above them, as in the reference;
309
+ // each month's own header stays in its table for screen readers.
310
+ const stacked = months.length > 1 && orientation === 'vertical';
311
+ const previousDisabled = firstMonth <= monthStart(min);
312
+ const nextDisabled = firstMonth >= monthStart(max);
313
+ return (_jsxs("section", { class: "rf-calendar", "aria-label": label, "data-orientation": orientation, "data-week-numbers": showWeekNumbers ? '' : undefined, "data-stacked": stacked ? '' : undefined, style: cellSize && Number.isFinite(cellSize) && cellSize > 0
304
314
  ? { '--rf-date-cell-size': `${cellSize}px` }
305
- : undefined, children: [_jsxs("div", { class: "rf-calendar-months", mix: [
315
+ : undefined, children: [stacked && (_jsxs("div", { class: "rf-calendar-weekdays", "aria-hidden": "true", children: [showWeekNumbers && _jsx("span", { class: "rf-calendar-weekdays-week" }), weekdays.map((weekday) => (_jsx("span", { children: weekday.slice(0, 2) }, weekday)))] })), _jsxs("div", { class: "rf-calendar-months", mix: [
306
316
  gridRef,
307
317
  hover,
308
318
  on('pointerleave', () => {
@@ -318,8 +328,9 @@ export function Calendar(handle) {
318
328
  const weekCount = Math.ceil((firstDayOffset + daysInMonth) / 7);
319
329
  const weeks = Array.from({ length: weekCount }, (_, row) => Array.from({ length: 7 }, (_, column) => addDays(start, row * 7 + column)));
320
330
  const monthIso = iso(month).slice(0, 7);
321
- return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", children: [index === 0 && monthButton('left', firstMonth <= monthStart(min)), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), index === months.length - 1 &&
322
- monthButton('right', firstMonth >= monthStart(max))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
331
+ return (_jsxs("div", { class: "rf-calendar-month", children: [_jsxs("div", { class: "rf-calendar-header", "data-centered": sideBySide ? '' : undefined, children: [sideBySide && index === 0 && monthButton('left', previousDisabled), _jsx("div", { class: "rf-calendar-months-label", "aria-live": index === 0 ? 'polite' : undefined, "aria-atomic": index === 0 ? 'true' : undefined, children: monthLabel.format(month) }), sideBySide
332
+ ? index === months.length - 1 && monthButton('right', nextDisabled)
333
+ : index === 0 && (_jsxs(_Fragment, { children: [monthButton('left', previousDisabled), monthButton('right', nextDisabled)] }))] }), _jsxs("table", { class: "rf-calendar-grid", role: "grid", "aria-label": monthLabel.format(month), children: [_jsx("thead", { children: _jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "col", class: "rf-calendar-week", abbr: "Week number", children: _jsx("span", { class: "rf-basic-sr-only", children: "Week number" }) })), weekdays.map((weekday) => (_jsx("th", { scope: "col", abbr: weekday, children: weekday.slice(0, 2) }, weekday)))] }) }), _jsx("tbody", { children: weeks.map((week) => (_jsxs("tr", { children: [showWeekNumbers && (_jsx("th", { scope: "row", class: "rf-calendar-week", children: getWeekNumber(week[0]) })), week.map((day) => {
323
334
  const dayIso = iso(day);
324
335
  const outside = !dayIso.startsWith(monthIso);
325
336
  if (!dayIso || (months.length > 1 && outside))
package/dist/flows.css CHANGED
@@ -141,7 +141,10 @@
141
141
  rows continue below and goes at the end, which is why the footer draws no
142
142
  line of its own. Without a footer the list is the panel's last child and
143
143
  that line would double the panel edge, so it is dropped. The lines ride the
144
- scroll root because the viewport's fade mask would erase them. */
144
+ scroll root because the viewport's fade mask would erase them.
145
+ This follows the reference's intent, not its pixels: there the stronger
146
+ line sits on the root's border, which the root's overflow clip hides, so
147
+ its header line never strengthens. Keep the strengthening. */
145
148
  .rf-command-scroll::before,
146
149
  .rf-command-scroll::after {
147
150
  content: '';
@@ -441,38 +441,45 @@
441
441
  position: absolute;
442
442
  top: 50%;
443
443
  left: 50%;
444
- width: var(--rf-control-height);
445
- height: var(--rf-control-height);
444
+ width: var(--rf-icon-size);
445
+ height: var(--rf-icon-size);
446
446
  transform: translate(-50%, -50%);
447
447
  pointer-events: none;
448
448
  }
449
449
 
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;
450
+ .rf-button-spinner g {
451
+ transform-box: fill-box;
452
+ transform-origin: center;
453
+ animation: rf-button-spinner-turn 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
454
+ }
455
+
456
+ .rf-button-spinner circle {
457
+ /* A nonzero dash keeps Safari's round caps visible as dots. */
458
+ stroke-dasharray: 0.01 49.99;
459
+ stroke-dashoffset: -25;
460
+ animation: rf-button-spinner-breathe 1s cubic-bezier(0.65, 0, 0.35, 1) infinite;
455
461
  }
456
462
 
457
- @keyframes rf-button-spinner {
463
+ @keyframes rf-button-spinner-turn {
458
464
  to {
459
- stroke-dashoffset: -100;
465
+ transform: rotate(360deg);
460
466
  }
461
467
  }
462
468
 
463
- @keyframes rf-button-spinner-dash {
469
+ @keyframes rf-button-spinner-breathe {
464
470
  0%,
465
471
  100% {
466
- stroke-dasharray: 15 85;
472
+ stroke-dasharray: 0.01 49.99;
473
+ stroke-dashoffset: -25;
467
474
  }
468
475
  50% {
469
- stroke-dasharray: 40 60;
476
+ stroke-dasharray: 26 24;
477
+ stroke-dashoffset: -12;
470
478
  }
471
479
  }
472
480
 
473
481
  @media (prefers-reduced-motion: reduce) {
474
- .rf-button-spinner path {
475
- stroke-dasharray: none;
482
+ .rf-button-spinner :is(g, circle) {
476
483
  animation: none;
477
484
  }
478
485
  }
package/dist/groups.css CHANGED
@@ -243,6 +243,40 @@
243
243
  .rf-choice-group-list[data-orientation='horizontal'] .rf-choice-group-row {
244
244
  border-radius: var(--rf-radius);
245
245
  }
246
+ /* Once hydrated, a vertical group draws its checked fill as moving blocks, one
247
+ per run of checked rows, under the hover fill; the rows draw none. */
248
+ .rf-choice-runs {
249
+ position: absolute;
250
+ z-index: -1;
251
+ inset: 0;
252
+ pointer-events: none;
253
+ }
254
+ .rf-choice-run {
255
+ position: absolute;
256
+ background: var(--rf-active);
257
+ transition:
258
+ top var(--rf-spring-moderate),
259
+ left var(--rf-spring-moderate),
260
+ width var(--rf-spring-moderate),
261
+ height var(--rf-spring-moderate),
262
+ border-radius var(--rf-spring-moderate) var(--rf-corner-delay, 0ms),
263
+ opacity 80ms ease;
264
+ }
265
+ .rf-choice-run[data-instant] {
266
+ transition: none;
267
+ }
268
+ .rf-choice-run[data-leaving] {
269
+ opacity: 0;
270
+ transition: opacity var(--rf-exit-moderate);
271
+ }
272
+ .rf-choice-group-list[data-rf-selection-blocks] > .rf-choice-group-row:has(input:checked) {
273
+ background: transparent;
274
+ }
275
+ @media (prefers-reduced-motion: reduce) {
276
+ .rf-choice-run:not([data-leaving]) {
277
+ transition: opacity 80ms ease;
278
+ }
279
+ }
246
280
  .rf-choice-group-row[aria-disabled='true'] {
247
281
  opacity: 0.45;
248
282
  }
package/dist/groups.js CHANGED
@@ -4,6 +4,7 @@ import * as accordion from '@remix-run/ui/accordion';
4
4
  import { on } from 'remix/component';
5
5
  import { Checkbox } from './basic.js';
6
6
  import { fluidHover } from './interactive.js';
7
+ import { selectionRuns } from './selection-runs.js';
7
8
  export function Accordion(handle) {
8
9
  return () => {
9
10
  const { items, variant = 'grouped', highlight = 'item', headingLevel = 3, children: _children, ...props } = handle.props;
@@ -12,9 +13,16 @@ export function Accordion(handle) {
12
13
  };
13
14
  }
14
15
  export function CheckboxGroup(handle) {
16
+ // The checked fill moves with the selection, as in the reference. A
17
+ // controlled value changes without a change event, so each render resyncs.
18
+ const runs = selectionRuns();
15
19
  return () => {
16
20
  const { options, label, hideLabel, name, disabled, value, defaultValue, onValueChange, orientation = 'vertical', } = handle.props;
21
+ const vertical = orientation !== 'horizontal';
22
+ if (vertical)
23
+ handle.queueTask(runs.sync);
17
24
  return (_jsxs("fieldset", { class: "rf-choice-group", disabled: disabled, children: [_jsx("legend", { class: hideLabel ? 'rf-basic-sr-only' : undefined, children: label }), _jsxs("div", { class: "rf-choice-group-list", "data-orientation": orientation, mix: [
25
+ vertical && runs.mix,
18
26
  fluidHover({
19
27
  axis: orientation === 'horizontal' ? 'x' : 'y',
20
28
  focusSelector: ':has(input:focus-visible)',
@@ -43,7 +51,7 @@ export function CheckboxGroup(handle) {
43
51
  const selected = event.currentTarget.querySelectorAll('input[type="checkbox"]:checked');
44
52
  onValueChange(Array.from(selected, (input) => input.value));
45
53
  }),
46
- ], children: [_jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
54
+ ], children: [vertical && _jsx("div", { class: "rf-choice-runs", "aria-hidden": "true", "data-rf-selection-runs": true }), _jsx("div", { class: "rf-highlight", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("div", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), options.map((option) => (_jsx("div", { class: "rf-choice-group-row", "data-rf-hover-item": true, "aria-disabled": disabled || option.disabled || undefined, children: _jsx(Checkbox, { name: name, value: option.value, disabled: option.disabled, checked: value?.includes(option.value), defaultChecked: value === undefined ? defaultValue?.includes(option.value) : undefined, label: _jsxs(_Fragment, { children: [option.label, option.description && _jsx("small", { children: option.description })] }) }) }, option.value)))] })] }));
47
55
  };
48
56
  }
49
57
  export function RadioGroup(handle) {
@@ -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
+ }
@@ -281,6 +281,19 @@ export declare const defaultIcons: {
281
281
  }], readonly ['path', {
282
282
  readonly d: 'M12 5v14';
283
283
  }]];
284
+ readonly pause: readonly [readonly ['rect', {
285
+ readonly x: '14';
286
+ readonly y: '3';
287
+ readonly width: '5';
288
+ readonly height: '18';
289
+ readonly rx: '1';
290
+ }], readonly ['rect', {
291
+ readonly x: '5';
292
+ readonly y: '3';
293
+ readonly width: '5';
294
+ readonly height: '18';
295
+ readonly rx: '1';
296
+ }]];
284
297
  readonly 'rotate-ccw': readonly [readonly ['path', {
285
298
  readonly d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8';
286
299
  }], readonly ['path', {
package/dist/icon-data.js CHANGED
@@ -246,6 +246,10 @@ export const defaultIcons = {
246
246
  ['path', { d: 'M5 12h14' }],
247
247
  ['path', { d: 'M12 5v14' }],
248
248
  ],
249
+ pause: [
250
+ ['rect', { x: '14', y: '3', width: '5', height: '18', rx: '1' }],
251
+ ['rect', { x: '5', y: '3', width: '5', height: '18', rx: '1' }],
252
+ ],
249
253
  'rotate-ccw': [
250
254
  ['path', { d: 'M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8' }],
251
255
  ['path', { d: 'M3 3v5h5' }],
package/dist/index.d.ts CHANGED
@@ -10,6 +10,9 @@ 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';
13
16
  export * from './systems.ts';
14
17
  export * from './queued-stack.tsx';
15
18
  export * from './scroll-area.tsx';
package/dist/index.js CHANGED
@@ -10,6 +10,9 @@ 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';
13
16
  export * from './systems.js';
14
17
  export * from './queued-stack.js';
15
18
  export * from './scroll-area.js';
@@ -1,4 +1,4 @@
1
- import type { Handle, RemixNode } from 'remix/component';
1
+ import type { Handle, MixInput, RemixNode } from 'remix/component';
2
2
  export interface FluidHoverOptions {
3
3
  /** Track the pointer on one axis or across a two-dimensional grid. */
4
4
  axis?: 'x' | 'y' | 'both';
@@ -62,6 +62,8 @@ export interface DropdownProps {
62
62
  /** `bare` renders an unstyled button trigger that takes its look from `triggerClass`, for sidebar rows and actions. */
63
63
  variant?: 'outline' | 'ghost' | 'bare';
64
64
  triggerClass?: string;
65
+ /** Extra behavior on the trigger, such as a Tooltip's trigger props. */
66
+ triggerMix?: MixInput;
65
67
  /** Which side of the trigger the menu opens on. Default bottom. */
66
68
  side?: 'top' | 'bottom';
67
69
  searchable?: boolean;
@@ -376,12 +376,14 @@ export function Dropdown(handle) {
376
376
  offset: 6,
377
377
  }),
378
378
  hoverMix,
379
+ handle.props.triggerMix,
379
380
  ], children: handle.props.triggerContent ?? label })), !handle.props.inline && variant !== 'bare' && (_jsx(Button, { class: "rf-dropdown-trigger", variant: variant === 'ghost' ? 'ghost' : 'tertiary', size: size, trailingIcon: handle.props.chevron === false ? undefined : (_jsx("svg", { width: "16", height: "16", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "m4 6 4 4 4-4", stroke: "currentColor", "stroke-linecap": "round", "stroke-linejoin": "round" }) })), mix: [
380
381
  menu.trigger({
381
382
  placement: `${handle.props.side ?? 'bottom'}-${align}`,
382
383
  offset: 6,
383
384
  }),
384
385
  hoverMix,
386
+ handle.props.triggerMix,
385
387
  ], children: handle.props.triggerContent ?? label })), _jsx(DropdownSurface, { inline: handle.props.inline, hover: hovering ? { control: hover, closeDelay } : undefined, items: displayedItems, emptyMessage: handle.props.emptyMessage, search: searchable || handle.props.creatable
386
388
  ? { query, setQuery, placeholder: searchPlaceholder }
387
389
  : undefined })] }) }));
@@ -517,7 +519,7 @@ function DropdownSurface(handle) {
517
519
  ? search.query.slice(0, -1)
518
520
  : search.query + event.key);
519
521
  }, true),
520
- ], children: [_jsx("span", { class: "rf-highlight rf-menu-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), handle.props.inline && (_jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true })), items.length === 0 && search && (_jsx("div", { class: "rf-menu-empty", role: "status", children: handle.props.emptyMessage ?? 'No results' })), items.map((item, index) => (_jsxs("div", { role: "none", class: "rf-menu-entry", children: [item.separatorBefore && (index > 0 || !normalized) && (_jsx("div", { class: "rf-menu-separator", role: "separator" })), item.group && item.group !== items[index - 1]?.group && (_jsx("div", { class: "rf-menu-group-label", children: item.group })), item.children && item.children.length > 0 ? (_jsxs(menu.Context, { label: item.label, children: [_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.submenuTrigger({
522
+ ], children: [_jsx("span", { class: "rf-highlight rf-menu-hover", "aria-hidden": "true", "data-rf-hover-layer": true }), _jsx("span", { class: "rf-highlight rf-focus-ring", "aria-hidden": "true", "data-rf-focus-layer": true }), items.length === 0 && search && (_jsx("div", { class: "rf-menu-empty", role: "status", children: handle.props.emptyMessage ?? 'No results' })), items.map((item, index) => (_jsxs("div", { role: "none", class: "rf-menu-entry", children: [item.separatorBefore && (index > 0 || !normalized) && (_jsx("div", { class: "rf-menu-separator", role: "separator" })), item.group && item.group !== items[index - 1]?.group && (_jsx("div", { class: "rf-menu-group-label", children: item.group })), item.children && item.children.length > 0 ? (_jsxs(menu.Context, { label: item.label, children: [_jsx("div", { class: "rf-menu-item", "data-rf-hover-item": true, mix: menu.submenuTrigger({
521
523
  value: item.value,
522
524
  label: item.label,
523
525
  disabled: item.disabled,
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;