@passui/react 0.1.19 → 0.1.21

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.
Files changed (84) hide show
  1. package/README.md +38 -0
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/dist/types/src/components/accordion/accordion.d.ts +19 -0
  4. package/dist/types/src/components/aspect-ratio/aspect-ratio.d.ts +7 -0
  5. package/dist/types/src/components/breadcrumb/breadcrumb.d.ts +16 -0
  6. package/dist/types/src/components/button/button.d.ts +2 -0
  7. package/dist/types/src/components/code-block/code-block.d.ts +10 -0
  8. package/dist/types/src/components/combo-box/combo-box.d.ts +9 -0
  9. package/dist/types/src/components/command/command.d.ts +18 -0
  10. package/dist/types/src/components/content-unavailable-view/content-unavailable-view.d.ts +9 -0
  11. package/dist/types/src/components/context-menu/context-menu-store.d.ts +2 -2
  12. package/dist/types/src/components/context-menu/menu-reveal.d.ts +3 -8
  13. package/dist/types/src/components/dialog/dialog-store.d.ts +3 -1
  14. package/dist/types/src/components/disclosure-group/disclosure-group.d.ts +3 -1
  15. package/dist/types/src/components/keyboard-shortcut/keyboard-shortcut.d.ts +5 -0
  16. package/dist/types/src/components/menu/menu.d.ts +1 -1
  17. package/dist/types/src/components/one-time-code/one-time-code.d.ts +23 -0
  18. package/dist/types/src/components/picker/picker.d.ts +7 -1
  19. package/dist/types/src/components/popover/anchored-surface.d.ts +13 -0
  20. package/dist/types/src/components/popover/popover.d.ts +5 -1
  21. package/dist/types/src/components/progress-view/progress-view.d.ts +1 -1
  22. package/dist/types/src/components/progress-view/use-liquid-progress.d.ts +3 -0
  23. package/dist/types/src/components/radio-group/radio-group.d.ts +17 -0
  24. package/dist/types/src/components/search-field/expanding-search-field.d.ts +5 -0
  25. package/dist/types/src/components/search-field/search-field.d.ts +5 -1
  26. package/dist/types/src/components/sheet/sheet.d.ts +2 -0
  27. package/dist/types/src/components/sidebar/sidebar.d.ts +3 -2
  28. package/dist/types/src/components/skeleton/skeleton.d.ts +6 -0
  29. package/dist/types/src/components/slosh-slider/liquid-step.d.ts +6 -0
  30. package/dist/types/src/components/slosh-slider/slosh-slider.d.ts +18 -0
  31. package/dist/types/src/components/stepper/rolling-digit.d.ts +11 -0
  32. package/dist/types/src/components/text-field/text-field.d.ts +3 -1
  33. package/dist/types/src/components/wake-slider/wake-slider.d.ts +12 -0
  34. package/dist/types/src/hooks/use-anchored-disclosure.d.ts +9 -0
  35. package/dist/types/src/hooks/use-anchored-placement.d.ts +1 -1
  36. package/dist/types/src/index.d.ts +13 -0
  37. package/dist/types/src/lib/anchored-placement.d.ts +2 -2
  38. package/package.json +60 -4
  39. package/src/components/accordion/accordion.tsx +48 -0
  40. package/src/components/aspect-ratio/aspect-ratio.tsx +22 -0
  41. package/src/components/breadcrumb/breadcrumb.tsx +53 -0
  42. package/src/components/button/button.tsx +5 -1
  43. package/src/components/code-block/code-block.tsx +41 -0
  44. package/src/components/combo-box/combo-box.tsx +13 -0
  45. package/src/components/command/command.tsx +119 -0
  46. package/src/components/compact-date-picker/compact-date-picker.tsx +40 -84
  47. package/src/components/compact-time-picker/compact-time-picker.tsx +44 -81
  48. package/src/components/content-unavailable-view/content-unavailable-view.tsx +37 -0
  49. package/src/components/context-menu/context-menu-host.tsx +28 -24
  50. package/src/components/context-menu/context-menu-store.ts +3 -4
  51. package/src/components/context-menu/menu-reveal.ts +5 -2
  52. package/src/components/dialog/dialog-host.tsx +13 -7
  53. package/src/components/dialog/dialog-store.ts +15 -4
  54. package/src/components/disclosure-group/disclosure-group.tsx +19 -10
  55. package/src/components/keyboard-shortcut/keyboard-shortcut.tsx +17 -0
  56. package/src/components/menu/menu.tsx +1 -1
  57. package/src/components/one-time-code/one-time-code.tsx +198 -0
  58. package/src/components/picker/picker.tsx +121 -108
  59. package/src/components/popover/anchored-surface.tsx +223 -0
  60. package/src/components/popover/popover.tsx +149 -20
  61. package/src/components/progress-view/progress-view.tsx +6 -1
  62. package/src/components/progress-view/use-liquid-progress.ts +43 -0
  63. package/src/components/radio-group/radio-group.tsx +63 -0
  64. package/src/components/search-field/expanding-search-field.tsx +183 -0
  65. package/src/components/search-field/search-field.tsx +41 -25
  66. package/src/components/search-list/search-list.tsx +16 -7
  67. package/src/components/sheet/sheet.tsx +4 -1
  68. package/src/components/sidebar/sidebar.tsx +21 -12
  69. package/src/components/skeleton/skeleton.tsx +19 -0
  70. package/src/components/slosh-slider/liquid-step.ts +42 -0
  71. package/src/components/slosh-slider/slosh-slider.tsx +251 -0
  72. package/src/components/stepper/rolling-digit.tsx +77 -0
  73. package/src/components/stepper/stepper.tsx +50 -29
  74. package/src/components/text-field/text-field.tsx +45 -7
  75. package/src/components/toggle/toggle.tsx +147 -30
  76. package/src/components/wake-slider/wake-slider.tsx +237 -0
  77. package/src/hooks/use-anchored-disclosure.ts +23 -0
  78. package/src/hooks/use-anchored-placement.ts +7 -4
  79. package/src/icons/icons.tsx +150 -150
  80. package/src/index.ts +18 -0
  81. package/src/lib/anchored-placement.ts +7 -3
  82. package/src/styles/code-block.css +33 -0
  83. package/src/styles/motion-controls.css +189 -0
  84. package/src/styles/styles.css +3 -0
