aeon-ui-engine 1.0.0 → 1.0.2

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 (88) hide show
  1. package/.changeset/config.json +2 -1
  2. package/CHANGELOG.md +38 -0
  3. package/COMPONENTS.md +7 -2
  4. package/INTEGRATION.md +14 -0
  5. package/PUBLISHING.md +20 -0
  6. package/README.md +8 -4
  7. package/apps/demo/og.config.mjs +8 -3
  8. package/apps/demo/public/og-aeon-ui-engine.png +0 -0
  9. package/apps/demo/public/og.svg +2 -2
  10. package/apps/demo/scripts/generate-og-png.mjs +4 -2
  11. package/apps/demo/src/App.tsx +2 -2
  12. package/apps/demo/src/aeonDiagrams.ts +17 -75
  13. package/apps/demo/src/components/DiagramsSection.tsx +8 -9
  14. package/apps/demo/src/components/HeroInstall.tsx +7 -4
  15. package/apps/demo/src/components/LandingFaq.tsx +1 -1
  16. package/apps/demo/src/components/LandingFooter.tsx +8 -0
  17. package/apps/demo/src/components/LandingHeader.tsx +4 -2
  18. package/apps/demo/src/components/LayersSection.tsx +6 -10
  19. package/apps/demo/src/components/MermaidDiagram.tsx +5 -3
  20. package/apps/demo/src/components/PhilosophySection.tsx +0 -2
  21. package/apps/demo/src/components/PlaygroundBlock.tsx +3 -8
  22. package/apps/demo/src/components/PlaygroundControlsDemo.tsx +63 -0
  23. package/apps/demo/src/components/PlaygroundSection.tsx +7 -91
  24. package/apps/demo/src/components/ThemeSwitcher.tsx +13 -10
  25. package/apps/demo/src/demo-layout.css +29 -0
  26. package/apps/demo/src/landing.css +138 -89
  27. package/apps/demo/src/nav.ts +3 -3
  28. package/apps/demo/src/site.ts +99 -228
  29. package/docs/MACHINES.md +6 -1
  30. package/package.json +1 -1
  31. package/packages/core/src/anatomy.ts +23 -0
  32. package/packages/panda/panda.config.ts +19 -1
  33. package/packages/panda/src/recipes/button.ts +4 -4
  34. package/packages/panda/src/recipes/combobox.ts +1 -1
  35. package/packages/panda/src/recipes/index.ts +5 -0
  36. package/packages/panda/src/recipes/pin-input.ts +29 -0
  37. package/packages/panda/src/recipes/progress.ts +44 -0
  38. package/packages/panda/src/recipes/radio-group.ts +51 -0
  39. package/packages/panda/src/recipes/select.ts +3 -1
  40. package/packages/panda/src/recipes/separator.ts +35 -0
  41. package/packages/panda/src/recipes/slider.ts +57 -0
  42. package/packages/panda/src/theme/tokens.ts +5 -0
  43. package/packages/panda/styled-system/recipes/aeon-pin-input.d.ts +28 -0
  44. package/packages/panda/styled-system/recipes/aeon-pin-input.mjs +36 -0
  45. package/packages/panda/styled-system/recipes/aeon-progress.d.ts +28 -0
  46. package/packages/panda/styled-system/recipes/aeon-progress.mjs +44 -0
  47. package/packages/panda/styled-system/recipes/aeon-radio-group.d.ts +28 -0
  48. package/packages/panda/styled-system/recipes/aeon-radio-group.mjs +48 -0
  49. package/packages/panda/styled-system/recipes/aeon-separator.d.ts +31 -0
  50. package/packages/panda/styled-system/recipes/aeon-separator.mjs +41 -0
  51. package/packages/panda/styled-system/recipes/aeon-slider.d.ts +28 -0
  52. package/packages/panda/styled-system/recipes/aeon-slider.mjs +48 -0
  53. package/packages/panda/styled-system/recipes/index.d.ts +5 -0
  54. package/packages/panda/styled-system/recipes/index.mjs +5 -0
  55. package/packages/panda/styled-system/styles.css +233 -52
  56. package/packages/panda/styled-system/tokens/index.mjs +32 -0
  57. package/packages/panda/styled-system/tokens/tokens.d.ts +2 -2
  58. package/packages/panda/styled-system/types/conditions.d.ts +1 -1
  59. package/packages/primitives/src/index.ts +1 -0
  60. package/packages/primitives/src/machines/slider.ts +93 -0
  61. package/packages/react/src/checkbox/index.tsx +14 -0
  62. package/packages/react/src/floating/FloatingPositioner.tsx +59 -0
  63. package/packages/react/src/hooks/use-anchor-position.ts +33 -5
  64. package/packages/react/src/hooks/use-prefers-hover.ts +16 -0
  65. package/packages/react/src/index.ts +5 -0
  66. package/packages/react/src/menu/index.tsx +38 -11
  67. package/packages/react/src/pin-input/index.tsx +223 -0
  68. package/packages/react/src/popover/index.tsx +38 -11
  69. package/packages/react/src/progress/index.tsx +110 -0
  70. package/packages/react/src/radio-group/index.tsx +199 -0
  71. package/packages/react/src/select/index.tsx +51 -28
  72. package/packages/react/src/select/select.test.tsx +21 -0
  73. package/packages/react/src/separator/index.tsx +23 -0
  74. package/packages/react/src/slider/index.tsx +264 -0
  75. package/packages/react/src/toast/primitives.tsx +8 -1
  76. package/packages/react/src/toast/toast.test.tsx +40 -0
  77. package/packages/react/src/tooltip/index.tsx +124 -20
  78. package/packages/react/src/tooltip/tooltip.test.tsx +114 -0
  79. package/packages/site/README.md +1 -1
  80. package/packages/ui/src/index.ts +5 -0
  81. package/packages/ui/src/pin-input.tsx +16 -0
  82. package/packages/ui/src/progress.tsx +21 -0
  83. package/packages/ui/src/radio-group.tsx +24 -0
  84. package/packages/ui/src/select.tsx +3 -4
  85. package/packages/ui/src/separator.tsx +15 -0
  86. package/packages/ui/src/slider.tsx +24 -0
  87. package/apps/demo/public/og.png +0 -0
  88. package/apps/demo/src/components/SceneSection.tsx +0 -157
