@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/README.md +1 -1
- package/dist/basic.js +1 -1
- package/dist/breadcrumb.css +10 -0
- package/dist/breadcrumb.js +67 -38
- package/dist/color-picker.css +16 -0
- package/dist/color-picker.js +83 -9
- package/dist/compositions.css +27 -7
- package/dist/compositions.js +67 -2
- package/dist/dates.css +108 -3
- package/dist/dates.js +16 -5
- package/dist/flows.css +4 -1
- package/dist/foundations.css +21 -14
- package/dist/groups.css +34 -0
- package/dist/groups.js +9 -1
- package/dist/hover-card.d.ts +32 -0
- package/dist/hover-card.js +119 -0
- package/dist/icon-data.d.ts +13 -0
- package/dist/icon-data.js +4 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/interactive.d.ts +3 -1
- package/dist/interactive.js +3 -1
- 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/selection-runs.d.ts +4 -0
- package/dist/selection-runs.js +221 -0
- package/dist/sidebar.css +8 -0
- package/dist/styles.css +2 -0
- package/dist/systems.js +5 -1
- package/package.json +1 -1
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
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { ref } from 'remix/component';
|
|
2
|
+
// The reference's merged-background timing: the inner corners trail the edges
|
|
3
|
+
// by 70ms, and a merge commits once the halves have met.
|
|
4
|
+
const cornerDelay = 70;
|
|
5
|
+
const springMs = 200;
|
|
6
|
+
const convergeMs = springMs + cornerDelay + 80;
|
|
7
|
+
/** Groups sorted row indices into runs, keeping each run's id while any of its rows had one. */
|
|
8
|
+
function assignRuns(sorted, previous, last) {
|
|
9
|
+
const spans = [];
|
|
10
|
+
for (const index of sorted) {
|
|
11
|
+
const span = spans[spans.length - 1];
|
|
12
|
+
if (span && index === span.end + 1)
|
|
13
|
+
span.end = index;
|
|
14
|
+
else
|
|
15
|
+
spans.push({ start: index, end: index });
|
|
16
|
+
}
|
|
17
|
+
const used = new Set();
|
|
18
|
+
const byRow = new Map();
|
|
19
|
+
const runs = spans.map((span) => {
|
|
20
|
+
let id;
|
|
21
|
+
for (let i = span.start; i <= span.end && id === undefined; i++) {
|
|
22
|
+
const prior = previous.get(i);
|
|
23
|
+
if (prior !== undefined && !used.has(prior))
|
|
24
|
+
id = prior;
|
|
25
|
+
}
|
|
26
|
+
id ??= ++last.id;
|
|
27
|
+
used.add(id);
|
|
28
|
+
for (let i = span.start; i <= span.end; i++)
|
|
29
|
+
byRow.set(i, id);
|
|
30
|
+
return { ...span, id };
|
|
31
|
+
});
|
|
32
|
+
return { runs, byRow };
|
|
33
|
+
}
|
|
34
|
+
/** Two runs inside `outer`, separated by exactly one row: the shape one click merges or splits. */
|
|
35
|
+
function bridge(outer, runs) {
|
|
36
|
+
const inside = runs
|
|
37
|
+
.filter((run) => run.start >= outer.start && run.end <= outer.end)
|
|
38
|
+
.sort((a, b) => a.start - b.start);
|
|
39
|
+
if (inside.length !== 2)
|
|
40
|
+
return null;
|
|
41
|
+
const [up, low] = inside;
|
|
42
|
+
return low.start === up.end + 2 ? { up, low, gap: up.end + 1 } : null;
|
|
43
|
+
}
|
|
44
|
+
export function selectionRuns() {
|
|
45
|
+
let host = null;
|
|
46
|
+
let layer = null;
|
|
47
|
+
let runs = [];
|
|
48
|
+
let byRow = new Map();
|
|
49
|
+
const last = { id: 0 };
|
|
50
|
+
const blocks = new Map();
|
|
51
|
+
// Halves absorbed by a merge, dropped when the merge commits.
|
|
52
|
+
const ghosts = new Set();
|
|
53
|
+
let commit;
|
|
54
|
+
let frame = 0;
|
|
55
|
+
function rowBoxes(rows) {
|
|
56
|
+
const parent = host.getBoundingClientRect();
|
|
57
|
+
// Translate back into local CSS pixels when a preview scales its contents.
|
|
58
|
+
const scaleX = parent.width / host.offsetWidth || 1;
|
|
59
|
+
const scaleY = parent.height / host.offsetHeight || 1;
|
|
60
|
+
return rows.map((row) => {
|
|
61
|
+
const rect = row.getBoundingClientRect();
|
|
62
|
+
return {
|
|
63
|
+
top: (rect.top - parent.top) / scaleY - host.clientTop,
|
|
64
|
+
left: (rect.left - parent.left) / scaleX - host.clientLeft,
|
|
65
|
+
width: rect.width / scaleX,
|
|
66
|
+
height: rect.height / scaleY,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
function paint(block, box, radii, { instant = false, delayCorners = false } = {}) {
|
|
71
|
+
block.toggleAttribute('data-instant', instant);
|
|
72
|
+
block.style.setProperty('--rf-corner-delay', delayCorners ? `${cornerDelay}ms` : '0ms');
|
|
73
|
+
block.style.top = `${box.top}px`;
|
|
74
|
+
block.style.left = `${box.left}px`;
|
|
75
|
+
block.style.width = `${box.width}px`;
|
|
76
|
+
block.style.height = `${box.height}px`;
|
|
77
|
+
block.style.borderRadius = radii.map((r) => `${r}px`).join(' ');
|
|
78
|
+
if (instant)
|
|
79
|
+
void block.offsetWidth;
|
|
80
|
+
}
|
|
81
|
+
function create() {
|
|
82
|
+
const block = host.ownerDocument.createElement('span');
|
|
83
|
+
block.className = 'rf-choice-run';
|
|
84
|
+
block.setAttribute('data-instant', '');
|
|
85
|
+
layer.append(block);
|
|
86
|
+
return block;
|
|
87
|
+
}
|
|
88
|
+
function drop(block, instant) {
|
|
89
|
+
ghosts.delete(block);
|
|
90
|
+
if (instant) {
|
|
91
|
+
block.remove();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
block.setAttribute('data-leaving', '');
|
|
95
|
+
setTimeout(() => block.remove(), 200);
|
|
96
|
+
}
|
|
97
|
+
function sync() {
|
|
98
|
+
frame = 0;
|
|
99
|
+
if (!host || !layer)
|
|
100
|
+
return;
|
|
101
|
+
const rows = Array.from(host.querySelectorAll(':scope > .rf-choice-group-row'));
|
|
102
|
+
const checked = rows.flatMap((row, index) => row.querySelector('input:checked') ? [index] : []);
|
|
103
|
+
const previous = runs;
|
|
104
|
+
const next = assignRuns(checked, byRow, last);
|
|
105
|
+
runs = next.runs;
|
|
106
|
+
byRow = next.byRow;
|
|
107
|
+
const boxes = rowBoxes(rows);
|
|
108
|
+
const radius = parseFloat(getComputedStyle(rows[0] ?? host).borderTopLeftRadius) || 0;
|
|
109
|
+
const full = [radius, radius, radius, radius];
|
|
110
|
+
const span = (run) => {
|
|
111
|
+
const start = boxes[run.start];
|
|
112
|
+
const end = boxes[run.end];
|
|
113
|
+
return {
|
|
114
|
+
top: start.top,
|
|
115
|
+
left: Math.min(start.left, end.left),
|
|
116
|
+
width: Math.max(start.width, end.width),
|
|
117
|
+
height: end.top + end.height - start.top,
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
// A merge in flight that this change undid leaves its ghost behind.
|
|
121
|
+
clearTimeout(commit);
|
|
122
|
+
for (const ghost of ghosts)
|
|
123
|
+
drop(ghost, true);
|
|
124
|
+
// Runs that are gone fade out, unless a merge absorbs them below.
|
|
125
|
+
const live = new Set(runs.map((run) => run.id));
|
|
126
|
+
const merges = runs.flatMap((run) => {
|
|
127
|
+
const pair = bridge(run, previous);
|
|
128
|
+
return pair && (pair.up.id === run.id || pair.low.id === run.id) ? [{ run, ...pair }] : [];
|
|
129
|
+
});
|
|
130
|
+
const splits = previous.flatMap((run) => {
|
|
131
|
+
const pair = bridge(run, runs);
|
|
132
|
+
return pair ? [{ run, ...pair }] : [];
|
|
133
|
+
});
|
|
134
|
+
const absorbed = new Set(merges.map(({ run, up, low }) => (run.id === up.id ? low.id : up.id)));
|
|
135
|
+
// Blocks of runs a merge absorbed, kept to glide in as the lower half.
|
|
136
|
+
const retired = new Map();
|
|
137
|
+
for (const [id, block] of blocks) {
|
|
138
|
+
if (live.has(id))
|
|
139
|
+
continue;
|
|
140
|
+
blocks.delete(id);
|
|
141
|
+
if (absorbed.has(id))
|
|
142
|
+
retired.set(id, block);
|
|
143
|
+
else
|
|
144
|
+
drop(block, false);
|
|
145
|
+
}
|
|
146
|
+
for (const run of runs) {
|
|
147
|
+
const fresh = !blocks.has(run.id);
|
|
148
|
+
const block = blocks.get(run.id) ?? create();
|
|
149
|
+
blocks.set(run.id, block);
|
|
150
|
+
if (fresh)
|
|
151
|
+
paint(block, span(run), full, { instant: true });
|
|
152
|
+
else
|
|
153
|
+
paint(block, span(run), full);
|
|
154
|
+
}
|
|
155
|
+
for (const { run, up, low, gap } of merges) {
|
|
156
|
+
const otherId = run.id === up.id ? low.id : up.id;
|
|
157
|
+
const ghost = retired.get(otherId);
|
|
158
|
+
const survivor = blocks.get(run.id);
|
|
159
|
+
const box = span(run);
|
|
160
|
+
const middle = boxes[gap].top + boxes[gap].height / 2;
|
|
161
|
+
// The halves glide to the middle of the bridge row, inner corners straightening.
|
|
162
|
+
paint(survivor, { ...box, height: middle - box.top }, [radius, radius, 0, 0], {
|
|
163
|
+
delayCorners: true,
|
|
164
|
+
});
|
|
165
|
+
if (ghost) {
|
|
166
|
+
ghosts.add(ghost);
|
|
167
|
+
paint(ghost, { ...box, top: middle, height: box.top + box.height - middle }, [0, 0, radius, radius], { delayCorners: true });
|
|
168
|
+
}
|
|
169
|
+
commit = setTimeout(() => {
|
|
170
|
+
// One block over the whole run, swapped in without visible motion.
|
|
171
|
+
paint(survivor, span(run), full, { instant: true });
|
|
172
|
+
for (const ghost of ghosts)
|
|
173
|
+
drop(ghost, true);
|
|
174
|
+
}, convergeMs);
|
|
175
|
+
}
|
|
176
|
+
for (const { run, up, low, gap } of splits) {
|
|
177
|
+
const upper = blocks.get(up.id);
|
|
178
|
+
const lower = blocks.get(low.id);
|
|
179
|
+
if (!upper || !lower)
|
|
180
|
+
continue;
|
|
181
|
+
const box = span(run);
|
|
182
|
+
const middle = boxes[gap].top + boxes[gap].height / 2;
|
|
183
|
+
// Snap into two abutting halves at the seam, then glide apart.
|
|
184
|
+
paint(upper, { ...box, height: middle - box.top }, [radius, radius, 0, 0], {
|
|
185
|
+
instant: true,
|
|
186
|
+
});
|
|
187
|
+
paint(lower, { ...box, top: middle, height: box.top + box.height - middle }, [0, 0, radius, radius], { instant: true });
|
|
188
|
+
paint(upper, span(up), full);
|
|
189
|
+
paint(lower, span(low), full);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
function schedule() {
|
|
193
|
+
if (!frame)
|
|
194
|
+
frame = requestAnimationFrame(sync);
|
|
195
|
+
}
|
|
196
|
+
const mix = ref((element, signal) => {
|
|
197
|
+
if (!(element instanceof HTMLElement))
|
|
198
|
+
return;
|
|
199
|
+
host = element;
|
|
200
|
+
layer = element.querySelector(':scope > [data-rf-selection-runs]');
|
|
201
|
+
if (!layer)
|
|
202
|
+
return;
|
|
203
|
+
host.setAttribute('data-rf-selection-blocks', '');
|
|
204
|
+
const size = new ResizeObserver(schedule);
|
|
205
|
+
size.observe(element);
|
|
206
|
+
element.addEventListener('change', () => sync(), { signal });
|
|
207
|
+
element.closest('form')?.addEventListener('reset', () => setTimeout(sync), { signal });
|
|
208
|
+
sync();
|
|
209
|
+
signal.addEventListener('abort', () => {
|
|
210
|
+
size.disconnect();
|
|
211
|
+
cancelAnimationFrame(frame);
|
|
212
|
+
clearTimeout(commit);
|
|
213
|
+
if (host === element) {
|
|
214
|
+
host.removeAttribute('data-rf-selection-blocks');
|
|
215
|
+
host = null;
|
|
216
|
+
layer = null;
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
return { mix, sync: schedule };
|
|
221
|
+
}
|
package/dist/sidebar.css
CHANGED
|
@@ -184,6 +184,14 @@
|
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
|
+
/* These menus mark keyboard focus with the row highlight, not the moving ring. */
|
|
188
|
+
:is(.rf-sidebar-header, .rf-sidebar-footer)
|
|
189
|
+
.rf-sidebar-row
|
|
190
|
+
+ .rf-dropdown-surface
|
|
191
|
+
> .rf-menu-list
|
|
192
|
+
> .rf-focus-ring {
|
|
193
|
+
display: none;
|
|
194
|
+
}
|
|
187
195
|
.rf-sidebar-row {
|
|
188
196
|
position: relative;
|
|
189
197
|
display: flex;
|
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
|