@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 +1 -1
- package/dist/breadcrumb.css +10 -0
- package/dist/breadcrumb.js +38 -40
- package/dist/foundations.css +21 -14
- package/dist/hover-card.d.ts +32 -0
- package/dist/hover-card.js +119 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/overlays.js +14 -2
- package/dist/popover.css +69 -0
- package/dist/popover.d.ts +38 -0
- package/dist/popover.js +57 -0
- package/dist/segmented-control.css +172 -0
- package/dist/segmented-control.d.ts +34 -0
- package/dist/segmented-control.js +38 -0
- package/dist/styles.css +2 -0
- package/dist/systems.js +5 -1
- package/package.json +1 -1
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
|
|
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',
|
package/dist/breadcrumb.css
CHANGED
|
@@ -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 {
|
package/dist/breadcrumb.js
CHANGED
|
@@ -11,14 +11,12 @@ const fallbackEllipsisWidth = 44;
|
|
|
11
11
|
*/
|
|
12
12
|
export function Breadcrumb(handle) {
|
|
13
13
|
let list = null;
|
|
14
|
-
let hidden
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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.
|
|
121
|
-
|
|
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
|
}
|
package/dist/foundations.css
CHANGED
|
@@ -441,38 +441,45 @@
|
|
|
441
441
|
position: absolute;
|
|
442
442
|
top: 50%;
|
|
443
443
|
left: 50%;
|
|
444
|
-
width: var(--rf-
|
|
445
|
-
height: var(--rf-
|
|
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
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
465
|
+
transform: rotate(360deg);
|
|
460
466
|
}
|
|
461
467
|
}
|
|
462
468
|
|
|
463
|
-
@keyframes rf-button-spinner-
|
|
469
|
+
@keyframes rf-button-spinner-breathe {
|
|
464
470
|
0%,
|
|
465
471
|
100% {
|
|
466
|
-
stroke-dasharray:
|
|
472
|
+
stroke-dasharray: 0.01 49.99;
|
|
473
|
+
stroke-dashoffset: -25;
|
|
467
474
|
}
|
|
468
475
|
50% {
|
|
469
|
-
stroke-dasharray:
|
|
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
|
|
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;
|
package/dist/popover.css
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/* Popover: a panel anchored to its trigger. It shares the menu surfaces'
|
|
2
|
+
elevation and motion: it drops 4px and grows from 96% on the fast spring,
|
|
3
|
+
toward the trigger's side, and leaves on the fast exit. */
|
|
4
|
+
.rf-popover {
|
|
5
|
+
display: inline-block;
|
|
6
|
+
max-inline-size: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.rf-popover-surface {
|
|
10
|
+
gap: 12px;
|
|
11
|
+
inline-size: max-content;
|
|
12
|
+
min-inline-size: 200px;
|
|
13
|
+
max-inline-size: min(320px, calc(100vw - 16px));
|
|
14
|
+
padding: 12px;
|
|
15
|
+
border: 0;
|
|
16
|
+
outline: none;
|
|
17
|
+
background: var(--rf-surface-3, white);
|
|
18
|
+
box-shadow:
|
|
19
|
+
inset 0 1px light-dark(transparent, rgb(255 255 255 / 2%)),
|
|
20
|
+
inset 0 0 0 1px light-dark(transparent, rgb(255 255 255 / 2%)),
|
|
21
|
+
0 0 0 1px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 12%)),
|
|
22
|
+
0 1px 1px -0.5px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 18%)),
|
|
23
|
+
0 3px 3px -1.5px light-dark(rgb(0 0 0 / 6%), rgb(0 0 0 / 18%)),
|
|
24
|
+
0 12px 28px light-dark(rgb(0 0 0 / 10%), rgb(0 0 0 / 24%));
|
|
25
|
+
font-size: var(--rf-text-body, 13px);
|
|
26
|
+
line-height: var(--rf-leading-body, 20px);
|
|
27
|
+
}
|
|
28
|
+
/* Only while open: a display value on the closed panel would override the
|
|
29
|
+
browser's hiding, leaving an invisible panel that catches clicks. */
|
|
30
|
+
.rf-popover-surface:popover-open {
|
|
31
|
+
display: grid;
|
|
32
|
+
}
|
|
33
|
+
.rf-popover-surface[data-density='compact'] {
|
|
34
|
+
gap: 8px;
|
|
35
|
+
padding: 10px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Beside the trigger, the panel slides in sideways instead. */
|
|
39
|
+
.rf-popover-surface[data-anchor-placement^='left'] {
|
|
40
|
+
--rf-menu-rest-transform: translateX(4px) scaleX(0.96);
|
|
41
|
+
transform-origin: right;
|
|
42
|
+
}
|
|
43
|
+
.rf-popover-surface[data-anchor-placement^='right'] {
|
|
44
|
+
--rf-menu-rest-transform: translateX(-4px) scaleX(0.96);
|
|
45
|
+
transform-origin: left;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.rf-popover-header {
|
|
49
|
+
display: grid;
|
|
50
|
+
gap: 4px;
|
|
51
|
+
}
|
|
52
|
+
.rf-popover-title {
|
|
53
|
+
margin: 0;
|
|
54
|
+
color: var(--rf-fg);
|
|
55
|
+
font-size: inherit;
|
|
56
|
+
line-height: inherit;
|
|
57
|
+
font-variation-settings: var(--rf-weight-strong);
|
|
58
|
+
}
|
|
59
|
+
.rf-popover-description {
|
|
60
|
+
margin: 0;
|
|
61
|
+
color: var(--rf-muted);
|
|
62
|
+
font-size: var(--rf-text-caption, 12px);
|
|
63
|
+
line-height: var(--rf-leading-caption, 16px);
|
|
64
|
+
}
|
|
65
|
+
.rf-popover-body {
|
|
66
|
+
display: grid;
|
|
67
|
+
gap: 8px;
|
|
68
|
+
min-inline-size: 0;
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type Handle, type MixInput, type RemixNode } from 'remix/component';
|
|
2
|
+
import { type ButtonProps } from './basic.tsx';
|
|
3
|
+
export type PopoverPlacement = 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end';
|
|
4
|
+
export interface PopoverTriggerProps {
|
|
5
|
+
'aria-haspopup': 'dialog';
|
|
6
|
+
'aria-expanded': boolean;
|
|
7
|
+
'aria-controls': string;
|
|
8
|
+
mix: MixInput<HTMLElement>;
|
|
9
|
+
}
|
|
10
|
+
export interface PopoverProps {
|
|
11
|
+
/** Names the panel when it has no visible title. */
|
|
12
|
+
label?: string;
|
|
13
|
+
/** A visible heading that names the panel. */
|
|
14
|
+
title?: RemixNode;
|
|
15
|
+
description?: RemixNode;
|
|
16
|
+
children?: RemixNode;
|
|
17
|
+
/** Contents of the button that opens the panel. */
|
|
18
|
+
trigger?: RemixNode;
|
|
19
|
+
triggerVariant?: ButtonProps['variant'];
|
|
20
|
+
triggerSize?: ButtonProps['size'];
|
|
21
|
+
/** Renders your own trigger. Spread the provided props onto a focusable element. */
|
|
22
|
+
renderTrigger?: (props: PopoverTriggerProps) => RemixNode;
|
|
23
|
+
open?: boolean;
|
|
24
|
+
defaultOpen?: boolean;
|
|
25
|
+
onOpenChange?: (open: boolean) => void;
|
|
26
|
+
/** Which side of the trigger the panel opens on. Default bottom. */
|
|
27
|
+
placement?: PopoverPlacement;
|
|
28
|
+
/** Distance from the trigger in pixels. Default 8. */
|
|
29
|
+
offset?: number;
|
|
30
|
+
size?: 'default' | 'compact';
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A panel anchored to a trigger, for content that needs more room than a
|
|
34
|
+
* tooltip: a filter, inline settings, a share sheet. A click opens it; Escape
|
|
35
|
+
* and a click outside close it. It is a non-modal dialog: focus moves to its
|
|
36
|
+
* first field, or to the panel, on open and returns to the trigger on close.
|
|
37
|
+
*/
|
|
38
|
+
export declare function Popover(handle: Handle<PopoverProps>): () => import("remix/component").RemixElement;
|
package/dist/popover.js
ADDED
|
@@ -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
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
|
-
{
|
|
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
|