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 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
- 71 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).
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.27.0",
4
- "description": "React components in a monochrome style. Copy them into your app with npx hlmnhkl-ui add <name>.",
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": {
@@ -22,6 +22,7 @@
22
22
  "Drawer",
23
23
  "EmojiPicker",
24
24
  "FileUpload",
25
+ "GlassLens",
25
26
  "HoverCard",
26
27
  "IcInput",
27
28
  "Image",
@@ -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-inverse-hover); }
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-inverse-hover); }
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-inverse-hover); }
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
- /* No spring on the pill: it is pressed often, and an overshoot on every press feels wobbly. */
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] { transition: none; }
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
- const CLOSE_VELOCITY = 0.5
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; t: number; dy: number; pointer: number } | null>(null)
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
- drag.current = { y: e.clientY, t: performance.now(), dy: 0, pointer: e.pointerId }
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 > 0 ? raw : raw * 0.15
62
- panel.style.transform = `translateY(${d.dy}px)`
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 velocity = d.dy / Math.max(1, performance.now() - d.t)
73
- if (d.dy > panel.offsetHeight * CLOSE_RATIO || (velocity > CLOSE_VELOCITY && d.dy > 24)) {
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
- // The close animation starts from the dragged spot. If the parent keeps it open, spring back.
85
+ // If the parent keeps it open, it springs back.
76
86
  requestAnimationFrame(() => {
77
- if (openRef.current) panel.style.transform = ''
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
- const pct = max === min ? 0 : ((value - min) / (max - min)) * 100
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={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
- onChange={(e) => setValue(Number(e.target.value))}
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; t: number; dx: number } | null>(null)
183
- const [dx, setDx] = useState(0)
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 || swipe.current !== null || item.leaving !== false
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
- swipe.current = { x: e.clientX, t: performance.now(), dx: 0 }
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
- if (!swipe.current) return
230
- swipe.current.dx = e.clientX - swipe.current.x
231
- setDx(swipe.current.dx)
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 v = Math.abs(s.dx) / Math.max(1, performance.now() - s.t)
238
- if (Math.abs(s.dx) > 80 || (v > 0.6 && Math.abs(s.dx) > 20)) onDismiss(s.dx < 0 ? 'left' : 'right')
239
- else setDx(0)
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={swipe.current ? true : undefined}
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-inverse-hover); }
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
+ }
@@ -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
- if (!open) return
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
- return { style, side: pos?.side ?? side, update }
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 foreground by default, so the library stays monochrome */
40
- --hl-color-accent: var(--hl-color-inverse);
41
- --hl-color-accent-fg: var(--hl-color-inverse-fg);
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 shade, dash and marker, never colour alone */
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: var(--hl-gray-950);
419
- --hl-chart-2: var(--hl-gray-600);
420
- --hl-chart-3: #8a8a8a;
421
- --hl-chart-4: var(--hl-gray-800);
422
- --hl-chart-5: var(--hl-gray-500);
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: var(--hl-gray-50);
440
- --hl-chart-2: var(--hl-gray-400);
441
- --hl-chart-3: #707070;
442
- --hl-chart-4: var(--hl-gray-200);
443
- --hl-chart-5: var(--hl-gray-500);
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: var(--hl-gray-50);
449
- --hl-chart-2: var(--hl-gray-400);
450
- --hl-chart-3: #707070;
451
- --hl-chart-4: var(--hl-gray-200);
452
- --hl-chart-5: var(--hl-gray-500);
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) {