@@ -0,0 +1,199 @@
1
+ import { partAttrs, partOnlyAttrs, radioGroupAnatomy } from '@aeon-ui/core'
2
+ import { tabsMachine } from '@aeon-ui/primitives'
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ type ButtonHTMLAttributes,
10
+ type HTMLAttributes,
11
+ type KeyboardEvent,
12
+ type MouseEvent,
13
+ type ReactNode,
14
+ } from 'react'
15
+ import { useAeonMachine } from '../hooks/use-aeon-machine.js'
16
+ import { mergeProps } from '../utils/merge-props.js'
17
+
18
+ interface RadioGroupContextValue {
19
+ value: string
20
+ send: ReturnType<typeof useAeonMachine<typeof tabsMachine>>[1]
21
+ }
22
+
23
+ const RadioGroupCtx = createContext<RadioGroupContextValue | null>(null)
24
+
25
+ function useRadioGroupCtx() {
26
+ const ctx = useContext(RadioGroupCtx)
27
+ if (!ctx) throw new Error('RadioGroup parts must be used within RadioGroup.Root')
28
+ return ctx
29
+ }
30
+
31
+ interface RadioGroupItemContextValue {
32
+ value: string
33
+ selected: boolean
34
+ }
35
+
36
+ const RadioGroupItemCtx = createContext<RadioGroupItemContextValue | null>(null)
37
+
38
+ function useRadioGroupItemCtx() {
39
+ const ctx = useContext(RadioGroupItemCtx)
40
+ if (!ctx) throw new Error('RadioGroup item parts must be used within RadioGroup.Item')
41
+ return ctx
42
+ }
43
+
44
+ export interface RadioGroupRootProps {
45
+ value?: string
46
+ defaultValue?: string
47
+ onValueChange?: (value: string) => void
48
+ children?: ReactNode
49
+ className?: string
50
+ }
51
+
52
+ const Root = forwardRef<HTMLDivElement, RadioGroupRootProps>(function RadioGroupRoot(
53
+ { value, defaultValue = '', onValueChange, children, className, ...rest },
54
+ ref,
55
+ ) {
56
+ const [snapshot, send] = useAeonMachine(tabsMachine, {
57
+ input: { value: defaultValue, disabled: false },
58
+ })
59
+
60
+ const resolvedValue = value ?? snapshot.context.value
61
+
62
+ useEffect(() => {
63
+ onValueChange?.(resolvedValue)
64
+ }, [resolvedValue, onValueChange])
65
+
66
+ useEffect(() => {
67
+ if (value !== undefined) send({ type: 'SET_VALUE', value })
68
+ }, [value, send])
69
+
70
+ const ctx = useMemo(() => ({ value: resolvedValue, send }), [resolvedValue, send])
71
+
72
+ return (
73
+ <RadioGroupCtx.Provider value={ctx}>
74
+ <div
75
+ ref={ref}
76
+ role="radiogroup"
77
+ className={className}
78
+ {...mergeProps(
79
+ partAttrs(radioGroupAnatomy.scope, radioGroupAnatomy.root),
80
+ rest as HTMLAttributes<HTMLDivElement>,
81
+ )}
82
+ >
83
+ {children}
84
+ </div>
85
+ </RadioGroupCtx.Provider>
86
+ )
87
+ })
88
+
89
+ export interface RadioGroupItemProps extends HTMLAttributes<HTMLDivElement> {
90
+ value: string
91
+ }
92
+
93
+ const Item = forwardRef<HTMLDivElement, RadioGroupItemProps>(function RadioGroupItem(
94
+ { value: itemValue, children, ...rest },
95
+ ref,
96
+ ) {
97
+ const { value } = useRadioGroupCtx()
98
+ const selected = value === itemValue
99
+ const itemCtx = useMemo(() => ({ value: itemValue, selected }), [itemValue, selected])
100
+
101
+ return (
102
+ <RadioGroupItemCtx.Provider value={itemCtx}>
103
+ <div
104
+ ref={ref}
105
+ {...mergeProps(
106
+ partOnlyAttrs(radioGroupAnatomy.item, {
107
+ state: selected ? 'selected' : 'unselected',
108
+ }),
109
+ rest,
110
+ )}
111
+ >
112
+ {children}
113
+ </div>
114
+ </RadioGroupItemCtx.Provider>
115
+ )
116
+ })
117
+
118
+ const ItemControl = forwardRef<HTMLSpanElement, ButtonHTMLAttributes<HTMLSpanElement>>(function RadioGroupItemControl(
119
+ { onClick, onKeyDown, ...rest },
120
+ ref,
121
+ ) {
122
+ const { send } = useRadioGroupCtx()
123
+ const { value: itemValue, selected } = useRadioGroupItemCtx()
124
+
125
+ const select = () => send({ type: 'SELECT', value: itemValue })
126
+
127
+ return (
128
+ <span
129
+ ref={ref}
130
+ role="radio"
131
+ aria-checked={selected}
132
+ tabIndex={selected ? 0 : -1}
133
+ {...mergeProps(
134
+ partOnlyAttrs(radioGroupAnatomy.itemControl, {
135
+ state: selected ? 'selected' : 'unselected',
136
+ highlighted: selected,
137
+ }),
138
+ {
139
+ onClick: (e: MouseEvent<HTMLSpanElement>) => {
140
+ onClick?.(e)
141
+ select()
142
+ },
143
+ onKeyDown: (e: KeyboardEvent<HTMLSpanElement>) => {
144
+ onKeyDown?.(e)
145
+ if (e.key === ' ' || e.key === 'Enter') {
146
+ e.preventDefault()
147
+ select()
148
+ }
149
+ },
150
+ },
151
+ rest,
152
+ )}
153
+ />
154
+ )
155
+ })
156
+
157
+ const ItemIndicator = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(function RadioGroupItemIndicator(
158
+ props,
159
+ ref,
160
+ ) {
161
+ const { selected } = useRadioGroupItemCtx()
162
+ if (!selected) return null
163
+
164
+ return (
165
+ <span
166
+ ref={ref}
167
+ {...mergeProps(
168
+ partOnlyAttrs(radioGroupAnatomy.itemIndicator, { state: 'selected' }),
169
+ props,
170
+ )}
171
+ />
172
+ )
173
+ })
174
+
175
+ const ItemLabel = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(function RadioGroupItemLabel(
176
+ { onClick, ...rest },
177
+ ref,
178
+ ) {
179
+ const { send } = useRadioGroupCtx()
180
+ const { value: itemValue } = useRadioGroupItemCtx()
181
+
182
+ return (
183
+ <span
184
+ ref={ref}
185
+ {...mergeProps(
186
+ partOnlyAttrs(radioGroupAnatomy.itemLabel),
187
+ {
188
+ onClick: (e: MouseEvent<HTMLSpanElement>) => {
189
+ onClick?.(e)
190
+ send({ type: 'SELECT', value: itemValue })
191
+ },
192
+ },
193
+ rest,
194
+ )}
195
+ />
196
+ )
197
+ })
198
+
199
+ export const RadioGroup = { Root, Item, ItemControl, ItemIndicator, ItemLabel }
@@ -15,10 +15,9 @@ import {
15
15
  type MouseEvent,
16
16
  type ReactNode,
17
17
  } from 'react'