@@ -0,0 +1,183 @@
1
+ import {
2
+ type InputHTMLAttributes,
3
+ type Ref,
4
+ useEffect,
5
+ useLayoutEffect,
6
+ useRef,
7
+ useState,
8
+ } from "react"
9
+ import { useMediaQuery } from "../../hooks/use-media-query"
10
+ import { SearchIcon } from "../../icons/icons"
11
+ import { cn } from "../../lib/class-names"
12
+ import { springs } from "@passui/tokens/motion"
13
+
14
+ /** One object: the field stays inside the pill, and the lens never moves within it. */
15
+ export function ExpandingSearchField({
16
+ inputRef,
17
+ className,
18
+ placeholder = "Search",
19
+ ...props
20
+ }: InputHTMLAttributes<HTMLInputElement> & { inputRef: Ref<HTMLInputElement> }) {
21
+ const [expanded, setExpanded] = useState(Boolean(props.value ?? props.defaultValue))
22
+ const reduced = useMediaQuery("(prefers-reduced-motion: reduce)")
23
+ const frame = useRef<HTMLDivElement>(null)
24
+ const pill = useRef<HTMLLabelElement>(null)
25
+ const input = useRef<HTMLInputElement>(null)
26
+ const geometry = useRef({
27
+ width: expanded ? 320 : 44,
28
+ velocity: 0,
29
+ x: 0,
30
+ y: 0,
31
+ vx: 0,
32
+ vy: 0,
33
+ pullX: 0,
34
+ pullY: 0,
35
+ capacity: 320,
36
+ raf: 0,
37
+ })
38
+ const current = useRef({ expanded, reduced })
39
+ const wake = useRef<() => void>(() => {})
40
+ useLayoutEffect(() => {
41
+ const box = frame.current,
42
+ label = pill.current,
43
+ field = input.current
44
+ if (!box || !label || !field) return
45
+ const s = geometry.current
46
+ let previous = 0
47
+ const tick = (now: number) => {
48
+ const dt = previous ? Math.min(0.032, (now - previous) / 1000) : 1 / 60
49
+ previous = now
50
+ const target = current.current.expanded ? s.capacity : Math.min(44, s.capacity)
51
+ const pullX = current.current.expanded ? 0 : s.pullX
52
+ const pullY = current.current.expanded ? 0 : s.pullY
53
+ const { stiffness, damping, mass } = springs.smooth
54
+ if (current.current.reduced) {
55
+ s.width = target
56
+ s.x = 0
57
+ s.y = 0
58
+ s.velocity = 0
59
+ s.vx = 0
60
+ s.vy = 0
61
+ } else {
62
+ s.velocity += (((target - s.width) * stiffness - s.velocity * damping) / mass) * dt
63
+ s.width += s.velocity * dt
64
+ s.vx += (((pullX - s.x) * stiffness - s.vx * damping) / mass) * dt
65
+ s.vy += (((pullY - s.y) * stiffness - s.vy * damping) / mass) * dt
66
+ s.x += s.vx * dt
67
+ s.y += s.vy * dt
68
+ }
69
+ label.style.width = `${Math.max(0, s.width)}px`
70
+ label.style.transform = `translate(${s.x}px, ${s.y}px)`
71
+ /* One number drives the field's reveal; focus lands with the
72
+ expansion, so the caret arrives while the shape is growing. */
73
+ const progress = (s.width - 44) / Math.max(1, s.capacity - 44)
74
+ field.style.opacity = String(Math.max(0, Math.min(1, (progress - 0.55) / 0.45)))
75
+ if (
76
+ Math.abs(s.width - target) < 0.05 &&
77
+ Math.abs(s.velocity) < 0.05 &&
78
+ Math.abs(s.x - pullX) + Math.abs(s.y - pullY) + Math.abs(s.vx) + Math.abs(s.vy) <
79
+ 0.05
80
+ ) {
81
+ label.style.width = `${target}px`
82
+ s.raf = 0
83
+ previous = 0
84
+ return
85
+ }
86
+ s.raf = requestAnimationFrame(tick)
87
+ }
88
+ wake.current = () => {
89
+ if (!s.raf) s.raf = requestAnimationFrame(tick)
90
+ }
91
+ const measure = () => {
92
+ s.capacity = Math.min(320, box.clientWidth)
93
+ wake.current()
94
+ }
95
+ measure()
96
+ const observer = new ResizeObserver(measure)
97
+ observer.observe(box)
98
+ return () => {
99
+ observer.disconnect()
100
+ cancelAnimationFrame(s.raf)
101
+ s.raf = 0
102
+ }
103
+ }, [])
104
+ useEffect(() => {
105
+ current.current = { expanded, reduced }
106
+ wake.current()
107
+ }, [expanded, reduced])
108
+ return (
109
+ <div
110
+ ref={frame}
111
+ className={cn(
112
+ "relative flex h-11 w-full max-w-xs items-center justify-center",
113
+ className
114
+ )}
115
+ onPointerMove={(event) => {
116
+ if (expanded || reduced || event.pointerType !== "mouse") return
117
+ // Measure the frame, which stays still; measuring the object being
118
+ // moved creates a feedback loop and a visible shiver.
119
+ const rect = event.currentTarget.getBoundingClientRect()
120
+ const dx = event.clientX - rect.left - rect.width / 2,
121
+ dy = event.clientY - rect.top - rect.height / 2
122
+ const distance = Math.hypot(dx, dy),
123
+ pull = Math.max(0, 1 - distance / 72) ** 1.4 * 7
124
+ geometry.current.pullX = distance ? (dx / distance) * pull : 0
125
+ geometry.current.pullY = distance ? (dy / distance) * pull : 0
126
+ wake.current()
127
+ }}
128
+ onPointerLeave={() => {
129
+ geometry.current.pullX = 0
130
+ geometry.current.pullY = 0
131
+ wake.current()
132
+ }}
133
+ >
134
+ <label
135
+ ref={pill}
136
+ className={cn(
137
+ "passui-search-pill control-field relative block h-11 max-w-full overflow-hidden rounded-full",
138
+ props.disabled && "opacity-40"
139
+ )}
140
+ style={{ width: expanded ? 320 : 44 }}
141
+ >
142
+ <SearchIcon
143
+ aria-hidden="true"
144
+ size={18}
145
+ className="pointer-events-none absolute top-1/2 start-[13px] -translate-y-1/2 text-label-secondary"
146
+ />
147
+ <input
148
+ {...props}
149
+ ref={(node) => {
150
+ input.current = node
151
+ if (typeof inputRef === "function") inputRef(node)
152
+ else if (inputRef) inputRef.current = node
153
+ }}
154
+ type="search"
155
+ placeholder={placeholder}
156
+ aria-label={
157
+ props["aria-labelledby"] ? undefined : (props["aria-label"] ?? placeholder)
158
+ }
159
+ className="h-11 w-full min-w-0 bg-transparent ps-11 pe-3 text-subheadline text-label outline-none placeholder:text-label-tertiary"
160
+ onFocus={(event) => {
161
+ setExpanded(true)
162
+ props.onFocus?.(event)
163
+ }}
164
+ onBlur={(event) => {
165
+ if (!event.currentTarget.value) setExpanded(false)
166
+ props.onBlur?.(event)
167
+ }}
168
+ onKeyDown={(event) => {
169
+ props.onKeyDown?.(event)
170
+ if (
171
+ !event.defaultPrevented &&
172
+ event.key === "Escape" &&
173
+ !event.currentTarget.value
174
+ ) {
175
+ setExpanded(false)
176
+ event.currentTarget.blur()
177
+ }
178
+ }}
179
+ />
180
+ </label>
181
+ </div>
182
+ )
183
+ }
@@ -1,31 +1,47 @@
1
1
  import { forwardRef, type InputHTMLAttributes } from "react"
