@rtzll/reflow 0.3.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/basic.js CHANGED
@@ -13,7 +13,7 @@ export function Button(handle) {
13
13
  const iconOnly = props.size === 'icon' || props.size === 'icon-compact';
14
14
  const leading = iconOnly ? undefined : props.leadingIcon;
15
15
  const trailing = iconOnly ? undefined : props.trailingIcon;
16
- const content = (_jsxs(_Fragment, { children: [_jsxs("span", { class: "rf-button-content", children: [leading && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: leading })), _jsx("span", { class: "rf-button-label", children: props.children }), trailing && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: trailing }))] }), props.loading && (_jsx("svg", { class: "rf-button-spinner", viewBox: "0 0 24 24", fill: "none", "aria-hidden": "true", children: _jsx("path", { d: "M12 12C14 8.5 19 8.5 19 12C19 15.5 14 15.5 12 12C10 8.5 5 8.5 5 12C5 15.5 10 15.5 12 12Z", stroke: "currentColor", "stroke-width": "1.125", "stroke-linecap": "round", pathLength: "100" }) }))] }));
16
+ const content = (_jsxs(_Fragment, { children: [_jsxs("span", { class: "rf-button-content", children: [leading && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: leading })), _jsx("span", { class: "rf-button-label", children: props.children }), trailing && (_jsx("span", { class: "rf-button-icon", "aria-hidden": "true", children: trailing }))] }), props.loading && (_jsx("svg", { class: "rf-button-spinner", viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", children: _jsx("g", { children: _jsx("circle", { cx: "8", cy: "8", r: "6.25", stroke: "currentColor", "stroke-width": "1.5", "stroke-linecap": "round", pathLength: "100", transform: "rotate(-90 8 8)" }) }) }))] }));
17
17
  const appearance = {
18
18
  class: classNames,
19
19
  'data-slot': 'button',
@@ -33,6 +33,16 @@
33
33
  font-size: var(--rf-text-body);
34
34
  line-height: var(--rf-leading-body);
35
35
  }
36
+ .rf-breadcrumb-measure {
37
+ position: absolute;
38
+ inline-size: 0;
39
+ block-size: 0;
40
+ overflow: clip;
41
+ visibility: hidden;
42
+ }
43
+ .rf-breadcrumb-measure > .rf-breadcrumb-list {
44
+ inline-size: max-content;
45
+ }
36
46
 
37
47
  /* Steps keep their width; only the current page gives way and truncates. */
38
48
  .rf-breadcrumb-item {
@@ -11,14 +11,12 @@ const fallbackEllipsisWidth = 44;
11
11
  */
12
12
  export function Breadcrumb(handle) {
13
13
  let list = null;
14
- let hidden = 0;
14
+ let hidden;
15
15
  // Natural widths per step, remembered so a hidden step returns as soon as it fits.
16
16
  let widths = [];
17
17
  let ellipsisWidth = 0;
18
- // Steps whose label is cut, by index, read as the pointer or keyboard focus
19
- // arrives; and the step whose menu is open, whose full name stays hidden.
18
+ // Steps whose label is cut, by index, read as the pointer or keyboard focus arrives.
20
19
  let cut = new Set();
21
- let menuOpen;
22
20
  const listRef = ref((node, signal) => {
23
21
  if (!(node instanceof HTMLOListElement))
24
22
  return;
@@ -26,18 +24,8 @@ export function Breadcrumb(handle) {
26
24
  const observer = new ResizeObserver(() => measure());
27
25
  if (node.parentElement)
28
26
  observer.observe(node.parentElement);
29
- const menus = new MutationObserver(() => {
30
- const open = node.querySelector('[data-rf-step][aria-expanded="true"]');
31
- const next = open ? Number(open.dataset.rfStep) : undefined;
32
- if (next === menuOpen)
33
- return;
34
- menuOpen = next;
35
- void handle.update();
36
- });
37
- menus.observe(node, { subtree: true, attributeFilter: ['aria-expanded'] });
38
27
  signal.addEventListener('abort', () => {
39
28
  observer.disconnect();
40
- menus.disconnect();
41
29
  if (list === node)
42
30
  list = null;
43
31
  });
@@ -54,34 +42,39 @@ export function Breadcrumb(handle) {
54
42
  }
55
43
  function measure() {
56
44
  const nav = list?.parentElement;
57
- if (!list || !nav)
45
+ if (!list || !nav || !handle.props.collapse)
58
46
  return;
59
47
  const count = handle.props.items.length;
48
+ const maxHidden = Math.max(0, count - 3);
60
49
  const items = Array.from(list.children);
61
50
  const steps = items.filter((item) => !item.hasAttribute('data-ellipsis'));
62
51
  const ellipsis = items.find((item) => item.hasAttribute('data-ellipsis'));
63
52
  if (ellipsis)
64
53
  ellipsisWidth = naturalWidth(ellipsis);
65
- // The rendered steps are 0, then hidden + 1 onward.
66
- steps.forEach((item, i) => {
67
- const index = hidden > 0 && i > 0 ? i + hidden : i;
68
- if (index < count)
69
- widths[index] = naturalWidth(item);
70
- });
54
+ const measuring = nav.querySelector('.rf-breadcrumb-measure > ol');
55
+ if (measuring) {
56
+ widths = Array.from(measuring.children, (item) => item.getBoundingClientRect().width);
57
+ }
58
+ else {
59
+ // The rendered steps are 0, then hidden + 1 onward.
60
+ const hiddenCount = Math.min(hidden ?? 0, maxHidden);
61
+ steps.forEach((item, i) => {
62
+ const index = i === 0 ? i : i + hiddenCount;
63
+ if (index < count)
64
+ widths[index] = naturalWidth(item);
65
+ });
66
+ }
71
67
  let next = 0;
72
- if (handle.props.collapse) {
73
- const available = nav.clientWidth;
74
- const width = (i) => widths[i] ?? 0;
75
- let total = 0;
76
- for (let i = 0; i < count; i++)
77
- total += width(i);
78
- const maxHidden = Math.max(0, count - 3);
79
- if (total > available + 0.5) {
80
- let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
81
- while (next < maxHidden && shown > available + 0.5) {
82
- shown -= width(1 + next);
83
- next++;
84
- }
68
+ const available = nav.clientWidth;
69
+ const width = (i) => widths[i] ?? 0;
70
+ let total = 0;
71
+ for (let i = 0; i < count; i++)
72
+ total += width(i);
73
+ if (total > available + 0.5) {
74
+ let shown = total + (ellipsisWidth || fallbackEllipsisWidth);
75
+ while (next < maxHidden && shown > available + 0.5) {
76
+ shown -= width(1 + next);
77
+ next++;
85
78
  }
86
79
  }
87
80
  if (next !== hidden) {
@@ -110,17 +103,19 @@ export function Breadcrumb(handle) {
110
103
  return () => {
111
104
  const { items, label = 'Breadcrumb', size, separator, collapse, openOnHover } = handle.props;
112
105
  widths.length = items.length;
113
- const hide = collapse ? Math.min(hidden, Math.max(0, items.length - 3)) : 0;
106
+ const maxHidden = Math.max(0, items.length - 3);
107
+ if (!collapse)
108
+ hidden = undefined;
109
+ // Start with the shortest usable trail, then restore the steps that fit.
110
+ const hide = collapse ? Math.min(hidden ?? maxHidden, maxHidden) : 0;
114
111
  // Every render: a label change can change a step's width.
115
112
  if (collapse)
116
113
  handle.queueTask(() => measure());
117
114
  const trailing = separator === null ? null : (_jsx("span", { class: "rf-breadcrumb-separator", "aria-hidden": "true", children: separator ?? _jsx(Icon, { name: "chevron-right" }) }));
118
115
  const content = (item) => (_jsxs(_Fragment, { children: [item.icon, _jsx("span", { class: "rf-breadcrumb-label", children: item.label })] }));
119
116
  // The full name of a cut step, in the library tooltip under it, as in
120
- // the reference. It stays closed while the name fits and while the
121
- // step's own menu is open, so it never stacks on the menu. The step is
122
- // described by the tooltip only while its name is cut.
123
- const withFullName = (item, index, render) => (_jsx(Tooltip, { content: item.label, side: "bottom", open: cut.has(index) && menuOpen !== index ? undefined : false, renderTrigger: (props) => render(cut.has(index) ? props : { 'aria-describedby': '', mix: props.mix }) }));
117
+ // the reference. The step is described by the tooltip only while its name is cut.
118
+ const withFullName = (item, index, render) => (_jsx(Tooltip, { content: item.label, side: "bottom", open: cut.has(index) ? undefined : false, renderTrigger: (props) => render(cut.has(index) ? props : { 'aria-describedby': '', mix: props.mix }) }));
124
119
  const described = (trigger) => trigger['aria-describedby'] || undefined;
125
120
  const step = (item, index) => {
126
121
  const last = index === items.length - 1;
@@ -159,6 +154,9 @@ export function Breadcrumb(handle) {
159
154
  fluidHover({ axis: 'x', focusSelector: ':has(:focus-visible)' }),
160
155
  on('pointerover', checkCut),
161
156
  on('focusin', checkCut),
162
- ], children: [_jsx("span", { class: "rf-highlight rf-breadcrumb-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 }), _jsx("ol", { class: "rf-breadcrumb-list", mix: listRef, children: rows })] }));
157
+ ], children: [_jsx("span", { class: "rf-highlight rf-breadcrumb-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 }), _jsx("ol", { class: "rf-breadcrumb-list", mix: listRef, children: rows }), collapse && hidden === undefined && maxHidden > 0 && (_jsx("div", { class: "rf-breadcrumb-measure", "aria-hidden": "true", inert: true, children: _jsx("ol", { class: "rf-breadcrumb-list", children: items.map((item, index) => {
158
+ const last = index === items.length - 1;
159
+ return (_jsx("li", { class: "rf-breadcrumb-item", children: _jsxs("span", { class: last ? 'rf-breadcrumb-crumb rf-breadcrumb-page' : 'rf-breadcrumb-crumb', children: [content(item), !last && trailing] }) }, index));
160
+ }) }) }))] }));
163
161
  };
164
162
  }
@@ -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
  }
@@ -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,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';
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;
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { on } from 'remix/component';
3
+ import * as popover from '@remix-run/ui/popover';
4
+ import { Button } from './basic.js';
5
+ // A field to type into, or an element the content marks to take focus. Buttons
6
+ // and links are left to Tab, so opening never fires a control's own tooltip.
7
+ const initialFocus = '[autofocus], input:not(:disabled, [readonly], [type="hidden"]), select:not(:disabled), textarea:not(:disabled, [readonly])';
8
+ /**
9
+ * A panel anchored to a trigger, for content that needs more room than a
10
+ * tooltip: a filter, inline settings, a share sheet. A click opens it; Escape
11
+ * and a click outside close it. It is a non-modal dialog: focus moves to its
12
+ * first field, or to the panel, on open and returns to the trigger on close.
13
+ */
14
+ export function Popover(handle) {
15
+ let localOpen = handle.props.defaultOpen ?? false;
16
+ const panelId = `${handle.id}-panel`;
17
+ const titleId = `${handle.id}-title`;
18
+ const descriptionId = `${handle.id}-description`;
19
+ const isOpen = () => handle.props.open ?? localOpen;
20
+ function setOpen(next) {
21
+ if (isOpen() === next)
22
+ return;
23
+ localOpen = next;
24
+ handle.props.onOpenChange?.(next);
25
+ void handle.update();
26
+ }
27
+ // On open, focus the panel's first field, or the panel itself.
28
+ const focusInside = on('toggle', (event) => {
29
+ if (event.newState !== 'open')
30
+ return;
31
+ const panel = event.currentTarget;
32
+ const target = panel.querySelector(initialFocus) ?? panel;
33
+ target.focus({ preventScroll: true });
34
+ });
35
+ return () => {
36
+ const { label, title, description, children, trigger, triggerVariant = 'secondary', triggerSize, renderTrigger, placement = 'bottom', offset = 8, size, } = handle.props;
37
+ const open = isOpen();
38
+ const triggerProps = {
39
+ 'aria-haspopup': 'dialog',
40
+ 'aria-expanded': open,
41
+ 'aria-controls': panelId,
42
+ mix: [
43
+ popover.anchor({ placement, offset }),
44
+ popover.focusOnHide(),
45
+ on('click', () => setOpen(!isOpen())),
46
+ ],
47
+ };
48
+ return (_jsx("span", { class: "rf-popover", "data-density": size, children: _jsxs(popover.Context, { children: [renderTrigger ? (renderTrigger(triggerProps)) : (_jsx(Button, { variant: triggerVariant, size: triggerSize ?? size, ...triggerProps, children: trigger })), _jsxs("div", { class: "rf-menu-surface rf-popover-surface", id: panelId, role: "dialog", tabIndex: -1, "aria-label": title == null ? label : undefined, "aria-labelledby": title == null ? undefined : titleId, "aria-describedby": description == null ? undefined : descriptionId, "data-density": size, mix: [
49
+ popover.surface({
50
+ open,
51
+ closeOnAnchorClick: false,
52
+ onHide: () => setOpen(false),
53
+ }),
54
+ focusInside,
55
+ ], children: [(title != null || description != null) && (_jsxs("header", { class: "rf-popover-header", children: [title != null && (_jsx("h2", { class: "rf-popover-title", id: titleId, children: title })), description != null && (_jsx("p", { class: "rf-popover-description", id: descriptionId, children: description }))] })), children != null && _jsx("div", { class: "rf-popover-body", children: children })] })] }) }));
56
+ };
57
+ }
@@ -0,0 +1,172 @@
1
+ /* SegmentedControl: a filled track around a row of segments, with a raised
2
+ thumb that glides to the selected one. The track's padding keeps the whole
3
+ control on the size ladder: 4px around 28px segments makes 36px, 2px around
4
+ 24px makes 28px compact. Corners stay concentric: the track's radius is the
5
+ thumb's plus the padding. */
6
+ .rf-segmented {
7
+ --rf-segment-pad: 4px;
8
+ display: inline-flex;
9
+ min-inline-size: 0;
10
+ max-inline-size: 100%;
11
+ margin: 0;
12
+ padding: 0;
13
+ border: 0;
14
+ color: var(--rf-fg);
15
+ }
16
+ .rf-segmented[data-density='compact'],
17
+ [data-density='compact'] .rf-segmented:not([data-density='default']) {
18
+ --rf-segment-pad: 2px;
19
+ }
20
+ .rf-segmented[data-fill] {
21
+ display: flex;
22
+ inline-size: 100%;
23
+ }
24
+
25
+ .rf-segmented-track {
26
+ display: inline-flex;
27
+ flex: 1;
28
+ align-items: stretch;
29
+ box-sizing: border-box;
30
+ min-inline-size: 0;
31
+ block-size: var(--rf-control-height, 36px);
32
+ padding: var(--rf-segment-pad);
33
+ border-radius: calc(var(--rf-radius, 8px) + var(--rf-segment-pad));
34
+ background: var(--rf-hover);
35
+ }
36
+ .rf-segmented[data-fill] .rf-segmented-track {
37
+ display: grid;
38
+ grid-auto-columns: minmax(0, 1fr);
39
+ grid-auto-flow: column;
40
+ }
41
+
42
+ .rf-segment {
43
+ position: relative;
44
+ display: inline-flex;
45
+ align-items: center;
46
+ justify-content: center;
47
+ gap: 6px;
48
+ box-sizing: border-box;
49
+ min-inline-size: 0;
50
+ padding: 0 var(--rf-control-padding, 12px);
51
+ border-radius: var(--rf-radius, 8px);
52
+ color: var(--rf-muted);
53
+ font-size: var(--rf-text-body, 13px);
54
+ line-height: var(--rf-leading-body, 20px);
55
+ white-space: nowrap;
56
+ cursor: pointer;
57
+ user-select: none;
58
+ -webkit-tap-highlight-color: transparent;
59
+ transition: color 80ms ease;
60
+ }
61
+ .rf-segment[data-icon-only] {
62
+ inline-size: calc(var(--rf-control-height, 36px) - var(--rf-segment-pad) * 2);
63
+ padding: 0;
64
+ }
65
+ .rf-segmented[data-fill] .rf-segment[data-icon-only] {
66
+ inline-size: auto;
67
+ }
68
+ .rf-segment:is([data-rf-hovered], :has(:checked), :has(:focus-visible)) {
69
+ color: var(--rf-fg);
70
+ }
71
+ .rf-segment[aria-disabled='true'] {
72
+ opacity: 0.4;
73
+ cursor: default;
74
+ }
75
+
76
+ /* The radio stays focusable and in the form, out of sight. */
77
+ .rf-segment-input {
78
+ position: absolute;
79
+ overflow: hidden;
80
+ inline-size: 1px;
81
+ block-size: 1px;
82
+ margin: 0;
83
+ padding: 0;
84
+ border: 0;
85
+ clip-path: inset(50%);
86
+ white-space: nowrap;
87
+ appearance: none;
88
+ }
89
+
90
+ .rf-segment-icon {
91
+ display: inline-flex;
92
+ flex: none;
93
+ }
94
+ .rf-segment-icon > svg {
95
+ inline-size: var(--rf-icon-size, 16px);
96
+ block-size: var(--rf-icon-size, 16px);
97
+ }
98
+
99
+ /* An invisible copy at the strong weight reserves the width, so the selected
100
+ label's weight shift never moves its neighbours. */
101
+ .rf-segment-text {
102
+ display: inline-grid;
103
+ min-inline-size: 0;
104
+ }
105
+ .rf-segment-sizer,
106
+ .rf-segment-label {
107
+ grid-area: 1 / 1;
108
+ overflow: hidden;
109
+ text-overflow: ellipsis;
110
+ }
111
+ .rf-segment-sizer {
112
+ visibility: hidden;
113
+ font-variation-settings: var(--rf-weight-strong);
114
+ }
115
+ .rf-segment-label {
116
+ font-variation-settings: var(--rf-weight-normal);
117
+ transition: font-variation-settings 80ms ease;
118
+ }
119
+ .rf-segment:has(:checked) .rf-segment-label {
120
+ font-variation-settings: var(--rf-weight-strong);
121
+ }
122
+
123
+ /* The thumb rides the shared selection layer; pressing any segment squeezes it. */
124
+ .rf-segmented-thumb {
125
+ background: var(--rf-surface-5);
126
+ box-shadow:
127
+ 0 1px 2px rgb(0 0 0 / 12%),
128
+ 0 0 0 1px light-dark(rgb(0 0 0 / 4%), rgb(255 255 255 / 4%));
129
+ transition:
130
+ opacity 80ms ease,
131
+ scale var(--rf-spring-fast);
132
+ }
133
+ .rf-segmented-thumb[data-moving] {
134
+ transition:
135
+ var(--rf-highlight-travel),
136
+ opacity 80ms ease,
137
+ scale var(--rf-spring-fast);
138
+ }
139
+ .rf-segmented-track:has(.rf-segment:not([aria-disabled='true']):active) .rf-segmented-thumb {
140
+ scale: 0.97;
141
+ }
142
+ .rf-segmented-hover {
143
+ background: var(--rf-hover);
144
+ }
145
+
146
+ /* Before the layers mount, the checked segment carries the thumb itself. */
147
+ .rf-segmented-track:not([data-rf-has-selection]) .rf-segment:has(:checked) {
148
+ background: var(--rf-surface-5);
149
+ box-shadow: 0 1px 2px rgb(0 0 0 / 12%);
150
+ }
151
+
152
+ .rf-segment:has(:focus-visible) {
153
+ outline: 1px solid var(--rf-accent, #6b97ff);
154
+ outline-offset: 1px;
155
+ }
156
+ @media (forced-colors: none) {
157
+ .rf-segmented-track:has(> .rf-focus-ring[data-visible]) .rf-segment:has(:focus-visible) {
158
+ outline-color: transparent;
159
+ }
160
+ }
161
+ @media (forced-colors: active) {
162
+ .rf-segment:has(:checked) {
163
+ outline: 1px solid Highlight;
164
+ outline-offset: -1px;
165
+ }
166
+ }
167
+ @media (prefers-reduced-motion: reduce) {
168
+ .rf-segmented-thumb,
169
+ .rf-segmented-thumb[data-moving] {
170
+ transition: opacity 80ms ease;
171
+ }
172
+ }
@@ -0,0 +1,34 @@
1
+ import { type Handle, type RemixNode } from 'remix/component';
2
+ export interface SegmentedControlOption {
3
+ value: string;
4
+ /** Names the segment; for an icon-only segment it is read out and shown in a Tooltip. */
5
+ label: string;
6
+ icon?: RemixNode;
7
+ /** Shows only the icon. The label stays for screen readers and appears in a Tooltip on hover. */
8
+ iconOnly?: boolean;
9
+ disabled?: boolean;
10
+ }
11
+ export interface SegmentedControlProps {
12
+ /** Names the group for assistive technology. */
13
+ label: string;
14
+ options: readonly SegmentedControlOption[];
15
+ /** The selected option in controlled mode. */
16
+ value?: string;
17
+ /** The option selected first in uncontrolled mode. Defaults to the first enabled option. */
18
+ defaultValue?: string;
19
+ onValueChange?: (value: string) => void;
20
+ /** Submits the selected value with a surrounding form. */
21
+ name?: string;
22
+ disabled?: boolean;
23
+ size?: 'default' | 'compact';
24
+ /** Stretches the control to its container, with equal-width segments. */
25
+ fill?: boolean;
26
+ }
27
+ /**
28
+ * One choice from a short row of options, such as a view or a time range. A
29
+ * segmented control is never empty: without a value, the first enabled option
30
+ * is selected. Each segment is a native radio, so arrow keys move the
31
+ * selection and the value takes part in forms. A raised thumb glides to the
32
+ * selected segment and squeezes while a segment is pressed.
33
+ */
34
+ export declare function SegmentedControl(handle: Handle<SegmentedControlProps>): () => import("remix/component").RemixElement;
@@ -0,0 +1,38 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "remix/component/jsx-runtime";
2
+ import { on } from 'remix/component';
3
+ import { fluidHover } from './interactive.js';
4
+ import { Tooltip } from './overlays.js';
5
+ /**
6
+ * One choice from a short row of options, such as a view or a time range. A
7
+ * segmented control is never empty: without a value, the first enabled option
8
+ * is selected. Each segment is a native radio, so arrow keys move the
9
+ * selection and the value takes part in forms. A raised thumb glides to the
10
+ * selected segment and squeezes while a segment is pressed.
11
+ */
12
+ export function SegmentedControl(handle) {
13
+ const indicators = fluidHover({
14
+ axis: 'x',
15
+ selectedSelector: ':has(input:checked)',
16
+ focusSelector: ':has(input:focus-visible)',
17
+ });
18
+ return () => {
19
+ const { label, options, value, defaultValue, onValueChange, name = handle.id, disabled, size, fill, } = handle.props;
20
+ const selectable = (candidate) => options.some((option) => option.value === candidate && !option.disabled);
21
+ const fallback = options.find((option) => !option.disabled)?.value;
22
+ const selected = value !== undefined
23
+ ? selectable(value)
24
+ ? value
25
+ : fallback
26
+ : selectable(defaultValue)
27
+ ? defaultValue
28
+ : fallback;
29
+ const segment = (option, mix) => (_jsxs("label", { class: "rf-segment", "data-rf-hover-item": true, "data-icon-only": option.iconOnly && option.icon != null ? '' : undefined, "aria-disabled": disabled || option.disabled || undefined, mix: mix, children: [_jsx("input", { type: "radio", class: "rf-segment-input", name: name, value: option.value, disabled: option.disabled, ...(value === undefined
30
+ ? { defaultChecked: selected === option.value }
31
+ : { checked: selected === option.value }), mix: onValueChange &&
32
+ on('change', (event) => {
33
+ if (event.currentTarget.checked)
34
+ onValueChange(option.value);
35
+ }) }), option.icon != null && (_jsx("span", { class: "rf-segment-icon", "aria-hidden": "true", children: option.icon })), option.iconOnly && option.icon != null ? (_jsx("span", { class: "rf-basic-sr-only", children: option.label })) : (_jsxs("span", { class: "rf-segment-text", children: [_jsx("span", { class: "rf-segment-sizer", "aria-hidden": "true", children: option.label }), _jsx("span", { class: "rf-segment-label", children: option.label })] }))] }, option.value));
36
+ return (_jsxs("fieldset", { class: "rf-segmented", "data-density": size, "data-fill": fill ? '' : undefined, disabled: disabled, children: [_jsx("legend", { class: "rf-basic-sr-only", children: label }), _jsxs("div", { class: "rf-segmented-track", mix: indicators, children: [_jsx("span", { class: "rf-highlight rf-segmented-thumb", "aria-hidden": "true", "data-rf-selection-layer": true }), _jsx("span", { class: "rf-highlight rf-segmented-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 }), options.map((option) => option.iconOnly && option.icon != null ? (_jsx(Tooltip, { content: option.label, renderTrigger: (trigger) => segment(option, trigger.mix) }, option.value)) : (segment(option)))] })] }));
37
+ };
38
+ }
package/dist/styles.css CHANGED
@@ -10,4 +10,6 @@
10
10
  @import './range-color.css';
11
11
  @import './color-picker.css';
12
12
  @import './breadcrumb.css';
13
+ @import './segmented-control.css';
14
+ @import './popover.css';
13
15
  @import './systems.css';
package/dist/systems.js CHANGED
@@ -55,7 +55,11 @@ export function reveal(options = {}) {
55
55
  return;
56
56
  observer.disconnect();
57
57
  animation = element.animate([
58
- { opacity: 0.3, translate: `0 ${preset?.distance ?? 6}px`, scale: preset?.scale ?? 1 },
58
+ {
59
+ opacity: preset ? 0 : 0.3,
60
+ translate: `0 ${preset?.distance ?? 6}px`,
61
+ scale: preset?.scale ?? 1,
62
+ },
59
63
  { opacity: 1, translate: '0 0', scale: 1 },
60
64
  ], {
61
65
  ...(preset
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtzll/reflow",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist",