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.
- package/.changeset/config.json +2 -1
- package/CHANGELOG.md +38 -0
- package/COMPONENTS.md +7 -2
- package/INTEGRATION.md +14 -0
- package/PUBLISHING.md +20 -0
- package/README.md +8 -4
- package/apps/demo/og.config.mjs +8 -3
- package/apps/demo/public/og-aeon-ui-engine.png +0 -0
- package/apps/demo/public/og.svg +2 -2
- package/apps/demo/scripts/generate-og-png.mjs +4 -2
- package/apps/demo/src/App.tsx +2 -2
- package/apps/demo/src/aeonDiagrams.ts +17 -75
- package/apps/demo/src/components/DiagramsSection.tsx +8 -9
- package/apps/demo/src/components/HeroInstall.tsx +7 -4
- package/apps/demo/src/components/LandingFaq.tsx +1 -1
- package/apps/demo/src/components/LandingFooter.tsx +8 -0
- package/apps/demo/src/components/LandingHeader.tsx +4 -2
- package/apps/demo/src/components/LayersSection.tsx +6 -10
- package/apps/demo/src/components/MermaidDiagram.tsx +5 -3
- package/apps/demo/src/components/PhilosophySection.tsx +0 -2
- package/apps/demo/src/components/PlaygroundBlock.tsx +3 -8
- package/apps/demo/src/components/PlaygroundControlsDemo.tsx +63 -0
- package/apps/demo/src/components/PlaygroundSection.tsx +7 -91
- package/apps/demo/src/components/ThemeSwitcher.tsx +13 -10
- package/apps/demo/src/demo-layout.css +29 -0
- package/apps/demo/src/landing.css +138 -89
- package/apps/demo/src/nav.ts +3 -3
- package/apps/demo/src/site.ts +99 -228
- package/docs/MACHINES.md +6 -1
- package/package.json +1 -1
- package/packages/core/src/anatomy.ts +23 -0
- package/packages/panda/panda.config.ts +19 -1
- package/packages/panda/src/recipes/button.ts +4 -4
- package/packages/panda/src/recipes/combobox.ts +1 -1
- package/packages/panda/src/recipes/index.ts +5 -0
- package/packages/panda/src/recipes/pin-input.ts +29 -0
- package/packages/panda/src/recipes/progress.ts +44 -0
- package/packages/panda/src/recipes/radio-group.ts +51 -0
- package/packages/panda/src/recipes/select.ts +3 -1
- package/packages/panda/src/recipes/separator.ts +35 -0
- package/packages/panda/src/recipes/slider.ts +57 -0
- package/packages/panda/src/theme/tokens.ts +5 -0
- package/packages/panda/styled-system/recipes/aeon-pin-input.d.ts +28 -0
- package/packages/panda/styled-system/recipes/aeon-pin-input.mjs +36 -0
- package/packages/panda/styled-system/recipes/aeon-progress.d.ts +28 -0
- package/packages/panda/styled-system/recipes/aeon-progress.mjs +44 -0
- package/packages/panda/styled-system/recipes/aeon-radio-group.d.ts +28 -0
- package/packages/panda/styled-system/recipes/aeon-radio-group.mjs +48 -0
- package/packages/panda/styled-system/recipes/aeon-separator.d.ts +31 -0
- package/packages/panda/styled-system/recipes/aeon-separator.mjs +41 -0
- package/packages/panda/styled-system/recipes/aeon-slider.d.ts +28 -0
- package/packages/panda/styled-system/recipes/aeon-slider.mjs +48 -0
- package/packages/panda/styled-system/recipes/index.d.ts +5 -0
- package/packages/panda/styled-system/recipes/index.mjs +5 -0
- package/packages/panda/styled-system/styles.css +233 -52
- package/packages/panda/styled-system/tokens/index.mjs +32 -0
- package/packages/panda/styled-system/tokens/tokens.d.ts +2 -2
- package/packages/panda/styled-system/types/conditions.d.ts +1 -1
- package/packages/primitives/src/index.ts +1 -0
- package/packages/primitives/src/machines/slider.ts +93 -0
- package/packages/react/src/checkbox/index.tsx +14 -0
- package/packages/react/src/floating/FloatingPositioner.tsx +59 -0
- package/packages/react/src/hooks/use-anchor-position.ts +33 -5
- package/packages/react/src/hooks/use-prefers-hover.ts +16 -0
- package/packages/react/src/index.ts +5 -0
- package/packages/react/src/menu/index.tsx +38 -11
- package/packages/react/src/pin-input/index.tsx +223 -0
- package/packages/react/src/popover/index.tsx +38 -11
- package/packages/react/src/progress/index.tsx +110 -0
- package/packages/react/src/radio-group/index.tsx +199 -0
- package/packages/react/src/select/index.tsx +51 -28
- package/packages/react/src/select/select.test.tsx +21 -0
- package/packages/react/src/separator/index.tsx +23 -0
- package/packages/react/src/slider/index.tsx +264 -0
- package/packages/react/src/toast/primitives.tsx +8 -1
- package/packages/react/src/toast/toast.test.tsx +40 -0
- package/packages/react/src/tooltip/index.tsx +124 -20
- package/packages/react/src/tooltip/tooltip.test.tsx +114 -0
- package/packages/site/README.md +1 -1
- package/packages/ui/src/index.ts +5 -0
- package/packages/ui/src/pin-input.tsx +16 -0
- package/packages/ui/src/progress.tsx +21 -0
- package/packages/ui/src/radio-group.tsx +24 -0
- package/packages/ui/src/select.tsx +3 -4
- package/packages/ui/src/separator.tsx +15 -0
- package/packages/ui/src/slider.tsx +24 -0
- package/apps/demo/public/og.png +0 -0
- 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
|
-
|
|
273
|
-
<
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|