2
2
  import { SearchIcon } from "../../icons/icons"
3
3
  import { cn } from "../../lib/class-names"
4
+ import { ExpandingSearchField } from "./expanding-search-field"
4
5
 
5
- export const SearchField = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
6
- function SearchField({ className, placeholder = "Search", ...props }, ref) {
7
- const accessibleName = props["aria-labelledby"]
8
- ? undefined
9
- : (props["aria-label"] ?? placeholder)
6
+ export interface SearchFieldProps extends InputHTMLAttributes<HTMLInputElement> {
7
+ /** Expand a single pill from its search icon on focus. @default false */
8
+ collapsible?: boolean
9
+ }
10
+
11
+ export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(function SearchField(
12
+ { className, placeholder = "Search", collapsible = false, ...props },
13
+ ref
14
+ ) {
15
+ if (collapsible)
10
16
  return (
11
- <label
12
- className={cn(
13
- "control-field flex items-center gap-2 rounded-lg px-2.5 py-2 text-subheadline text-label-tertiary",
14
- className
15
- )}
16
- >
17
- <span aria-hidden="true" className="shrink-0">
18
- <SearchIcon size={15} strokeWidth={2} />
19
- </span>
20
- <input
21
- ref={ref}
22
- type="search"
23
- placeholder={placeholder}
24
- aria-label={accessibleName}
25
- className="min-w-0 grow bg-transparent text-label outline-none placeholder:text-label-tertiary"
26
- {...props}
27
- />
28
- </label>
17
+ <ExpandingSearchField
18
+ {...props}
19
+ inputRef={ref}
20
+ className={className}
21
+ placeholder={placeholder}
22
+ />
29
23
  )
30
- }
31
- )
24
+ const accessibleName = props["aria-labelledby"]
25
+ ? undefined
26
+ : (props["aria-label"] ?? placeholder)
27
+ return (
28
+ <label
29
+ className={cn(
30
+ "control-field flex items-center gap-2 rounded-full px-3 py-2 text-subheadline text-label-tertiary",
31
+ className
32
+ )}
33
+ >
34
+ <span aria-hidden="true" className="shrink-0">
35
+ <SearchIcon size={15} strokeWidth={2} />
36
+ </span>
37
+ <input
38
+ ref={ref}
39
+ type="search"
40
+ placeholder={placeholder}
41
+ aria-label={accessibleName}
42
+ className="min-w-0 grow bg-transparent text-label outline-none placeholder:text-label-tertiary"
43
+ {...props}
44
+ />
45
+ </label>
46
+ )
47
+ })
@@ -37,6 +37,10 @@ export function SearchList({
37
37
  open?: boolean
38
38
  }) {
39
39
  const [query, setQuery] = useState("")
40
+ const updateQuery = useCallback((next: string) => {
41
+ setQuery(next)
42
+ setActive("")
43
+ }, [])
40
44
  const [active, setActive] = useState("")
41
45
  const [results, setResults] = useState<Record<string, boolean>>({})
42
46
  const listId = useId()
@@ -54,14 +58,16 @@ export function SearchList({
54
58
  }),
55
59
  []
56
60
  )
