hlmnhkl-ui 0.27.0 → 0.32.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 +2 -2
- package/package.json +2 -2
- package/scripts/pro-components.json +1 -0
- package/src/components/Button/Button.module.css +1 -1
- package/src/components/Dialog/Dialog.module.css +4 -2
- package/src/components/DropdownMenu/DropdownMenu.module.css +10 -2
- package/src/components/DropdownMenu/DropdownMenu.tsx +3 -0
- package/src/components/Fab/Fab.module.css +7 -3
- package/src/components/IconButton/IconButton.module.css +1 -1
- package/src/components/Popover/Popover.module.css +10 -2
- package/src/components/Popover/Popover.tsx +3 -0
- package/src/components/SegmentedControl/SegmentedControl.module.css +6 -1
- package/src/components/SegmentedControl/SegmentedControl.tsx +6 -0
- package/src/components/Select/Select.module.css +6 -0
- package/src/components/Select/Select.tsx +4 -1
- package/src/components/Sheet/Sheet.module.css +9 -3
- package/src/components/Sheet/Sheet.tsx +20 -12
- package/src/components/Slider/Slider.module.css +30 -3
- package/src/components/Slider/Slider.tsx +129 -6
- package/src/components/Tabs/Tabs.module.css +5 -0
- package/src/components/Tabs/Tabs.tsx +6 -0
- package/src/components/Toast/Toast.module.css +13 -2
- package/src/components/Toast/Toast.tsx +44 -14
- package/src/components/Toggle/Toggle.module.css +1 -1
- package/src/internal/liquid.ts +137 -0
- package/src/internal/magnet.ts +19 -0
- package/src/internal/motion.ts +2 -2
- package/src/internal/spring.ts +107 -0
- package/src/internal/useFloating.ts +11 -2
- package/src/tokens.css +42 -19
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# hlmnhkl/ui
|
|
2
2
|
|
|
3
|
-
React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey, and copied into your app so the code is yours.
|
|
3
|
+
React components, measured down to the part you never see: the 44px tap area, the 2px focus ring, the timing of every move. Black, white and grey with one blue accent, and copied into your app so the code is yours.
|
|
4
4
|
|
|
5
5
|
<picture>
|
|
6
6
|
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/hilmanhaikal/hlmnhkl-ui/main/.github/assets/home-dark.png">
|
|
@@ -74,7 +74,7 @@ Setup for other tools: [MCP server](https://ui.hlmnhkl.my/mcp).
|
|
|
74
74
|
|
|
75
75
|
## Pro
|
|
76
76
|
|
|
77
|
-
|
|
77
|
+
72 more components (such as DataGrid, Calendar, Chart, KanbanBoard and Combobox), page blocks, full templates and brand themes are coming as hlmnhkl/ui Pro. You can see them on the [docs site](https://ui.hlmnhkl.my).
|
|
78
78
|
|
|
79
79
|
## Licence
|
|
80
80
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hlmnhkl-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "React components in
|
|
3
|
+
"version": "0.32.0",
|
|
4
|
+
"description": "React components in black, white and grey with one blue accent. Copy them into your app with npx hlmnhkl-ui add <name>.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://ui.hlmnhkl.my",
|
|
7
7
|
"repository": {
|
|
@@ -74,7 +74,7 @@
|
|
|
74
74
|
box-shadow: var(--hl-shadow-1);
|
|
75
75
|
}
|
|
76
76
|
@media (hover: hover) {
|
|
77
|
-
.primary:hover:not([aria-disabled="true"]) { background: var(--hl-color-
|
|
77
|
+
.primary:hover:not([aria-disabled="true"]) { background: var(--hl-color-accent-hover); }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
.secondary {
|
|
@@ -27,9 +27,11 @@
|
|
|
27
27
|
max-height: calc(100dvh - var(--hl-space-5) * 2);
|
|
28
28
|
display: flex;
|
|
29
29
|
flex-direction: column;
|
|
30
|
-
border: var(--hl-line) solid var(--hl-color-border);
|
|
30
|
+
border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
|
|
31
31
|
border-radius: var(--hl-radius-panel);
|
|
32
|
-
background: var(--hl-color-surface);
|
|
32
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
33
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
34
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
33
35
|
box-shadow: var(--hl-shadow-4);
|
|
34
36
|
outline: none;
|
|
35
37
|
pointer-events: auto;
|
|
@@ -6,9 +6,11 @@
|
|
|
6
6
|
max-height: calc(100dvh - 2 * var(--hl-space-5));
|
|
7
7
|
overflow-y: auto;
|
|
8
8
|
padding: var(--hl-space-2);
|
|
9
|
-
border: var(--hl-line) solid var(--hl-color-border);
|
|
9
|
+
border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
|
|
10
10
|
border-radius: var(--hl-radius-md);
|
|
11
|
-
background: var(--hl-color-surface);
|
|
11
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
12
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
13
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
12
14
|
box-shadow: var(--hl-shadow-3);
|
|
13
15
|
outline: none;
|
|
14
16
|
transform-origin: top left;
|
|
@@ -22,6 +24,12 @@
|
|
|
22
24
|
pointer-events: none;
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/* Liquid (internal/liquid.ts) grows the menu out of its button instead; the fade stands down. */
|
|
28
|
+
.menu[data-liquid],
|
|
29
|
+
.menu[data-liquid][data-state="closed"] {
|
|
30
|
+
animation: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
.item {
|
|
26
34
|
display: flex;
|
|
27
35
|
align-items: center;
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
type Ref,
|
|
15
15
|
} from 'react'
|
|
16
16
|
import { useDismiss } from '../../internal/layers'
|
|
17
|
+
import { useLiquid } from '../../internal/liquid'
|
|
17
18
|
import { mergeRefs } from '../../internal/mergeRefs'
|
|
18
19
|
import { Portal } from '../../internal/Portal'
|
|
19
20
|
import { useFloating, type Align } from '../../internal/useFloating'
|
|
@@ -61,6 +62,8 @@ export function DropdownMenu({ trigger, items, label, align = 'start' }: Dropdow
|
|
|
61
62
|
const typeahead = useRef({ text: '', at: 0 })
|
|
62
63
|
const { mounted, state } = usePresence(open, 'fast')
|
|
63
64
|
const floating = useFloating(anchorRef, menuRef, { open: mounted, side: 'bottom', align, offset: 6 })
|
|
65
|
+
// The menu grows out of its button and folds back into it.
|
|
66
|
+
useLiquid(menuRef, anchorRef, state, mounted && floating.ready)
|
|
64
67
|
|
|
65
68
|
const close = (focusTrigger = true) => {
|
|
66
69
|
setOpen(false)
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
@media (hover: hover) {
|
|
48
|
-
.fab:hover { background: var(--hl-color-
|
|
48
|
+
.fab:hover { background: var(--hl-color-accent-hover); }
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
.fab:active { scale: var(--hl-press-scale); }
|
|
@@ -142,7 +142,9 @@
|
|
|
142
142
|
.actionLabel {
|
|
143
143
|
padding: var(--hl-space-2) var(--hl-space-4);
|
|
144
144
|
border-radius: var(--hl-radius-full);
|
|
145
|
-
background: var(--hl-color-surface);
|
|
145
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
146
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
147
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
146
148
|
box-shadow: var(--hl-shadow-2);
|
|
147
149
|
font-size: var(--hl-text-sm);
|
|
148
150
|
font-weight: var(--hl-weight-medium);
|
|
@@ -157,7 +159,9 @@
|
|
|
157
159
|
height: var(--hl-hit-min);
|
|
158
160
|
margin-inline-end: calc((var(--hl-fab-size) - var(--hl-hit-min)) / 2);
|
|
159
161
|
border-radius: var(--hl-radius-full);
|
|
160
|
-
background: var(--hl-color-surface);
|
|
162
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
163
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
164
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
161
165
|
box-shadow: var(--hl-shadow-2);
|
|
162
166
|
}
|
|
163
167
|
|
|
@@ -74,5 +74,5 @@
|
|
|
74
74
|
}
|
|
75
75
|
.primary { background: var(--hl-color-accent); color: var(--hl-color-accent-fg); }
|
|
76
76
|
@media (hover: hover) {
|
|
77
|
-
.primary:hover:not(:disabled) { background: var(--hl-color-
|
|
77
|
+
.primary:hover:not(:disabled) { background: var(--hl-color-accent-hover); }
|
|
78
78
|
}
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
width: max-content;
|
|
5
5
|
max-width: min(var(--hl-panel-w-sm), calc(100vw - var(--hl-space-5) * 2));
|
|
6
6
|
padding: var(--hl-space-5);
|
|
7
|
-
border: var(--hl-line) solid var(--hl-color-border);
|
|
7
|
+
border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
|
|
8
8
|
border-radius: var(--hl-radius-panel);
|
|
9
|
-
background: var(--hl-color-surface);
|
|
9
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
10
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
11
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
10
12
|
box-shadow: var(--hl-shadow-3);
|
|
11
13
|
outline: none;
|
|
12
14
|
transform-origin: top left;
|
|
@@ -20,6 +22,12 @@
|
|
|
20
22
|
pointer-events: none;
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
/* Liquid (internal/liquid.ts) grows the panel out of its trigger instead; the pop stands down. */
|
|
26
|
+
.panel[data-liquid],
|
|
27
|
+
.panel[data-liquid][data-state="closed"] {
|
|
28
|
+
animation: none;
|
|
29
|
+
}
|
|
30
|
+
|
|
23
31
|
.head {
|
|
24
32
|
display: flex;
|
|
25
33
|
align-items: center;
|
|
@@ -14,6 +14,7 @@ import { cx } from '../../internal/cx'
|
|
|
14
14
|
import { useFocusTrap } from '../../internal/focus'
|
|
15
15
|
import { XIcon } from '../../internal/icons'
|
|
16
16
|
import { useDismiss } from '../../internal/layers'
|
|
17
|
+
import { useLiquid } from '../../internal/liquid'
|
|
17
18
|
import { mergeRefs } from '../../internal/mergeRefs'
|
|
18
19
|
import { Portal } from '../../internal/Portal'
|
|
19
20
|
import { useControllable } from '../../internal/useControllable'
|
|
@@ -67,6 +68,8 @@ export function Popover({
|
|
|
67
68
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
68
69
|
const { mounted, state } = usePresence(open, 'fast')
|
|
69
70
|
const floating = useFloating(anchorRef, panelRef, { open: mounted, side, align, offset: 8 })
|
|
71
|
+
// The panel grows out of its trigger and folds back into it.
|
|
72
|
+
useLiquid(panelRef, anchorRef, state, mounted && floating.ready)
|
|
70
73
|
|
|
71
74
|
useDismiss(open, [anchorRef, panelRef], () => setOpen(false))
|
|
72
75
|
useFocusTrap(panelRef, open && mounted)
|
|
@@ -25,13 +25,18 @@
|
|
|
25
25
|
box-shadow: var(--hl-shadow-2), 0 0 0 var(--hl-line) var(--hl-color-border);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/*
|
|
28
|
+
/* A resize slides the pill plainly; a new choice stretches it (internal/liquid.ts), which only goes
|
|
29
|
+
2% past and settles, so a pill pressed often still never wobbles. */
|
|
29
30
|
.pill[data-ready] {
|
|
30
31
|
transition:
|
|
31
32
|
transform var(--hl-motion-slow) var(--hl-ease-out),
|
|
32
33
|
width var(--hl-motion-slow) var(--hl-ease-out);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
.pill[data-liquid] {
|
|
37
|
+
transition: none;
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
/* Options share any room the track has beyond its own width, so a stretched track never ends in an empty gap. */
|
|
36
41
|
.item {
|
|
37
42
|
flex: 1 0 auto;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useLayoutEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
|
+
import { useStretch } from '../../internal/liquid'
|
|
5
6
|
import { useControllable } from '../../internal/useControllable'
|
|
6
7
|
import styles from './SegmentedControl.module.css'
|
|
7
8
|
|
|
@@ -41,6 +42,7 @@ export function SegmentedControl({
|
|
|
41
42
|
const refs = useRef<Array<HTMLButtonElement | null>>([])
|
|
42
43
|
const [pill, setPill] = useState<{ x: number; w: number } | null>(null)
|
|
43
44
|
const [ready, setReady] = useState(false)
|
|
45
|
+
const pillRef = useRef<HTMLSpanElement>(null)
|
|
44
46
|
const index = options.findIndex((o) => o.value === value)
|
|
45
47
|
// With no match, the first enabled segment takes the tab stop
|
|
46
48
|
const tabStop = index >= 0 && !options[index].disabled ? index : options.findIndex((o) => !o.disabled)
|
|
@@ -69,6 +71,9 @@ export function SegmentedControl({
|
|
|
69
71
|
}
|
|
70
72
|
}, [pill, ready])
|
|
71
73
|
|
|
74
|
+
// The pill stretches toward the new choice and settles there.
|
|
75
|
+
useStretch(pillRef, pill, index, ready, (b) => `translateX(${b.x}px)`)
|
|
76
|
+
|
|
72
77
|
const move = (from: number, dir: 1 | -1) => {
|
|
73
78
|
let i = from
|
|
74
79
|
for (let n = 0; n < options.length; n++) {
|
|
@@ -100,6 +105,7 @@ export function SegmentedControl({
|
|
|
100
105
|
>
|
|
101
106
|
{pill && (
|
|
102
107
|
<span
|
|
108
|
+
ref={pillRef}
|
|
103
109
|
className={styles.pill}
|
|
104
110
|
data-ready={ready || undefined}
|
|
105
111
|
style={{ transform: `translateX(${pill.x}px)`, width: pill.w }}
|
|
@@ -103,6 +103,12 @@
|
|
|
103
103
|
pointer-events: none;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/* Liquid (internal/liquid.ts) grows the list out of the field instead; the fade stands down. */
|
|
107
|
+
.list[data-liquid],
|
|
108
|
+
.list[data-liquid][data-state="closed"] {
|
|
109
|
+
animation: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
106
112
|
.highlight {
|
|
107
113
|
position: absolute;
|
|
108
114
|
top: 0;
|
|
@@ -4,6 +4,7 @@ import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent
|
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
5
|
import { CheckIcon, ChevronDownIcon } from '../../internal/icons'
|
|
6
6
|
import { useDismiss } from '../../internal/layers'
|
|
7
|
+
import { useLiquid } from '../../internal/liquid'
|
|
7
8
|
import { Portal } from '../../internal/Portal'
|
|
8
9
|
import { useControllable } from '../../internal/useControllable'
|
|
9
10
|
import { useFloating } from '../../internal/useFloating'
|
|
@@ -72,7 +73,9 @@ export function Select({
|
|
|
72
73
|
const optionRefs = useRef<Array<HTMLDivElement | null>>([])
|
|
73
74
|
const typeahead = useRef({ text: '', at: 0 })
|
|
74
75
|
const { mounted, state } = usePresence(open, 'fast')
|
|
75
|
-
const { style, side } = useFloating(triggerRef, listRef, { open: mounted, side: 'bottom', align: 'start', offset: 6, matchWidth: true })
|
|
76
|
+
const { style, side, ready } = useFloating(triggerRef, listRef, { open: mounted, side: 'bottom', align: 'start', offset: 6, matchWidth: true })
|
|
77
|
+
// The list grows out of the field and folds back into it.
|
|
78
|
+
useLiquid(listRef, triggerRef, state, mounted && ready)
|
|
76
79
|
const [hl, setHl] = useState<{ top: number; height: number } | null>(null)
|
|
77
80
|
|
|
78
81
|
const selectedIndex = options.findIndex((o) => o.value === value)
|
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
display: flex;
|
|
20
20
|
flex-direction: column;
|
|
21
21
|
margin-left: calc(min(100%, var(--hl-panel-w-lg)) / -2);
|
|
22
|
-
border: var(--hl-line) solid var(--hl-color-border);
|
|
22
|
+
border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
|
|
23
23
|
border-bottom: 0;
|
|
24
24
|
border-radius: var(--hl-radius-sheet) var(--hl-radius-sheet) 0 0;
|
|
25
|
-
background: var(--hl-color-surface);
|
|
25
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
26
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
27
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
26
28
|
box-shadow: var(--hl-shadow-4);
|
|
27
29
|
outline: none;
|
|
28
30
|
padding-bottom: env(safe-area-inset-bottom);
|
|
@@ -30,7 +32,11 @@
|
|
|
30
32
|
transition: transform var(--hl-motion-slow) var(--hl-ease-spring);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
.sheet[data-dragging]
|
|
35
|
+
.sheet[data-dragging],
|
|
36
|
+
.sheet[data-fluid] { transition: none; }
|
|
37
|
+
|
|
38
|
+
/* A spring is carrying it from the hand, so the CSS close must not start over from the top. */
|
|
39
|
+
.sheet[data-fluid][data-state="closed"] { animation: none; }
|
|
34
40
|
|
|
35
41
|
/* No "from" frame: the close starts where a drag left the sheet */
|
|
36
42
|
.sheet[data-state="closed"] {
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useId, useRef, type PointerEvent, type ReactNode, type RefObject } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
|
+
import { rubber, speedTracker, useFling } from '../../internal/spring'
|
|
5
6
|
import { useFocusTrap } from '../../internal/focus'
|
|
6
7
|
import { XIcon } from '../../internal/icons'
|
|
7
8
|
import { useDismiss, useScrollLock } from '../../internal/layers'
|
|
@@ -31,15 +32,17 @@ export type SheetProps = {
|
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
const CLOSE_RATIO = 0.3
|
|
34
|
-
|
|
35
|
+
// px per second: a flick down this fast closes the sheet from anywhere.
|
|
36
|
+
const CLOSE_SPEED = 500
|
|
35
37
|
|
|
36
38
|
export function Sheet({ open, onOpenChange, title, description, children, footer, closeLabel = 'Close', initialFocus, className }: SheetProps) {
|
|
37
39
|
const id = useId()
|
|
38
40
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
39
|
-
const drag = useRef<{ y: number;
|
|
41
|
+
const drag = useRef<{ y: number; dy: number; pointer: number; speed: ReturnType<typeof speedTracker> } | null>(null)
|
|
40
42
|
const { mounted, state } = usePresence(open, 'slow')
|
|
41
43
|
const openRef = useRef(open)
|
|
42
44
|
openRef.current = open
|
|
45
|
+
const { at, paint, fling, stop } = useFling(panelRef, open, 'y')
|
|
43
46
|
|
|
44
47
|
useScrollLock(open)
|
|
45
48
|
useDismiss(open, [panelRef], () => onOpenChange(false))
|
|
@@ -48,7 +51,10 @@ export function Sheet({ open, onOpenChange, title, description, children, footer
|
|
|
48
51
|
const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
49
52
|
if ((e.target as HTMLElement).closest('button')) return
|
|
50
53
|
if (e.pointerType === 'mouse' && e.button !== 0) return
|
|
51
|
-
|
|
54
|
+
stop()
|
|
55
|
+
// Caught mid-spring, it carries on from where it is.
|
|
56
|
+
drag.current = { y: e.clientY - at.current, dy: at.current, pointer: e.pointerId, speed: speedTracker() }
|
|
57
|
+
drag.current.speed.add(at.current)
|
|
52
58
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
53
59
|
panelRef.current?.setAttribute('data-dragging', '')
|
|
54
60
|
}
|
|
@@ -58,8 +64,9 @@ export function Sheet({ open, onOpenChange, title, description, children, footer
|
|
|
58
64
|
const panel = panelRef.current
|
|
59
65
|
if (!d || !panel || d.pointer !== e.pointerId) return
|
|
60
66
|
const raw = e.clientY - d.y
|
|
61
|
-
d.dy = raw
|
|
62
|
-
|
|
67
|
+
d.dy = raw >= 0 ? raw : rubber(raw, panel.offsetHeight)
|
|
68
|
+
d.speed.add(d.dy)
|
|
69
|
+
paint(d.dy)
|
|
63
70
|
}
|
|
64
71
|
|
|
65
72
|
const onPointerUp = (e: PointerEvent<HTMLDivElement>) => {
|
|
@@ -69,16 +76,17 @@ export function Sheet({ open, onOpenChange, title, description, children, footer
|
|
|
69
76
|
if (!d || !panel) return
|
|
70
77
|
e.currentTarget.releasePointerCapture(d.pointer)
|
|
71
78
|
panel.removeAttribute('data-dragging')
|
|
72
|
-
const
|
|
73
|
-
|
|
79
|
+
const cancelled = e.type === 'pointercancel'
|
|
80
|
+
const speed = cancelled ? 0 : d.speed.speed()
|
|
81
|
+
const h = panel.offsetHeight
|
|
82
|
+
if (!cancelled && (d.dy > h * CLOSE_RATIO || (speed > CLOSE_SPEED && d.dy > 24))) {
|
|
83
|
+
fling(h, speed)
|
|
74
84
|
onOpenChange(false)
|
|
75
|
-
//
|
|
85
|
+
// If the parent keeps it open, it springs back.
|
|
76
86
|
requestAnimationFrame(() => {
|
|
77
|
-
if (openRef.current)
|
|
87
|
+
if (openRef.current) fling(0, 0)
|
|
78
88
|
})
|
|
79
|
-
} else
|
|
80
|
-
panel.style.transform = ''
|
|
81
|
-
}
|
|
89
|
+
} else fling(0, speed)
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
if (!mounted) return null
|
|
@@ -116,13 +116,40 @@
|
|
|
116
116
|
pointer-events: none;
|
|
117
117
|
opacity: 0;
|
|
118
118
|
transform: translate(-50%, var(--hl-shift-sm)) scale(0.8);
|
|
119
|
+
/* It stays a beat after a click or a let-go, so the value can be read before it fades. */
|
|
119
120
|
transition:
|
|
120
|
-
opacity var(--hl-motion-fast) var(--hl-ease-out),
|
|
121
|
-
transform var(--hl-motion-base) var(--hl-ease-spring);
|
|
121
|
+
opacity var(--hl-motion-fast) var(--hl-ease-out) var(--hl-motion-base),
|
|
122
|
+
transform var(--hl-motion-base) var(--hl-ease-spring) var(--hl-motion-base);
|
|
122
123
|
}
|
|
123
124
|
|
|
124
125
|
.input:active:not(:disabled) + .bubble,
|
|
125
|
-
.input:focus-visible + .bubble
|
|
126
|
+
.input:focus-visible + .bubble,
|
|
127
|
+
.trackWrap[data-gliding] .bubble {
|
|
126
128
|
opacity: 1;
|
|
127
129
|
transform: translate(-50%, 0) scale(1);
|
|
130
|
+
transition-delay: 0s;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* Marks: dots on the track where the thumb's centre lands for each value. They never take a press, and
|
|
134
|
+
the one under the chosen value hides, so a dot never sits on the thumb at rest. */
|
|
135
|
+
.marks {
|
|
136
|
+
position: absolute;
|
|
137
|
+
inset: 0;
|
|
138
|
+
pointer-events: none;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.mark {
|
|
142
|
+
position: absolute;
|
|
143
|
+
top: 50%;
|
|
144
|
+
left: calc(var(--thumb) / 2 + (100% - var(--thumb)) * var(--m));
|
|
145
|
+
width: var(--hl-space-2);
|
|
146
|
+
height: var(--hl-space-2);
|
|
147
|
+
border-radius: var(--hl-radius-full);
|
|
148
|
+
background: var(--hl-color-border-strong);
|
|
149
|
+
translate: -50% -50%;
|
|
150
|
+
transition: opacity var(--hl-motion-fast) var(--hl-ease-out);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.mark[data-on] {
|
|
154
|
+
opacity: 0;
|
|
128
155
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useId, type ComponentPropsWithRef } from 'react'
|
|
3
|
+
import { useEffect, useId, useRef, useState, type ComponentPropsWithRef, type KeyboardEvent, type PointerEvent } from 'react'
|
|
4
4
|
import { cx } from '../../internal/cx'
|
|
5
|
+
import { magnetTo, nearestMark } from '../../internal/magnet'
|
|
6
|
+
import { motionMs, prefersReducedMotion } from '../../internal/motion'
|
|
5
7
|
import { useControllable } from '../../internal/useControllable'
|
|
6
8
|
import styles from './Slider.module.css'
|
|
7
9
|
|
|
@@ -24,6 +26,8 @@ export type SliderProps = Omit<ComponentPropsWithRef<'input'>, 'type' | 'value'
|
|
|
24
26
|
showValue?: boolean
|
|
25
27
|
/** Turns the number into the text shown and read by screen readers. */
|
|
26
28
|
formatValue?: (value: number) => string
|
|
29
|
+
/** Values the thumb is drawn to as it passes, shown as dots on the track. Let go near one and it lands on it; Page Up and Page Down jump between them. */
|
|
30
|
+
marks?: number[]
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export function Slider({
|
|
@@ -36,16 +40,81 @@ export function Slider({
|
|
|
36
40
|
step = 1,
|
|
37
41
|
showValue = true,
|
|
38
42
|
formatValue = String,
|
|
43
|
+
marks,
|
|
39
44
|
id,
|
|
40
45
|
className,
|
|
41
46
|
disabled,
|
|
47
|
+
onPointerDown,
|
|
48
|
+
onPointerMove,
|
|
49
|
+
onPointerUp,
|
|
50
|
+
onPointerCancel,
|
|
51
|
+
onKeyDown,
|
|
52
|
+
onBlur,
|
|
42
53
|
...rest
|
|
43
54
|
}: SliderProps) {
|
|
44
55
|
const auto = useId()
|
|
45
56
|
const inputId = id ?? auto
|
|
46
57
|
const [value, setValue] = useControllable(valueProp, defaultValue ?? min, onValueChange)
|
|
47
|
-
|
|
58
|
+
// Where the thumb is drawn while it glides to a click or settles on a mark; the value is set already.
|
|
59
|
+
const [shown, setShown] = useState<number | null>(null)
|
|
60
|
+
// `grab` is how far from the thumb's centre it was picked up, so the thumb does not jump to the pointer.
|
|
61
|
+
const press = useRef<{ x: number; lastX: number; moved: boolean; grab: number } | null>(null)
|
|
62
|
+
const glide = useRef(0)
|
|
63
|
+
const latest = useRef(value)
|
|
64
|
+
latest.current = value
|
|
65
|
+
const at = shown ?? value
|
|
66
|
+
const pct = max === min ? 0 : ((at - min) / (max - min)) * 100
|
|
48
67
|
const text = formatValue(value)
|
|
68
|
+
// How near a mark the pull starts: 6% of the track.
|
|
69
|
+
const reach = (max - min) * 0.06
|
|
70
|
+
const decimals = (String(step).split('.')[1] ?? '').length
|
|
71
|
+
const toStep = (v: number) => Math.min(max, Math.max(min, Number((Math.round((v - min) / step) * step + min).toFixed(decimals))))
|
|
72
|
+
|
|
73
|
+
useEffect(() => () => cancelAnimationFrame(glide.current), [])
|
|
74
|
+
|
|
75
|
+
// The thumb travels from where it is drawn to `to`, easing out. A click and a landing both use it.
|
|
76
|
+
function glideTo(from: number, to: number) {
|
|
77
|
+
cancelAnimationFrame(glide.current)
|
|
78
|
+
const ms = motionMs('base')
|
|
79
|
+
if (!ms || from === to || prefersReducedMotion()) return setShown(null)
|
|
80
|
+
const t0 = performance.now()
|
|
81
|
+
const tick = (now: number) => {
|
|
82
|
+
const p = Math.min(1, (now - t0) / ms)
|
|
83
|
+
setShown(p < 1 ? from + (to - from) * (1 - Math.pow(1 - p, 3)) : null)
|
|
84
|
+
if (p < 1) glide.current = requestAnimationFrame(tick)
|
|
85
|
+
}
|
|
86
|
+
glide.current = requestAnimationFrame(tick)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The value under the pointer, before any step, from the input's own geometry: the thumb's centre
|
|
90
|
+
// runs from half a thumb in at each end.
|
|
91
|
+
function track(input: HTMLInputElement) {
|
|
92
|
+
const r = input.getBoundingClientRect()
|
|
93
|
+
const thumb = parseFloat(getComputedStyle(input).getPropertyValue('--thumb')) || 0
|
|
94
|
+
return { left: r.left + thumb / 2, width: Math.max(1, r.width - thumb), thumb }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fromPointer(input: HTMLInputElement, x: number) {
|
|
98
|
+
const t = track(input)
|
|
99
|
+
return min + Math.min(1, Math.max(0, (x - t.left) / t.width)) * (max - min)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function change(input: HTMLInputElement, native: number) {
|
|
103
|
+
const p = press.current
|
|
104
|
+
const next = p && marks?.length ? toStep(magnetTo(fromPointer(input, p.lastX - p.grab), marks, reach)) : native
|
|
105
|
+
// A press that has not moved is a click: the value jumps, the thumb glides there. Once it moves, the hand leads.
|
|
106
|
+
if (p && !p.moved) glideTo(at, next)
|
|
107
|
+
else if (shown !== null) {
|
|
108
|
+
cancelAnimationFrame(glide.current)
|
|
109
|
+
setShown(null)
|
|
110
|
+
}
|
|
111
|
+
latest.current = next
|
|
112
|
+
setValue(next)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const endPress = () => {
|
|
116
|
+
press.current = null
|
|
117
|
+
}
|
|
49
118
|
|
|
50
119
|
return (
|
|
51
120
|
<div className={cx(styles.root, className)} data-disabled={disabled || undefined}>
|
|
@@ -53,23 +122,77 @@ export function Slider({
|
|
|
53
122
|
<label htmlFor={inputId} className={styles.label}>{label}</label>
|
|
54
123
|
{showValue && <output htmlFor={inputId} className={styles.value} aria-hidden="true">{text}</output>}
|
|
55
124
|
</div>
|
|
56
|
-
<div className={styles.trackWrap} style={{ ['--pct' as string]: `${pct}%`, ['--p' as string]: pct / 100 }}>
|
|
125
|
+
<div className={styles.trackWrap} data-gliding={shown !== null || undefined} style={{ ['--pct' as string]: `${pct}%`, ['--p' as string]: pct / 100 }}>
|
|
57
126
|
<input
|
|
58
127
|
id={inputId}
|
|
59
128
|
type="range"
|
|
60
129
|
className={styles.input}
|
|
61
130
|
min={min}
|
|
62
131
|
max={max}
|
|
63
|
-
step={step}
|
|
64
|
-
value={
|
|
132
|
+
step={shown === null ? step : 'any'}
|
|
133
|
+
value={at}
|
|
65
134
|
disabled={disabled}
|
|
135
|
+
aria-valuenow={value}
|
|
66
136
|
aria-valuetext={formatValue === String ? undefined : text}
|
|
67
|
-
|
|
137
|
+
// Only "input": the browser's "change" on release carries the thumb's mid-glide place, not the value.
|
|
138
|
+
onChange={(e) => e.nativeEvent.type === 'input' && change(e.currentTarget, Number(e.target.value))}
|
|
139
|
+
onPointerDown={(e: PointerEvent<HTMLInputElement>) => {
|
|
140
|
+
const t = track(e.currentTarget)
|
|
141
|
+
const centre = t.left + ((value - min) / (max - min || 1)) * t.width
|
|
142
|
+
const grab = marks?.length && Math.abs(e.clientX - centre) <= t.thumb / 2 ? e.clientX - centre : 0
|
|
143
|
+
press.current = { x: e.clientX, lastX: e.clientX, moved: false, grab }
|
|
144
|
+
onPointerDown?.(e)
|
|
145
|
+
}}
|
|
146
|
+
onPointerMove={(e: PointerEvent<HTMLInputElement>) => {
|
|
147
|
+
const p = press.current
|
|
148
|
+
if (p) {
|
|
149
|
+
p.lastX = e.clientX
|
|
150
|
+
if (Math.abs(e.clientX - p.x) > 3) p.moved = true
|
|
151
|
+
}
|
|
152
|
+
onPointerMove?.(e)
|
|
153
|
+
}}
|
|
154
|
+
onPointerUp={(e: PointerEvent<HTMLInputElement>) => {
|
|
155
|
+
endPress()
|
|
156
|
+
// Let go near a mark: it lands on the mark.
|
|
157
|
+
const now = latest.current
|
|
158
|
+
const mark = marks?.length ? nearestMark(now, marks) : null
|
|
159
|
+
if (mark !== null && mark !== now && Math.abs(mark - now) < reach / 2) {
|
|
160
|
+
glideTo(at, mark)
|
|
161
|
+
setValue(mark)
|
|
162
|
+
}
|
|
163
|
+
onPointerUp?.(e)
|
|
164
|
+
}}
|
|
165
|
+
onPointerCancel={(e: PointerEvent<HTMLInputElement>) => {
|
|
166
|
+
endPress()
|
|
167
|
+
onPointerCancel?.(e)
|
|
168
|
+
}}
|
|
169
|
+
onLostPointerCapture={endPress}
|
|
170
|
+
onBlur={(e) => {
|
|
171
|
+
endPress()
|
|
172
|
+
onBlur?.(e)
|
|
173
|
+
}}
|
|
174
|
+
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
|
|
175
|
+
endPress()
|
|
176
|
+
onKeyDown?.(e)
|
|
177
|
+
if (e.defaultPrevented || !marks?.length || (e.key !== 'PageUp' && e.key !== 'PageDown')) return
|
|
178
|
+
const sorted = [...marks].sort((a, b) => a - b)
|
|
179
|
+
const next = e.key === 'PageUp' ? (sorted.find((m) => m > value) ?? max) : ([...sorted].reverse().find((m) => m < value) ?? min)
|
|
180
|
+
e.preventDefault()
|
|
181
|
+
setValue(next)
|
|
182
|
+
}}
|
|
68
183
|
{...rest}
|
|
69
184
|
/>
|
|
70
185
|
{showValue && (
|
|
71
186
|
<span className={styles.bubble} aria-hidden="true">{text}</span>
|
|
72
187
|
)}
|
|
188
|
+
{/* After the bubble, which CSS shows from the input just before it. */}
|
|
189
|
+
{marks?.length ? (
|
|
190
|
+
<span className={styles.marks} aria-hidden="true">
|
|
191
|
+
{marks.map((m) => (
|
|
192
|
+
<span key={m} className={styles.mark} data-on={m === value || undefined} style={{ ['--m' as string]: (m - min) / (max - min || 1) }} />
|
|
193
|
+
))}
|
|
194
|
+
</span>
|
|
195
|
+
) : null}
|
|
73
196
|
</div>
|
|
74
197
|
</div>
|
|
75
198
|
)
|
|
@@ -69,6 +69,11 @@
|
|
|
69
69
|
width var(--hl-motion-slow) var(--hl-ease-out);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/* A new tab stretches the underline instead (internal/liquid.ts). */
|
|
73
|
+
.indicator[data-liquid] {
|
|
74
|
+
transition: none;
|
|
75
|
+
}
|
|
76
|
+
|
|
72
77
|
.panel {
|
|
73
78
|
padding-top: var(--hl-space-6);
|
|
74
79
|
border-radius: var(--hl-radius-sm);
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from 'react'
|
|
13
13
|
import { prefersReducedMotion } from '../../internal/motion'
|
|
14
14
|
import { cx } from '../../internal/cx'
|
|
15
|
+
import { useStretch } from '../../internal/liquid'
|
|
15
16
|
import { useControllable } from '../../internal/useControllable'
|
|
16
17
|
import styles from './Tabs.module.css'
|
|
17
18
|
|
|
@@ -91,6 +92,10 @@ export function TabList({ label, className, children }: TabListProps) {
|
|
|
91
92
|
}
|
|
92
93
|
}, [bar, ready])
|
|
93
94
|
|
|
95
|
+
// The underline stretches toward the new tab and settles under it.
|
|
96
|
+
const barRef = useRef<HTMLSpanElement>(null)
|
|
97
|
+
useStretch(barRef, bar, value, ready, (b) => `translateX(${b.x}px)`)
|
|
98
|
+
|
|
94
99
|
// Automatic activation, per the WAI-ARIA tabs pattern
|
|
95
100
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
96
101
|
const tabs = Array.from(listRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]:not(:disabled)') ?? [])
|
|
@@ -113,6 +118,7 @@ export function TabList({ label, className, children }: TabListProps) {
|
|
|
113
118
|
{children}
|
|
114
119
|
{bar && (
|
|
115
120
|
<span
|
|
121
|
+
ref={barRef}
|
|
116
122
|
className={styles.indicator}
|
|
117
123
|
data-ready={ready || undefined}
|
|
118
124
|
style={{ transform: `translateX(${bar.x}px)`, width: bar.w }}
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
|
|
39
39
|
/* All toasts sit at the bottom; --y, --s and --dx move each into place */
|
|
40
40
|
.toast {
|
|
41
|
+
--dx: 0px;
|
|
41
42
|
position: absolute;
|
|
42
43
|
left: 0;
|
|
43
44
|
right: 0;
|
|
@@ -46,9 +47,11 @@
|
|
|
46
47
|
align-items: flex-start;
|
|
47
48
|
gap: var(--hl-space-4);
|
|
48
49
|
padding: var(--hl-space-5) var(--hl-space-2) var(--hl-space-5) var(--hl-space-5);
|
|
49
|
-
border: var(--hl-line) solid var(--hl-color-border);
|
|
50
|
+
border: var(--hl-line) solid var(--hl-glass-edge, var(--hl-color-border));
|
|
50
51
|
border-radius: var(--hl-radius-md);
|
|
51
|
-
background: var(--hl-color-surface);
|
|
52
|
+
background: var(--hl-glass-bg, var(--hl-color-surface));
|
|
53
|
+
-webkit-backdrop-filter: var(--hl-glass-filter, none);
|
|
54
|
+
backdrop-filter: var(--hl-glass-filter, none);
|
|
52
55
|
box-shadow: var(--hl-shadow-3);
|
|
53
56
|
pointer-events: auto;
|
|
54
57
|
touch-action: pan-y;
|
|
@@ -62,6 +65,8 @@
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
.toast[data-dragging] { transition: none; }
|
|
68
|
+
/* A spring is moving it from the hand: only the fade stays with CSS. */
|
|
69
|
+
.toast[data-fluid] { transition: opacity var(--hl-motion-base) var(--hl-ease-out); }
|
|
65
70
|
.toast[data-hidden] { opacity: 0; pointer-events: none; }
|
|
66
71
|
|
|
67
72
|
.toast[data-behind] {
|
|
@@ -79,6 +84,8 @@
|
|
|
79
84
|
.toast[data-leaving="down"] { animation: toastOutDown var(--hl-motion-slow) var(--hl-ease-in-out) forwards; }
|
|
80
85
|
.toast[data-leaving="left"] { animation: toastOutLeft var(--hl-motion-slow) var(--hl-ease-out) forwards; }
|
|
81
86
|
.toast[data-leaving="right"] { animation: toastOutRight var(--hl-motion-slow) var(--hl-ease-out) forwards; }
|
|
87
|
+
/* Thrown away: the spring carries it off at the hand's speed, so it only fades. */
|
|
88
|
+
.toast[data-fluid][data-leaving] { animation: toastFade var(--hl-motion-slow) var(--hl-ease-out) forwards; }
|
|
82
89
|
|
|
83
90
|
.icon {
|
|
84
91
|
display: inline-flex;
|
|
@@ -174,3 +181,7 @@
|
|
|
174
181
|
@keyframes toastOutRight {
|
|
175
182
|
to { opacity: 0; transform: translate(110%, var(--y)); }
|
|
176
183
|
}
|
|
184
|
+
|
|
185
|
+
@keyframes toastFade {
|
|
186
|
+
to { opacity: 0; }
|
|
187
|
+
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from 'react'
|
|
15
15
|
import { CheckCircleIcon, ErrorIcon, InfoIcon, XIcon } from '../../internal/icons'
|
|
16
16
|
import { motionMs } from '../../internal/motion'
|
|
17
|
+
import { speedTracker, spring } from '../../internal/spring'
|
|
17
18
|
import { Spinner } from '../Spinner'
|
|
18
19
|
import styles from './Toast.module.css'
|
|
19
20
|
|
|
@@ -58,6 +59,8 @@ export type ToastProviderProps = {
|
|
|
58
59
|
let nextId = 1
|
|
59
60
|
const GAP = 8
|
|
60
61
|
const PEEK = 10
|
|
62
|
+
// px per second: a toast thrown sideways this fast goes, however short the throw.
|
|
63
|
+
const THROW_SPEED = 600
|
|
61
64
|
|
|
62
65
|
export function ToastProvider({ children, visible = 3, label = 'Notifications', dismissLabel = 'Dismiss' }: ToastProviderProps) {
|
|
63
66
|
const [items, setItems] = useState<Item[]>([])
|
|
@@ -179,9 +182,25 @@ const icons: Record<ToastVariant, ReactNode> = {
|
|
|
179
182
|
|
|
180
183
|
function ToastItem({ item, index, y, expanded, hidden, onHeight, onDismiss, dismissLabel }: ItemProps) {
|
|
181
184
|
const ref = useRef<HTMLLIElement>(null)
|
|
182
|
-
const swipe = useRef<{ x: number;
|
|
183
|
-
const [
|
|
185
|
+
const swipe = useRef<{ x: number; dx: number; speed: ReturnType<typeof speedTracker> } | null>(null)
|
|
186
|
+
const [dragging, setDragging] = useState(false)
|
|
184
187
|
const [hover, setHover] = useState(false)
|
|
188
|
+
// Where the toast is drawn sideways, set straight on --dx so a spring does not render every frame.
|
|
189
|
+
const at = useRef(0)
|
|
190
|
+
const stop = useRef(() => {})
|
|
191
|
+
useEffect(() => () => stop.current(), [])
|
|
192
|
+
const paint = (v: number) => {
|
|
193
|
+
at.current = v
|
|
194
|
+
ref.current?.style.setProperty('--dx', `${v}px`)
|
|
195
|
+
}
|
|
196
|
+
const fling = (to: number, speed: number) => {
|
|
197
|
+
const el = ref.current
|
|
198
|
+
stop.current()
|
|
199
|
+
el?.setAttribute('data-fluid', '')
|
|
200
|
+
stop.current = spring(at.current, to, speed, paint, () => {
|
|
201
|
+
if (to === 0) el?.removeAttribute('data-fluid')
|
|
202
|
+
})
|
|
203
|
+
}
|
|
185
204
|
|
|
186
205
|
useLayoutEffect(() => {
|
|
187
206
|
const el = ref.current
|
|
@@ -194,7 +213,7 @@ function ToastItem({ item, index, y, expanded, hidden, onHeight, onDismiss, dism
|
|
|
194
213
|
return () => ro.disconnect()
|
|
195
214
|
}, [])
|
|
196
215
|
|
|
197
|
-
const paused = expanded || hover ||
|
|
216
|
+
const paused = expanded || hover || dragging || item.leaving !== false
|
|
198
217
|
const remaining = useRef(item.duration ?? 5000)
|
|
199
218
|
useEffect(() => {
|
|
200
219
|
remaining.current = item.duration ?? 5000
|
|
@@ -221,22 +240,34 @@ function ToastItem({ item, index, y, expanded, hidden, onHeight, onDismiss, dism
|
|
|
221
240
|
}, [paused, item.variant, item.duration])
|
|
222
241
|
|
|
223
242
|
const onPointerDown = (e: PointerEvent<HTMLLIElement>) => {
|
|
224
|
-
if ((e.target as HTMLElement).closest('button')) return
|
|
225
|
-
|
|
243
|
+
if ((e.target as HTMLElement).closest('button') || item.leaving) return
|
|
244
|
+
stop.current()
|
|
245
|
+
// Caught mid-spring, it carries on from where it is.
|
|
246
|
+
swipe.current = { x: e.clientX - at.current, dx: at.current, speed: speedTracker() }
|
|
247
|
+
swipe.current.speed.add(at.current)
|
|
226
248
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
249
|
+
setDragging(true)
|
|
227
250
|
}
|
|
228
251
|
const onPointerMove = (e: PointerEvent<HTMLLIElement>) => {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
252
|
+
const s = swipe.current
|
|
253
|
+
if (!s) return
|
|
254
|
+
s.dx = e.clientX - s.x
|
|
255
|
+
s.speed.add(s.dx)
|
|
256
|
+
paint(s.dx)
|
|
232
257
|
}
|
|
233
|
-
const onPointerUp = () => {
|
|
258
|
+
const onPointerUp = (e: PointerEvent<HTMLLIElement>) => {
|
|
234
259
|
const s = swipe.current
|
|
235
260
|
swipe.current = null
|
|
261
|
+
setDragging(false)
|
|
236
262
|
if (!s) return
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
|
|
263
|
+
const speed = e.type === 'pointercancel' ? 0 : s.speed.speed()
|
|
264
|
+
const thrown = Math.abs(speed) > THROW_SPEED && Math.abs(s.dx) > 20
|
|
265
|
+
if (e.type !== 'pointercancel' && (Math.abs(s.dx) > 80 || thrown)) {
|
|
266
|
+
// It leaves the way it was thrown, or the way it was pulled when let go slowly.
|
|
267
|
+
const way = Math.sign(thrown ? speed : s.dx)
|
|
268
|
+
fling(way * (ref.current?.offsetWidth ?? 0) * 1.1, speed)
|
|
269
|
+
onDismiss(way < 0 ? 'left' : 'right')
|
|
270
|
+
} else fling(0, speed)
|
|
240
271
|
}
|
|
241
272
|
|
|
242
273
|
const stackedY = expanded ? -y : -Math.min(index, 3) * PEEK
|
|
@@ -247,13 +278,12 @@ function ToastItem({ item, index, y, expanded, hidden, onHeight, onDismiss, dism
|
|
|
247
278
|
ref={ref}
|
|
248
279
|
className={styles.toast}
|
|
249
280
|
data-leaving={item.leaving || undefined}
|
|
250
|
-
data-dragging={
|
|
281
|
+
data-dragging={dragging || undefined}
|
|
251
282
|
data-hidden={hidden || undefined}
|
|
252
283
|
data-behind={(!expanded && index > 0) || undefined}
|
|
253
284
|
style={{
|
|
254
285
|
['--y' as string]: `${stackedY}px`,
|
|
255
286
|
['--s' as string]: scale,
|
|
256
|
-
['--dx' as string]: `${dx}px`,
|
|
257
287
|
zIndex: 100 - Math.max(index, 0),
|
|
258
288
|
}}
|
|
259
289
|
aria-hidden={hidden || undefined}
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
|
|
60
60
|
@media (hover: hover) {
|
|
61
61
|
.root:hover:not(:disabled):not([data-on]) { background: var(--hl-color-surface-hover); }
|
|
62
|
-
.root[data-on]:hover:not(:disabled) { background: var(--hl-color-
|
|
62
|
+
.root[data-on]:hover:not(:disabled) { background: var(--hl-color-accent-hover); }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
.root:focus-visible {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { useLayoutEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import { easing, motionMs, prefersReducedMotion } from './motion'
|
|
3
|
+
|
|
4
|
+
// Liquid: a panel grows out of what opened it. It starts as the opener's own shape (its width and its
|
|
5
|
+
// corners) at the panel's edge nearest the opener, then fills out to the whole panel; on close it
|
|
6
|
+
// folds back. Only a clip changes, so nothing inside the panel is stretched and text stays sharp.
|
|
7
|
+
// Where the browser cannot clip or animate this way, or with reduced motion, the part's own CSS fade
|
|
8
|
+
// runs instead.
|
|
9
|
+
|
|
10
|
+
/** What the panel grows from: the opener element, or a rectangle (such as the point of a right click). */
|
|
11
|
+
export type LiquidOrigin = RefObject<HTMLElement | null> | (() => DOMRect | null)
|
|
12
|
+
|
|
13
|
+
const able = () =>
|
|
14
|
+
typeof CSS !== 'undefined' &&
|
|
15
|
+
CSS.supports('clip-path', 'inset(0px round 1px)') &&
|
|
16
|
+
CSS.supports('animation-timing-function', 'linear(0, 1)') &&
|
|
17
|
+
typeof Element !== 'undefined' &&
|
|
18
|
+
typeof Element.prototype.animate === 'function'
|
|
19
|
+
|
|
20
|
+
const clamp = (v: number, max: number) => Math.max(0, Math.min(max, v))
|
|
21
|
+
|
|
22
|
+
// How far the panel's shadow reaches past its box, so the clip can end there and the shadow grows in
|
|
23
|
+
// with the panel instead of appearing all at once when the clip is removed.
|
|
24
|
+
function shadowReach(el: HTMLElement) {
|
|
25
|
+
let reach = 0
|
|
26
|
+
for (const shadow of getComputedStyle(el).boxShadow.split(/,(?![^(]*\))/)) {
|
|
27
|
+
if (shadow.includes('inset')) continue
|
|
28
|
+
const [x = 0, y = 0, blur = 0, spread = 0] = (shadow.replace(/rgba?\([^)]*\)|#[\da-f]+/gi, '').match(/-?[\d.]+px/g) ?? []).map(parseFloat)
|
|
29
|
+
reach = Math.max(reach, Math.abs(x) + blur + spread, Math.abs(y) + blur + spread)
|
|
30
|
+
}
|
|
31
|
+
return Math.ceil(reach)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The opener's footprint on the panel, as an inset from each edge. An opener above or below the panel
|
|
35
|
+
// lands as a strip on the nearest edge; one beside it, on the nearest side; one inside it (a click
|
|
36
|
+
// point), where it is.
|
|
37
|
+
export function startShape(panel: DOMRect, from: DOMRect, radius: string) {
|
|
38
|
+
const w = panel.width
|
|
39
|
+
const h = panel.height
|
|
40
|
+
let top = clamp(from.top - panel.top, h)
|
|
41
|
+
let bottom = clamp(panel.bottom - from.bottom, h)
|
|
42
|
+
let left = clamp(from.left - panel.left, w)
|
|
43
|
+
let right = clamp(panel.right - from.right, w)
|
|
44
|
+
const fh = Math.min(from.height, h)
|
|
45
|
+
const fw = Math.min(from.width, w)
|
|
46
|
+
if (from.bottom <= panel.top + 1) [top, bottom] = [0, h - fh]
|
|
47
|
+
else if (from.top >= panel.bottom - 1) [top, bottom] = [h - fh, 0]
|
|
48
|
+
else if (from.right <= panel.left + 1) [left, right] = [0, w - fw]
|
|
49
|
+
else if (from.left >= panel.right - 1) [left, right] = [w - fw, 0]
|
|
50
|
+
// Never less than a sliver, so the shape always has somewhere to grow from.
|
|
51
|
+
if (w - left - right < 2) right = Math.max(0, w - left - 2)
|
|
52
|
+
if (h - top - bottom < 2) bottom = Math.max(0, h - top - 2)
|
|
53
|
+
return `inset(${top}px ${right}px ${bottom}px ${left}px round ${radius})`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Plays the growth when `state` turns open (once `ready`, that is, once the panel is in its place) and
|
|
58
|
+
* the fold when it turns closed. Marks the panel with data-liquid while it runs, so the part's CSS can
|
|
59
|
+
* stand its own enter and exit animation down.
|
|
60
|
+
*/
|
|
61
|
+
export function useLiquid(panel: RefObject<HTMLElement | null>, origin: LiquidOrigin, state: 'open' | 'closed', ready: boolean) {
|
|
62
|
+
useLayoutEffect(() => {
|
|
63
|
+
const p = panel.current
|
|
64
|
+
if (!p || !ready || prefersReducedMotion() || !able()) return
|
|
65
|
+
const opener = typeof origin === 'function' ? null : origin.current
|
|
66
|
+
const from = typeof origin === 'function' ? origin() : (opener?.getBoundingClientRect() ?? null)
|
|
67
|
+
if (!from) return
|
|
68
|
+
// Never rounder than half the opener's height: a pill's 999px corner would keep a tall panel a
|
|
69
|
+
// capsule for most of the growth.
|
|
70
|
+
const cap = Math.min(from.width, from.height) / 2
|
|
71
|
+
const radius = `${Math.min(cap, opener ? parseFloat(getComputedStyle(opener).borderRadius) || 0 : cap)}px`
|
|
72
|
+
const start = startShape(p.getBoundingClientRect(), from, radius)
|
|
73
|
+
const room = shadowReach(p)
|
|
74
|
+
const end = `inset(${-room}px round calc(${getComputedStyle(p).borderRadius || '0px'} + ${room}px))`
|
|
75
|
+
// Stays set while the panel is open: taking it off would restart the part's CSS enter animation.
|
|
76
|
+
p.dataset.liquid = ''
|
|
77
|
+
const run =
|
|
78
|
+
state === 'open'
|
|
79
|
+
? p.animate({ clipPath: [start, end] }, { duration: motionMs('liquid'), easing: easing('liquid') })
|
|
80
|
+
: p.animate({ clipPath: [end, start] }, { duration: motionMs('fast'), easing: easing('in'), fill: 'forwards' })
|
|
81
|
+
return () => run.cancel()
|
|
82
|
+
// Plays once per open and once per close; the origin is read fresh each time.
|
|
83
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
84
|
+
}, [state, ready])
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A highlight's place in its row: its left edge, width, and (for rows that wrap) its top. */
|
|
88
|
+
export type StretchBox = { x: number; w: number; y?: number }
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Liquid highlight: when the choice changes, the edge nearest the new choice leaves first and the far
|
|
92
|
+
* edge follows, so the highlight stretches toward the choice, then settles there like a drop. Runs
|
|
93
|
+
* only on a change of choice, never when the row is resized, and only along one row. `place` turns a
|
|
94
|
+
* box into the highlight's transform, as the part draws it.
|
|
95
|
+
*/
|
|
96
|
+
export function useStretch(el: RefObject<HTMLElement | null>, box: StretchBox | null, choice: unknown, ready: boolean, place: (b: StretchBox) => string) {
|
|
97
|
+
const last = useRef<{ box: StretchBox; choice: unknown } | null>(null)
|
|
98
|
+
const current = useRef<Animation | null>(null)
|
|
99
|
+
useLayoutEffect(() => {
|
|
100
|
+
const node = el.current
|
|
101
|
+
const prev = last.current
|
|
102
|
+
// A part often renders the new choice before it has measured its new place, so only a moved box
|
|
103
|
+
// counts; it then stretches if the choice behind it changed, and simply follows a resize.
|
|
104
|
+
// No highlight (nothing chosen): forget the old place, so the next one appears rather than stretching from it.
|
|
105
|
+
if (!box) {
|
|
106
|
+
last.current = null
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (prev && prev.box.x === box.x && prev.box.w === box.w && prev.box.y === box.y) return
|
|
110
|
+
last.current = { box, choice }
|
|
111
|
+
if (!node || !prev || !ready || prev.choice === choice || prefersReducedMotion() || !able()) return
|
|
112
|
+
const from = prev.box
|
|
113
|
+
if ((from.y ?? 0) !== (box.y ?? 0)) return
|
|
114
|
+
const right = box.x > from.x
|
|
115
|
+
const lead = right ? box.x + box.w : box.x
|
|
116
|
+
const trail = right ? from.x + (box.x - from.x) * 0.2 : from.x + from.w - (from.x + from.w - (box.x + box.w)) * 0.2
|
|
117
|
+
const mid = right ? { x: trail, w: lead - trail, y: box.y } : { x: lead, w: trail - lead, y: box.y }
|
|
118
|
+
node.dataset.liquid = ''
|
|
119
|
+
const run = node.animate(
|
|
120
|
+
[
|
|
121
|
+
{ transform: place(from), width: `${from.w}px`, easing: easing('out') },
|
|
122
|
+
{ transform: place(mid), width: `${mid.w}px`, offset: 0.3, easing: easing('liquid') },
|
|
123
|
+
{ transform: place(box), width: `${box.w}px` },
|
|
124
|
+
],
|
|
125
|
+
{ duration: motionMs('liquid') },
|
|
126
|
+
)
|
|
127
|
+
current.current = run
|
|
128
|
+
// Only the latest stretch takes the mark off: a cancelled one settles later, mid-way through the next.
|
|
129
|
+
const done = () => {
|
|
130
|
+
if (current.current === run) delete node.dataset.liquid
|
|
131
|
+
}
|
|
132
|
+
run.finished.then(done, done)
|
|
133
|
+
return () => run.cancel()
|
|
134
|
+
// Keyed on the choice and the box; `place` is a pure formatter.
|
|
135
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
136
|
+
}, [choice, box?.x, box?.w, box?.y, ready])
|
|
137
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Magnetic: the right place pulls. Near a mark a dragged value is drawn onto it, strongly close in and
|
|
2
|
+
// not at all at the edge of its reach, so nothing jumps and you can still stop between marks.
|
|
3
|
+
|
|
4
|
+
/** The mark nearest to `value`, or null when there are none. */
|
|
5
|
+
export function nearestMark(value: number, marks: number[]): number | null {
|
|
6
|
+
let best: number | null = null
|
|
7
|
+
for (const m of marks) if (best === null || Math.abs(m - value) < Math.abs(best - value)) best = m
|
|
8
|
+
return best
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** `raw` drawn toward the nearest mark within `reach`: sticky close to the mark, free at the edge. */
|
|
12
|
+
export function magnetTo(raw: number, marks: number[], reach: number): number {
|
|
13
|
+
const mark = nearestMark(raw, marks)
|
|
14
|
+
if (mark === null || reach <= 0) return raw
|
|
15
|
+
const d = raw - mark
|
|
16
|
+
if (Math.abs(d) >= reach) return raw
|
|
17
|
+
const t = Math.abs(d) / reach
|
|
18
|
+
return mark + Math.sign(d) * reach * t * t
|
|
19
|
+
}
|
package/src/internal/motion.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MotionToken = 'instant' | 'fast' | 'base' | 'slow' | 'slower' | 'delay-tooltip'
|
|
1
|
+
export type MotionToken = 'instant' | 'fast' | 'base' | 'slow' | 'slower' | 'liquid' | 'fling' | 'delay-tooltip'
|
|
2
2
|
|
|
3
3
|
// Reads a duration token at run time, so reduced motion (0ms) and Retime's speed are honoured by JS
|
|
4
4
|
// timers too.
|
|
@@ -19,7 +19,7 @@ export function motionMs(token: MotionToken): number {
|
|
|
19
19
|
return readMotionMs(token)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
export function easing(name: 'out' | 'in' | 'in-out' | 'spring' | 'bounce'): string {
|
|
22
|
+
export function easing(name: 'out' | 'in' | 'in-out' | 'spring' | 'bounce' | 'liquid'): string {
|
|
23
23
|
if (typeof window === 'undefined') return 'ease'
|
|
24
24
|
return getComputedStyle(document.documentElement).getPropertyValue(`--hl-ease-${name}`).trim() || 'ease'
|
|
25
25
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react'
|
|
2
|
+
import { readMotionMs } from './motion'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs from `from` to `to`, starting at `velocity` (units per second), so a let-go carries on at the
|
|
6
|
+
* hand's speed. One swing lasts --hl-motion-fling: Retime slows it, reduced motion (0ms) jumps.
|
|
7
|
+
* Returns a stop function.
|
|
8
|
+
*/
|
|
9
|
+
export function spring(from: number, to: number, velocity: number, onFrame: (value: number) => void, onDone?: () => void, damping = 0.88) {
|
|
10
|
+
const period = readMotionMs('fling') / 1000
|
|
11
|
+
if (!period) {
|
|
12
|
+
onFrame(to)
|
|
13
|
+
onDone?.()
|
|
14
|
+
return () => {}
|
|
15
|
+
}
|
|
16
|
+
const w = (2 * Math.PI) / period
|
|
17
|
+
let x = from
|
|
18
|
+
let v = velocity
|
|
19
|
+
let last = performance.now()
|
|
20
|
+
let raf = requestAnimationFrame(function step(now) {
|
|
21
|
+
// Fixed small slices, so a slow frame does not throw the spring off.
|
|
22
|
+
let dt = Math.min(0.064, (now - last) / 1000)
|
|
23
|
+
last = now
|
|
24
|
+
while (dt > 0) {
|
|
25
|
+
const h = Math.min(dt, 1 / 240)
|
|
26
|
+
v += (-w * w * (x - to) - 2 * damping * w * v) * h
|
|
27
|
+
x += v * h
|
|
28
|
+
dt -= h
|
|
29
|
+
}
|
|
30
|
+
if (Math.abs(x - to) < 0.3 && Math.abs(v) < 4) {
|
|
31
|
+
onFrame(to)
|
|
32
|
+
onDone?.()
|
|
33
|
+
return
|
|
34
|
+
}
|
|
35
|
+
onFrame(x)
|
|
36
|
+
raf = requestAnimationFrame(step)
|
|
37
|
+
})
|
|
38
|
+
return () => cancelAnimationFrame(raf)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Past an edge a drag still moves, less the further it goes, and never more than `size`. */
|
|
42
|
+
export function rubber(over: number, size: number) {
|
|
43
|
+
if (size <= 0) return 0
|
|
44
|
+
return Math.sign(over) * (1 - 1 / ((Math.abs(over) * 0.55) / size + 1)) * size
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The hand's speed when it lets go, in px per second, from the last tenth of a second of moves. */
|
|
48
|
+
export function speedTracker() {
|
|
49
|
+
let samples: { t: number; x: number }[] = []
|
|
50
|
+
return {
|
|
51
|
+
add(x: number, t = performance.now()) {
|
|
52
|
+
samples = [...samples.filter((s) => t - s.t <= 100), { t, x }]
|
|
53
|
+
},
|
|
54
|
+
speed(t = performance.now()) {
|
|
55
|
+
const recent = samples.filter((s) => t - s.t <= 100)
|
|
56
|
+
if (recent.length < 2) return 0
|
|
57
|
+
const a = recent[0]
|
|
58
|
+
const b = recent[recent.length - 1]
|
|
59
|
+
return b.t > a.t ? ((b.x - a.x) / (b.t - a.t)) * 1000 : 0
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A dialog panel that the hand drags along one axis and lets go of. `fling` springs it to a place at
|
|
66
|
+
* the hand's speed. While it runs, data-fluid on the panel turns its CSS motion off; once the parent
|
|
67
|
+
* closes it on the way out, the CSS close keyframes stay off too, so they do not start over.
|
|
68
|
+
*/
|
|
69
|
+
export function useFling(ref: RefObject<HTMLElement | null>, open: boolean, axis: 'x' | 'y') {
|
|
70
|
+
const at = useRef(0)
|
|
71
|
+
const target = useRef(0)
|
|
72
|
+
const stop = useRef(() => {})
|
|
73
|
+
|
|
74
|
+
const paint = (v: number) => {
|
|
75
|
+
at.current = v
|
|
76
|
+
if (ref.current) ref.current.style.transform = v ? `translate${axis === 'x' ? 'X' : 'Y'}(${v}px)` : ''
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const fling = (to: number, speed: number) => {
|
|
80
|
+
const el = ref.current
|
|
81
|
+
if (!el) return
|
|
82
|
+
stop.current()
|
|
83
|
+
target.current = to
|
|
84
|
+
el.setAttribute('data-fluid', '')
|
|
85
|
+
stop.current = spring(at.current, to, speed, paint, () => {
|
|
86
|
+
if (to === 0) el.removeAttribute('data-fluid')
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
useEffect(() => () => stop.current(), [])
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
// Opened again, even mid-close on the same panel: it starts at rest, not where the old spring was going.
|
|
93
|
+
if (open) {
|
|
94
|
+
stop.current()
|
|
95
|
+
target.current = 0
|
|
96
|
+
paint(0)
|
|
97
|
+
ref.current?.removeAttribute('data-fluid')
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
// Closed by Esc or a tap while springing back: the CSS close takes over from where it is.
|
|
101
|
+
if (target.current !== 0) return
|
|
102
|
+
stop.current()
|
|
103
|
+
ref.current?.removeAttribute('data-fluid')
|
|
104
|
+
}, [open])
|
|
105
|
+
|
|
106
|
+
return { at, paint, fling, stop: () => stop.current() }
|
|
107
|
+
}
|
|
@@ -20,6 +20,8 @@ export function useFloating(
|
|
|
20
20
|
{ open, side = 'bottom', align = 'center', offset = 8, matchWidth = false }: Options,
|
|
21
21
|
) {
|
|
22
22
|
const [pos, setPos] = useState<{ top: number; left: number; side: Side; width?: number } | null>(null)
|
|
23
|
+
// Measured since this open began.
|
|
24
|
+
const [fresh, setFresh] = useState(false)
|
|
23
25
|
|
|
24
26
|
const update = useCallback(() => {
|
|
25
27
|
const a = anchorRef.current
|
|
@@ -50,10 +52,16 @@ export function useFloating(
|
|
|
50
52
|
left = Math.min(Math.max(MARGIN, left), Math.max(MARGIN, vw - fw - MARGIN))
|
|
51
53
|
top = Math.min(Math.max(MARGIN, top), Math.max(MARGIN, vh - fh - MARGIN))
|
|
52
54
|
setPos({ top, left, side: s, width })
|
|
55
|
+
setFresh(true)
|
|
53
56
|
}, [anchorRef, floatingRef, side, align, offset, matchWidth])
|
|
54
57
|
|
|
55
58
|
useLayoutEffect(() => {
|
|
56
|
-
|
|
59
|
+
// Closed: the next open is not ready until it has been measured again, so nothing reads a stale
|
|
60
|
+
// place. The place itself stays, so a layer that stays on screen never flashes.
|
|
61
|
+
if (!open) {
|
|
62
|
+
setFresh(false)
|
|
63
|
+
return
|
|
64
|
+
}
|
|
57
65
|
update()
|
|
58
66
|
const f = floatingRef.current
|
|
59
67
|
const ro = new ResizeObserver(update)
|
|
@@ -72,5 +80,6 @@ export function useFloating(
|
|
|
72
80
|
? { position: 'fixed', top: pos.top, left: pos.left, width: pos.width }
|
|
73
81
|
: { position: 'fixed', top: 0, left: 0, opacity: 0, pointerEvents: 'none' }
|
|
74
82
|
|
|
75
|
-
|
|
83
|
+
// ready: the layer is in its place, so a part can measure it there (for its opening animation).
|
|
84
|
+
return { style, side: pos?.side ?? side, update, ready: pos !== null && fresh }
|
|
76
85
|
}
|
package/src/tokens.css
CHANGED
|
@@ -36,9 +36,13 @@
|
|
|
36
36
|
--hl-color-inverse-fg: var(--hl-gray-0);
|
|
37
37
|
--hl-color-inverse-hover: var(--hl-gray-700);
|
|
38
38
|
--hl-color-shimmer: rgb(255 255 255 / 0.7);
|
|
39
|
-
/* Accent: the
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
/* Accent: marking blue, the layout dye a machinist wipes on a part to check its fit, for what is chosen
|
|
40
|
+
or on: primary buttons, checked and selected states, the current tab, progress. Blue because most
|
|
41
|
+
apps use it for the main action, so it reads at once. Everything else stays black, white and grey.
|
|
42
|
+
White on it is 6.8:1. */
|
|
43
|
+
--hl-color-accent: #1f5aa6;
|
|
44
|
+
--hl-color-accent-fg: #ffffff;
|
|
45
|
+
--hl-color-accent-hover: #194a8a;
|
|
42
46
|
|
|
43
47
|
/* Status: always paired with an icon or word */
|
|
44
48
|
--hl-color-success: #12733a;
|
|
@@ -170,6 +174,10 @@
|
|
|
170
174
|
--hl-motion-slower: calc(600ms * var(--hl-motion-scale));
|
|
171
175
|
/* Navbar dock: the trip to and from the bottom of the screen, long enough to read as liquid */
|
|
172
176
|
--hl-motion-pour: calc(760ms * var(--hl-motion-scale));
|
|
177
|
+
/* Liquid: a shape growing into another (a button into its menu), the length of --hl-ease-liquid */
|
|
178
|
+
--hl-motion-liquid: calc(520ms * var(--hl-motion-scale));
|
|
179
|
+
/* Fluid: the swing of the spring that carries a let-go sheet, row or photo on at the hand's speed */
|
|
180
|
+
--hl-motion-fling: calc(280ms * var(--hl-motion-scale));
|
|
173
181
|
--hl-motion-stagger: calc(30ms * var(--hl-motion-scale));
|
|
174
182
|
--hl-motion-delay-tooltip: 400ms;
|
|
175
183
|
--hl-motion-shimmer: 1400ms;
|
|
@@ -182,6 +190,9 @@
|
|
|
182
190
|
for small pops like a badge). A bigger overshoot reads as wobbly once it is on every control. */
|
|
183
191
|
--hl-ease-spring: cubic-bezier(0.3, 1.2, 0.5, 1);
|
|
184
192
|
--hl-ease-bounce: cubic-bezier(0.3, 1.3, 0.5, 1);
|
|
193
|
+
/* A real spring (stiffness 300, damping 26), sampled every 25ms: it carries momentum, goes 2% past
|
|
194
|
+
and settles. Most of the move is done in the first 200ms. */
|
|
195
|
+
--hl-ease-liquid: linear(0, 0.09, 0.264, 0.455, 0.628, 0.768, 0.871, 0.941, 0.984, 1.008, 1.019, 1.022, 1.02, 1.017, 1.012, 1.009, 1.005, 1.003, 1.001, 1, 1, 1);
|
|
185
196
|
--hl-ease-linear: linear;
|
|
186
197
|
|
|
187
198
|
/* Movement distances */
|
|
@@ -235,6 +246,9 @@
|
|
|
235
246
|
--hl-color-inverse: #ececec;
|
|
236
247
|
--hl-color-inverse-fg: #0a0a0a;
|
|
237
248
|
--hl-color-inverse-hover: var(--hl-gray-300);
|
|
249
|
+
--hl-color-accent: #7fa8e6;
|
|
250
|
+
--hl-color-accent-fg: #0a0a0a;
|
|
251
|
+
--hl-color-accent-hover: #9bbcec;
|
|
238
252
|
--hl-color-shimmer: rgb(255 255 255 / 0.06);
|
|
239
253
|
--hl-color-success: #4cc97a;
|
|
240
254
|
--hl-color-warning: #f2b33d;
|
|
@@ -273,6 +287,9 @@
|
|
|
273
287
|
--hl-color-inverse: #ececec;
|
|
274
288
|
--hl-color-inverse-fg: #0a0a0a;
|
|
275
289
|
--hl-color-inverse-hover: var(--hl-gray-300);
|
|
290
|
+
--hl-color-accent: #7fa8e6;
|
|
291
|
+
--hl-color-accent-fg: #0a0a0a;
|
|
292
|
+
--hl-color-accent-hover: #9bbcec;
|
|
276
293
|
--hl-color-shimmer: rgb(255 255 255 / 0.06);
|
|
277
294
|
--hl-color-success: #4cc97a;
|
|
278
295
|
--hl-color-warning: #f2b33d;
|
|
@@ -301,6 +318,8 @@
|
|
|
301
318
|
--hl-motion-slow: 0ms;
|
|
302
319
|
--hl-motion-slower: 0ms;
|
|
303
320
|
--hl-motion-pour: 0ms;
|
|
321
|
+
--hl-motion-liquid: 0ms;
|
|
322
|
+
--hl-motion-fling: 0ms;
|
|
304
323
|
--hl-motion-stagger: 0ms;
|
|
305
324
|
--hl-motion-shimmer: 0ms;
|
|
306
325
|
--hl-motion-spin: 1600ms;
|
|
@@ -411,15 +430,18 @@
|
|
|
411
430
|
/* A cell's widest, so long text wraps and a sideways-scrolling table stays readable. */
|
|
412
431
|
--hl-table-cell-max: 320px;
|
|
413
432
|
|
|
414
|
-
/* Charts: series differ by
|
|
433
|
+
/* Charts: series differ by colour, dash and marker, never colour alone. The colours are a survey map's
|
|
434
|
+
own code, in a fixed order: navy (the accent's family), contour brown, survey green, revision purple,
|
|
435
|
+
chart ochre. Checked for colour-blind separation (8.0 or more for adjacent pairs) and 3:1 against the surface, in both
|
|
436
|
+
themes. A sixth series folds into "Other" or a second chart; it never gets a made-up colour. */
|
|
415
437
|
--hl-chart-h: 260px;
|
|
416
438
|
--hl-chart-donut: 220px;
|
|
417
439
|
--hl-chart-area-opacity: 0.14;
|
|
418
|
-
--hl-chart-1:
|
|
419
|
-
--hl-chart-2:
|
|
420
|
-
--hl-chart-3: #
|
|
421
|
-
--hl-chart-4:
|
|
422
|
-
--hl-chart-5:
|
|
440
|
+
--hl-chart-1: #2f64b4;
|
|
441
|
+
--hl-chart-2: #b36324;
|
|
442
|
+
--hl-chart-3: #2f9a6e;
|
|
443
|
+
--hl-chart-4: #8a52c2;
|
|
444
|
+
--hl-chart-5: #a8861a;
|
|
423
445
|
--hl-chart-dash-1: none;
|
|
424
446
|
--hl-chart-dash-2: 6 4;
|
|
425
447
|
--hl-chart-dash-3: 1.5 3.5;
|
|
@@ -434,22 +456,23 @@
|
|
|
434
456
|
--hl-editor-min-h: 160px;
|
|
435
457
|
}
|
|
436
458
|
|
|
459
|
+
/* The same five colours, a step lighter for the dark surface, checked there on their own. */
|
|
437
460
|
@media (prefers-color-scheme: dark) {
|
|
438
461
|
:root:not([data-theme="light"]) {
|
|
439
|
-
--hl-chart-1:
|
|
440
|
-
--hl-chart-2:
|
|
441
|
-
--hl-chart-3: #
|
|
442
|
-
--hl-chart-4:
|
|
443
|
-
--hl-chart-5:
|
|
462
|
+
--hl-chart-1: #638fdb;
|
|
463
|
+
--hl-chart-2: #c97638;
|
|
464
|
+
--hl-chart-3: #3ea97a;
|
|
465
|
+
--hl-chart-4: #a873d6;
|
|
466
|
+
--hl-chart-5: #b0902a;
|
|
444
467
|
}
|
|
445
468
|
}
|
|
446
469
|
|
|
447
470
|
[data-theme="dark"] {
|
|
448
|
-
--hl-chart-1:
|
|
449
|
-
--hl-chart-2:
|
|
450
|
-
--hl-chart-3: #
|
|
451
|
-
--hl-chart-4:
|
|
452
|
-
--hl-chart-5:
|
|
471
|
+
--hl-chart-1: #638fdb;
|
|
472
|
+
--hl-chart-2: #c97638;
|
|
473
|
+
--hl-chart-3: #3ea97a;
|
|
474
|
+
--hl-chart-4: #a873d6;
|
|
475
|
+
--hl-chart-5: #b0902a;
|
|
453
476
|
}
|
|
454
477
|
|
|
455
478
|
@media (prefers-reduced-motion: reduce) {
|