18
+ import { FloatingPositioner } from '../floating/FloatingPositioner.js'
18
19
  import { useAeonMachine } from '../hooks/use-aeon-machine.js'
19
- import { useAnchorPosition } from '../hooks/use-anchor-position.js'
20
20
  import { useOutsideClick } from '../hooks/use-outside-click.js'
21
- import { renderPortalled, usePortalledMount } from '../hooks/use-portalled.js'
22
21
  import { mergeProps } from '../utils/merge-props.js'
23
22
 
24
23
  interface SelectContextValue {
@@ -39,6 +38,9 @@ interface SelectContextValue {
39
38
 
40
39
  const SelectCtx = createContext<SelectContextValue | null>(null)
41
40
 
41
+ /** Set when `Select.Content` is a descendant of `Select.Positioner`. */
42
+ const SelectPositionerCtx = createContext(false)
43
+
42
44
  function useSelectCtx() {
43
45
  const ctx = useContext(SelectCtx)
44
46
  if (!ctx) throw new Error('Select parts must be used within <Select.Root>')
@@ -263,44 +265,49 @@ const Positioner = forwardRef<HTMLDivElement, SelectPositionerProps>(function Se
263
265
  ref,
264
266
  ) {
265
267
  const { open, triggerRef, positionerRef } = useSelectCtx()
266
- const mounted = usePortalledMount()
267
-
268
- useAnchorPosition(triggerRef, positionerRef, open && portalled)
269
-
270
- if (!open) return null
271
268
 
272
- const node = (
273
- <div
274
- ref={(el) => {
275
- positionerRef.current = el
276
- if (typeof ref === 'function') ref(el)
277
- else if (ref) ref.current = el
278
- }}
279
- className={className}
280
- {...mergeProps(
281
- partAttrs(selectAnatomy.scope, selectAnatomy.positioner, { state: 'open' }),
282
- rest,
283
- )}
284
- >
285
- {children}
286
- </div>
269
+ return (
270
+ <SelectPositionerCtx.Provider value={true}>
271
+ <FloatingPositioner
272
+ ref={ref}
273
+ open={open}
274
+ scope={selectAnatomy.scope}
275
+ part={selectAnatomy.positioner}
276
+ triggerRef={triggerRef}
277
+ positionerRef={positionerRef}
278
+ portalled={portalled}
279
+ matchAnchorWidth
280
+ className={className}
281
+ {...rest}
282
+ >
283
+ {children}
284
+ </FloatingPositioner>
285
+ </SelectPositionerCtx.Provider>
287
286
  )
288
-
289
- return renderPortalled(node, portalled, mounted)
290
287
  })
291
288
 
292
- const Content = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function SelectContent(
293
- { onKeyDown, children, className, ...rest },
289
+ export interface SelectContentProps extends HTMLAttributes<HTMLDivElement> {
290
+ /**
291
+ * When `Select.Content` is not wrapped in `Select.Positioner`, portal to body (default true).
292
+ * Ignored when a `Select.Positioner` ancestor is present.
293
+ */
294
+ portalled?: boolean
295
+ }
296
+
297
+ const Content = forwardRef<HTMLDivElement, SelectContentProps>(function SelectContent(
298
+ { portalled = true, onKeyDown, children, className, ...rest },
294
299
  ref,
295
300
  ) {
296
- const { open, listboxId, contentRef, highlightedValue, onListboxKeyDown } = useSelectCtx()
301
+ const inPositioner = useContext(SelectPositionerCtx)
302
+ const { open, listboxId, contentRef, highlightedValue, onListboxKeyDown, triggerRef, positionerRef } =
303
+ useSelectCtx()
297
304
 
298
305
  if (!open) return null
299
306
 
300
307
  const activeId =
301
308
  highlightedValue && open ? `${listboxId}-option-${highlightedValue}` : undefined
302
309
 
303
- return (
310
+ const listbox = (
304
311
  <div
305
312
  ref={(node) => {
306
313
  contentRef.current = node
@@ -323,6 +330,22 @@ const Content = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(funct
323
330
  {children}
324
331
  </div>
325
332
  )
333
+
334
+ if (inPositioner) return listbox
335
+
336
+ return (
337
+ <FloatingPositioner
338
+ open={open}
339
+ scope={selectAnatomy.scope}
340
+ part={selectAnatomy.positioner}
341
+ triggerRef={triggerRef}
342
+ positionerRef={positionerRef}
343
+ portalled={portalled}
344
+ matchAnchorWidth
345
+ >
346
+ {listbox}
347
+ </FloatingPositioner>
348
+ )
326
349
  })
327
350
 
328
351
  export interface SelectItemProps extends ButtonHTMLAttributes<HTMLButtonElement> {
@@ -35,6 +35,27 @@ describe('Select', () => {
35
35
  expect(trigger).toHaveAttribute('aria-expanded', 'false')
36
36
  })
37
37
 
38
+ it('portals listbox when Content is used without Positioner', async () => {
39
+ const user = userEvent.setup()
40
+
41
+ render(
42
+ <Select.Root value="a">
43
+ <Select.Trigger>
44
+ <Select.ValueText>Alpha</Select.ValueText>
45
+ </Select.Trigger>
46
+ <Select.Content>
47
+ <Select.Item value="a">Alpha</Select.Item>
48
+ </Select.Content>
49
+ </Select.Root>,
50
+ )
51
+
52
+ const root = screen.getByRole('button').parentElement!
53
+ await user.click(screen.getByRole('button'))
54
+
55
+ const listbox = screen.getByRole('listbox')
56
+ expect(root).not.toContainElement(listbox)
57
+ })
58
+
38
59
  it('closes on escape', async () => {
39
60
  const user = userEvent.setup()
40
61
 
@@ -0,0 +1,23 @@
1
+ import { partAttrs, separatorAnatomy } from '@aeon-ui/core'
2
+ import { forwardRef, type HTMLAttributes } from 'react'
3
+ import { mergeProps } from '../utils/merge-props.js'
4
+
5
+ export interface SeparatorRootProps extends HTMLAttributes<HTMLDivElement> {
6
+ orientation?: 'horizontal' | 'vertical'
7
+ }
8
+
9
+ const Root = forwardRef<HTMLDivElement, SeparatorRootProps>(function SeparatorRoot(
10
+ { orientation = 'horizontal', ...rest },
11
+ ref,
12
+ ) {
13
+ return (
14
+ <div
15
+ ref={ref}
16
+ role="separator"
17
+ aria-orientation={orientation}
18
+ {...mergeProps(partAttrs(separatorAnatomy.scope, separatorAnatomy.root), rest)}
19
+ />
20
+ )
21
+ })
22
+
23
+ export const Separator = { Root }
@@ -0,0 +1,264 @@
1
+ import { partAttrs, partOnlyAttrs, sliderAnatomy } from '@aeon-ui/core'
2
+ import { sliderMachine, snapSliderValue } from '@aeon-ui/primitives'
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ type HTMLAttributes,
12
+ type KeyboardEvent,
13
+ type PointerEvent,
14
+ type ReactNode,
15
+ } from 'react'
16
+ import { useAeonMachine } from '../hooks/use-aeon-machine.js'
17
+ import { mergeProps } from '../utils/merge-props.js'
18
+
19
+ interface SliderContextValue {
20
+ value: number
21
+ min: number
22
+ max: number
23
+ step: number
24
+ disabled: boolean
25
+ dragging: boolean
26
+ send: ReturnType<typeof useAeonMachine<typeof sliderMachine>>[1]
27
+ onValueChange?: (value: number) => void
28
+ setValueFromClientX: (clientX: number) => void
29
+ trackRef: React.RefObject<HTMLDivElement | null>
30
+ }
31
+
32
+ const SliderCtx = createContext<SliderContextValue | null>(null)
33
+
34
+ function useSliderCtx() {
35
+ const ctx = useContext(SliderCtx)
36
+ if (!ctx) throw new Error('Slider parts must be used within Slider.Root')
37
+ return ctx
38
+ }
39
+
40
+ function percent(value: number, min: number, max: number) {
41
+ if (max <= min) return 0
42
+ return ((value - min) / (max - min)) * 100
43
+ }
44
+
45
+ export interface SliderRootProps {
46
+ value?: number
47
+ defaultValue?: number
48
+ min?: number
49
+ max?: number
50
+ step?: number
51
+ disabled?: boolean
52
+ onValueChange?: (value: number) => void
53
+ children?: ReactNode
54
+ className?: string
55
+ }
56
+
57
+ const Root = forwardRef<HTMLDivElement, SliderRootProps>(function SliderRoot(
58
+ {
59
+ value,
60
+ defaultValue,
61
+ min = 0,
62
+ max = 100,
63
+ step = 1,
64
+ disabled = false,
65
+ onValueChange,
66
+ children,
67
+ className,
68
+ ...rest
69
+ },
70
+ ref,
71
+ ) {
72
+ const trackRef = useRef<HTMLDivElement>(null)
73
+ const [snapshot, send] = useAeonMachine(sliderMachine, {
74
+ input: {
75
+ value: defaultValue ?? min,
76
+ min,
77
+ max,
78
+ step,
79
+ disabled,
80
+ },
81
+ })
82
+
83
+ const resolvedValue = value ?? snapshot.context.value
84
+ const dragging = snapshot.matches({ interaction: 'dragging' })
85
+
86
+ useEffect(() => {
87
+ onValueChange?.(resolvedValue)
88
+ }, [resolvedValue, onValueChange])
89
+
90
+ useEffect(() => {
91
+ if (value !== undefined) send({ type: 'SET_VALUE', value })
92
+ }, [value, send])
93
+
94
+ const setValueFromClientX = useCallback(
95
+ (clientX: number) => {
96
+ const track = trackRef.current
97
+ if (!track || disabled) return
98
+ const rect = track.getBoundingClientRect()
99
+ if (rect.width <= 0) return
100
+ const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width))
101
+ const next = snapSliderValue(min + ratio * (max - min), min, max, step)
102
+ send({ type: 'SET_VALUE', value: next })
103
+ if (value === undefined) onValueChange?.(next)
104
+ },
105
+ [disabled, min, max, step, send, value, onValueChange],
106
+ )
107
+
108
+ useEffect(() => {
109
+ if (!dragging) return
110
+ const onMove = (e: globalThis.PointerEvent) => setValueFromClientX(e.clientX)
111
+ const onUp = () => send({ type: 'POINTER_UP' })
112
+ window.addEventListener('pointermove', onMove)
113
+ window.addEventListener('pointerup', onUp)
114
+ return () => {
115
+ window.removeEventListener('pointermove', onMove)
116
+ window.removeEventListener('pointerup', onUp)
117
+ }
118
+ }, [dragging, setValueFromClientX, send])
119
+
120
+ const ctx = useMemo(
121
+ () => ({
122
+ value: resolvedValue,
123
+ min,
124
+ max,
125
+ step,
126
+ disabled,
127
+ dragging,
128
+ send,
129
+ onValueChange,
130
+ setValueFromClientX,
131
+ trackRef,
132
+ }),
133
+ [resolvedValue, min, max, step, disabled, dragging, send, onValueChange, setValueFromClientX],
134
+ )
135
+
136
+ return (
137
+ <SliderCtx.Provider value={ctx}>
138
+ <div
139
+ ref={ref}
140
+ className={className}
141
+ {...mergeProps(
142
+ partAttrs(sliderAnatomy.scope, sliderAnatomy.root, {
143
+ state: dragging ? 'dragging' : 'idle',
144
+ disabled,
145
+ }),
146
+ rest as HTMLAttributes<HTMLDivElement>,
147
+ )}
148
+ >
149
+ {children}
150
+ </div>
151
+ </SliderCtx.Provider>
152
+ )
153
+ })
154
+
155
+ const Track = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function SliderTrack(
156
+ { onPointerDown, ...rest },
157
+ ref,
158
+ ) {
159
+ const { disabled, send, setValueFromClientX, trackRef } = useSliderCtx()
160
+
161
+ return (
162
+ <div
163
+ ref={(node) => {
164
+ trackRef.current = node
165
+ if (typeof ref === 'function') ref(node)
166
+ else if (ref) ref.current = node
167
+ }}
168
+ {...mergeProps(
169
+ partOnlyAttrs(sliderAnatomy.track),
170
+ {
171
+ onPointerDown: (e: PointerEvent<HTMLDivElement>) => {
172
+ onPointerDown?.(e)
173
+ if (disabled || e.button !== 0) return
174
+ e.currentTarget.setPointerCapture(e.pointerId)
175
+ send({ type: 'POINTER_DOWN' })
176
+ setValueFromClientX(e.clientX)
177
+ },
178
+ },
179
+ rest,
180
+ )}
181
+ />
182
+ )
183
+ })
184
+
185
+ const Range = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function SliderRange(
186
+ { style, ...rest },
187
+ ref,
188
+ ) {
189
+ const { value, min, max } = useSliderCtx()
190
+ return (
191
+ <div
192
+ ref={ref}
193
+ {...mergeProps(partOnlyAttrs(sliderAnatomy.range), rest, {
194
+ style: { ...style, width: `${percent(value, min, max)}%` },
195
+ })}
196
+ />
197
+ )
198
+ })
199
+
200
+ const Thumb = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(function SliderThumb(
201
+ { onKeyDown, onPointerDown, style, ...rest },
202
+ ref,
203
+ ) {
204
+ const { value, min, max, step, disabled, dragging, send } = useSliderCtx()
205
+
206
+ return (
207
+ <span
208
+ ref={ref}
209
+ role="slider"
210
+ tabIndex={disabled ? -1 : 0}
211
+ aria-valuemin={min}
212
+ aria-valuemax={max}
213
+ aria-valuenow={value}
214
+ aria-disabled={disabled || undefined}
215
+ {...mergeProps(
216
+ partOnlyAttrs(sliderAnatomy.thumb, { state: dragging ? 'dragging' : disabled ? 'disabled' : 'idle' }),
217
+ {
218
+ style: { ...style, left: `${percent(value, min, max)}%` },
219
+ onPointerDown: (e: PointerEvent<HTMLSpanElement>) => {
220
+ onPointerDown?.(e)
221
+ if (disabled || e.button !== 0) return
222
+ e.stopPropagation()
223
+ send({ type: 'POINTER_DOWN' })
224
+ },
225
+ onKeyDown: (e: KeyboardEvent<HTMLSpanElement>) => {
226
+ onKeyDown?.(e)
227
+ if (disabled || e.defaultPrevented) return
228
+ if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
229
+ e.preventDefault()
230
+ send({ type: 'STEP', delta: step })
231
+ }
232
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
233
+ e.preventDefault()
234
+ send({ type: 'STEP', delta: -step })
235
+ }
236
+ if (e.key === 'Home') {
237
+ e.preventDefault()
238
+ send({ type: 'HOME' })
239
+ }
240
+ if (e.key === 'End') {
241
+ e.preventDefault()
242
+ send({ type: 'END' })
243
+ }
244
+ },
245
+ },
246
+ rest,
247
+ )}
248
+ />
249
+ )
250
+ })
251
+
252
+ const ValueText = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(function SliderValueText(
253
+ props,
254
+ ref,
255
+ ) {
256
+ const { value } = useSliderCtx()
257
+ return (
258
+ <span ref={ref} {...mergeProps(partOnlyAttrs(sliderAnatomy.valueText), props)}>
259
+ {props.children ?? value}
260
+ </span>
261
+ )
262
+ })
263
+
264
+ export const Slider = { Root, Track, Range, Thumb, ValueText }
@@ -7,6 +7,7 @@ import {
7
7
  useEffect,
8
8
  useId,
9
9
  useMemo,
10
+ useRef,
10
11
  type ButtonHTMLAttributes,
11
12
  type HTMLAttributes,
12
13
  type MouseEvent,
@@ -44,13 +45,19 @@ export const Root = forwardRef<HTMLDivElement, ToastRootProps>(function ToastRoo
44
45
  const [snapshot, send] = useAeonMachine(toastMachine, { input: { durationMs } })
45
46
  const visible = snapshot.matches('visible')
46
47
  const titleId = useId()
48
+ const hasBeenVisible = useRef(false)
47
49
 
48
50
  useEffect(() => {
49
51
  if (defaultVisible) send({ type: 'SHOW' })
50
52
  }, [defaultVisible, send])
51
53
 
52
54
  useEffect(() => {
53
- onVisibleChange?.(visible)
55
+ if (visible) {
56
+ hasBeenVisible.current = true
57
+ onVisibleChange?.(true)
58
+ return
59
+ }
60
+ if (hasBeenVisible.current) onVisibleChange?.(false)
54
61
  }, [visible, onVisibleChange])
55
62
 
56
63
  const value = useMemo(() => ({ visible, send, titleId }), [visible, send, titleId])
@@ -0,0 +1,40 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { Toast, useToast } from './index.js'
5
+
6
+ function PublishButton() {
7
+ const { publish } = useToast()
8
+ return (
9
+ <button
10
+ type="button"
11
+ onClick={() =>
12
+ publish({
13
+ title: 'Saved',
14
+ description: 'Your preferences were updated.',
15
+ durationMs: 4000,
16
+ })
17
+ }
18
+ >
19
+ Show toast
20
+ </button>
21
+ )
22
+ }
23
+
24
+ describe('Toast', () => {
25
+ it('shows a published toast in the viewport', async () => {
26
+ const user = userEvent.setup()
27
+
28
+ render(
29
+ <Toast.Provider>
30
+ <PublishButton />
31
+ <Toast.Viewport />
32
+ </Toast.Provider>,
33
+ )
34
+
35
+ await user.click(screen.getByRole('button', { name: 'Show toast' }))
36
+
37
+ expect(screen.getByRole('status')).toHaveTextContent('Saved')
38
+ expect(screen.getByRole('status')).toHaveTextContent('Your preferences were updated.')
39
+ })
40
+ })