61
+ useEffect(() => {
62
+ if (!open) return
63
+ const available = Object.keys(results).filter((id) => results[id])
64
+ if (!available.includes(active)) setActive(available[0] ?? "")
65
+ }, [open, results, active])
57
66
  return (
58
67
  <SearchContext
59
68
  value={{
60
69
  query,
61
- setQuery: (next) => {
62
- setQuery(next)
63
- setActive("")
64
- },
70
+ setQuery: updateQuery,
65
71
  active,
66
72
  listId,
67
73
  open,
@@ -75,7 +81,7 @@ export function SearchList({
75
81
  className={cn("relative", className)}
76
82
  onKeyDown={(event) => {
77
83
  onKeyDown?.(event)
78
- if (event.defaultPrevented || !open) return
84
+ if (event.defaultPrevented || !open || event.nativeEvent.isComposing) return
79
85
  const items = Array.from(
80
86
  ref.current?.querySelectorAll<HTMLButtonElement>(
81
87
  "[data-search-result]:not([hidden]):not(:disabled)"
@@ -125,13 +131,16 @@ export function SearchListField({
125
131
  onValueChange?: (value: string) => void
126
132
  }) {
127
133
  const search = useSearch()
134
+ useEffect(() => {
135
+ if (value !== undefined) search.setQuery(value)
136
+ }, [value, search.setQuery])
128
137
  return (
129
138
  <input
130
139
  {...props}
131
140
  role="combobox"
132
141
  aria-autocomplete="list"
133
142
  aria-expanded={search.open}
134
- aria-controls={search.listId}
143
+ aria-controls={search.open ? search.listId : undefined}
135
144
  aria-activedescendant={search.open && search.active ? search.active : undefined}
136
145
  aria-label={props["aria-label"] ?? props.placeholder}
137
146
  value={value ?? search.query}
@@ -223,7 +232,7 @@ export function SearchListItem({
223
232
  aria-selected={search.active === id}
224
233
  data-selected={search.active === id ? "true" : undefined}
225
234
  className={cn(
226
- "flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left hover:bg-fill aria-selected:bg-accent-soft",
235
+ "flex min-h-9 w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-subheadline text-label hover:bg-fill aria-selected:bg-fill [@media(pointer:coarse)]:min-h-11",
227
236
  className
228
237
  )}
229
238
  onMouseDown={(event) => {
@@ -13,6 +13,8 @@ export interface SheetProps {
13
13
  open: boolean
14
14
  /** Called on Escape or an outside press. */
15
15
  onClose: () => void
16
+ /** Allow tapping the backdrop to close. Escape still calls onClose. @default true */
17
+ dismissOnOutsidePress?: boolean
16
18
  /** Read out by screen readers as the sheet's name. */
17
19
  label: string
18
20
  /** ID of a visible heading to use instead of label for the accessible name. */
@@ -41,6 +43,7 @@ export function Sheet({ open, ...props }: SheetProps) {
41
43
 
42
44
  function OpenSheet({
43
45
  onClose,
46
+ dismissOnOutsidePress = true,
44
47
  label,
45
48
  labelledBy,
46
49
  placement = "adaptive",
@@ -77,7 +80,7 @@ function OpenSheet({
77
80
  initial={{ opacity: 0 }}
78
81
  animate={{ opacity: 1 }}
79
82
  exit={{ opacity: 0 }}
80
- onClick={onClose}
83
+ onClick={dismissOnOutsidePress ? onClose : undefined}
81
84
  />
82
85
  <motion.div
83
86
  ref={panel}
@@ -8,7 +8,7 @@ export interface SidebarSectionProps {
8
8
  }
9
9
 
10
10
  export interface SidebarItemContentProps {
11
- icon: ReactNode
11
+ icon?: ReactNode
12
12
  /** Optional trailing count. */
13
13
  count?: number
14
14
  /** Matches the router link's active state. */
@@ -32,24 +32,33 @@ export function SidebarSection({ title, children }: SidebarSectionProps) {
32
32
  }
33
33
 
34
34
  /** Classes for a sidebar row; apply to the app's router link. */
35
- export function sidebarItemClass(active?: boolean): string {
35
+ export type SidebarDensity = "regular" | "compact"
36
+
37
+ export function sidebarItemClass(active?: boolean, density: SidebarDensity = "regular"): string {
36
38
  return cn(
37
- "group flex items-center gap-2.5 rounded-2xl px-2.5 py-[7px] text-subheadline text-label no-underline transition-colors duration-fast",
38
- active ? "bg-accent-soft font-medium text-label" : "hover:bg-fill/70"
39
+ "group flex w-full items-center gap-2.5 text-label no-underline transition-colors duration-fast focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-text",
40
+ density === "compact"
41
+ ? "min-h-8 rounded-lg px-2 py-1 text-footnote [@media(pointer:coarse)]:min-h-10"
42
+ : "rounded-2xl px-2.5 py-[7px] text-subheadline",
43
+ active
44
+ ? "bg-fill font-medium"
45
+ : "font-normal text-label-secondary hover:bg-fill/70 hover:text-label"
39
46
  )
40
47
  }
41
48
 
42
49
  export function SidebarItemContent({ icon, count, active, children }: SidebarItemContentProps) {
43
50
  return (
44
51
  <>
45
- <span
46
- className={cn(
47
- "flex transition-colors",
48
- active ? "text-accent-text" : "text-label-secondary"
49
- )}
50
- >
51
- {icon}
52
- </span>
52
+ {icon && (
53
+ <span
54
+ className={cn(
55
+ "flex transition-colors",
56
+ active ? "text-accent-text" : "text-label-secondary"
57
+ )}
58
+ >
59
+ {icon}
60
+ </span>
61
+ )}
53
62
  <span className="grow truncate">{children}</span>
54
63
  {count !== undefined && (
55
64
  <span
@@ -0,0 +1,19 @@
1
+ import type { ComponentProps } from "react"
2
+ import { cn } from "../../lib/class-names"
3
+
4
+ export interface SkeletonProps extends ComponentProps<"div"> {
5
+ label?: string
6
+ }
7
+ /** A loading placeholder that becomes still when Reduce Motion is enabled. */
8
+ export function Skeleton({ label, className, ...props }: SkeletonProps) {
9
+ return (
10
+ <div
11
+ {...props}
12
+ role={label ? "status" : undefined}
13
+ aria-hidden={label ? undefined : true}
14
+ className={cn("min-h-4 rounded-md bg-fill motion-safe:animate-pulse", className)}
15
+ >
16
+ {label && <span className="sr-only">{label}</span>}
17
+ </div>
18
+ )
19
+ }
@@ -0,0 +1,42 @@
1
+ import { clampRange } from "../../lib/range-value"
2
+
3
+ export interface LiquidState {
4
+ fill: number
5
+ velocity: number
6
+ }
7
+
8
+ /** Bencho's frame-normalized liquid spring. See THIRD_PARTY_NOTICES.md. */
9
+ export function stepLiquid(
10
+ state: LiquidState,
11
+ target: number,
12
+ viscosity: number,
13
+ dt: number
14
+ ): LiquidState {
15
+ const soft = clampRange(viscosity, 0, 100) / 100
16
+ target = clampRange(target, 0, 100)
17
+ dt = clampRange(dt, 0, 2.5)
18
+ if (soft === 0) {
19
+ /* 0 is an ordinary slider and has to be exactly that,
20
+ not a very stiff spring that still rings. */
21
+ return { fill: target, velocity: 0 }
22
+ }
23
+ /* Stiffness falls and damping rises together: thin liquid is slow to
24
+ answer and slow to forget, which is why these are one knob. */
25
+ const stiff = 0.34 - soft * 0.29
26
+ const damp = 0.74 + soft * 0.22
27
+ let velocity = (state.velocity + (target - state.fill) * stiff * dt) * damp ** dt
28
+ let fill = state.fill + velocity * dt
29
+ /* The end stop is a wall, and a wall gives some back. Without this
30
+ the overshoot is swallowed exactly where you are looking at it. */
31
+ if (fill > 100) {
32
+ fill = 100
33
+ velocity = -velocity * 0.42
34
+ }
35
+ if (fill < 0) {
36
+ fill = 0
37
+ velocity = -velocity * 0.42
38
+ }
39
+ if (Math.abs(target - fill) < 0.02 && Math.abs(velocity) < 0.02)
40
+ return { fill: target, velocity: 0 }
41
+ return { fill, velocity }
42
+ }