aeon-ui-engine 1.0.0 → 1.0.1

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 (79) hide show
  1. package/COMPONENTS.md +7 -2
  2. package/INTEGRATION.md +14 -0
  3. package/README.md +8 -4
  4. package/apps/demo/og.config.mjs +8 -3
  5. package/apps/demo/public/og-aeon-ui-engine.png +0 -0
  6. package/apps/demo/public/og.svg +2 -2
  7. package/apps/demo/scripts/generate-og-png.mjs +4 -2
  8. package/apps/demo/src/App.tsx +2 -2
  9. package/apps/demo/src/aeonDiagrams.ts +17 -75
  10. package/apps/demo/src/components/DiagramsSection.tsx +8 -9
  11. package/apps/demo/src/components/HeroInstall.tsx +7 -4
  12. package/apps/demo/src/components/LandingFaq.tsx +1 -1
  13. package/apps/demo/src/components/LandingFooter.tsx +8 -0
  14. package/apps/demo/src/components/LandingHeader.tsx +4 -2
  15. package/apps/demo/src/components/LayersSection.tsx +6 -10
  16. package/apps/demo/src/components/MermaidDiagram.tsx +5 -3
  17. package/apps/demo/src/components/PhilosophySection.tsx +0 -2
  18. package/apps/demo/src/components/PlaygroundBlock.tsx +3 -8
  19. package/apps/demo/src/components/PlaygroundControlsDemo.tsx +63 -0
  20. package/apps/demo/src/components/PlaygroundSection.tsx +7 -91
  21. package/apps/demo/src/components/ThemeSwitcher.tsx +12 -9
  22. package/apps/demo/src/demo-layout.css +29 -0
  23. package/apps/demo/src/landing.css +136 -79
  24. package/apps/demo/src/nav.ts +3 -3
  25. package/apps/demo/src/site.ts +99 -228
  26. package/docs/MACHINES.md +6 -1
  27. package/package.json +1 -1
  28. package/packages/core/src/anatomy.ts +23 -0
  29. package/packages/panda/panda.config.ts +19 -1
  30. package/packages/panda/src/recipes/index.ts +5 -0
  31. package/packages/panda/src/recipes/pin-input.ts +29 -0
  32. package/packages/panda/src/recipes/progress.ts +44 -0
  33. package/packages/panda/src/recipes/radio-group.ts +51 -0
  34. package/packages/panda/src/recipes/select.ts +2 -0
  35. package/packages/panda/src/recipes/separator.ts +35 -0
  36. package/packages/panda/src/recipes/slider.ts +57 -0
  37. package/packages/panda/styled-system/recipes/aeon-pin-input.d.ts +28 -0
  38. package/packages/panda/styled-system/recipes/aeon-pin-input.mjs +36 -0
  39. package/packages/panda/styled-system/recipes/aeon-progress.d.ts +28 -0
  40. package/packages/panda/styled-system/recipes/aeon-progress.mjs +44 -0
  41. package/packages/panda/styled-system/recipes/aeon-radio-group.d.ts +28 -0
  42. package/packages/panda/styled-system/recipes/aeon-radio-group.mjs +48 -0
  43. package/packages/panda/styled-system/recipes/aeon-separator.d.ts +31 -0
  44. package/packages/panda/styled-system/recipes/aeon-separator.mjs +41 -0
  45. package/packages/panda/styled-system/recipes/aeon-slider.d.ts +28 -0
  46. package/packages/panda/styled-system/recipes/aeon-slider.mjs +48 -0
  47. package/packages/panda/styled-system/recipes/index.d.ts +5 -0
  48. package/packages/panda/styled-system/recipes/index.mjs +5 -0
  49. package/packages/panda/styled-system/styles.css +223 -46
  50. package/packages/panda/styled-system/types/conditions.d.ts +1 -1
  51. package/packages/primitives/src/index.ts +1 -0
  52. package/packages/primitives/src/machines/slider.ts +93 -0
  53. package/packages/react/src/checkbox/index.tsx +14 -0
  54. package/packages/react/src/floating/FloatingPositioner.tsx +59 -0
  55. package/packages/react/src/hooks/use-anchor-position.ts +33 -5
  56. package/packages/react/src/hooks/use-prefers-hover.ts +16 -0
  57. package/packages/react/src/index.ts +5 -0
  58. package/packages/react/src/menu/index.tsx +38 -11
  59. package/packages/react/src/pin-input/index.tsx +223 -0
  60. package/packages/react/src/popover/index.tsx +38 -11
  61. package/packages/react/src/progress/index.tsx +110 -0
  62. package/packages/react/src/radio-group/index.tsx +199 -0
  63. package/packages/react/src/select/index.tsx +51 -28
  64. package/packages/react/src/select/select.test.tsx +21 -0
  65. package/packages/react/src/separator/index.tsx +23 -0
  66. package/packages/react/src/slider/index.tsx +264 -0
  67. package/packages/react/src/toast/primitives.tsx +8 -1
  68. package/packages/react/src/toast/toast.test.tsx +40 -0
  69. package/packages/react/src/tooltip/index.tsx +124 -20
  70. package/packages/react/src/tooltip/tooltip.test.tsx +114 -0
  71. package/packages/site/README.md +1 -1
  72. package/packages/ui/src/index.ts +5 -0
  73. package/packages/ui/src/pin-input.tsx +16 -0
  74. package/packages/ui/src/progress.tsx +21 -0
  75. package/packages/ui/src/radio-group.tsx +24 -0
  76. package/packages/ui/src/separator.tsx +15 -0
  77. package/packages/ui/src/slider.tsx +24 -0
  78. package/apps/demo/public/og.png +0 -0
  79. package/apps/demo/src/components/SceneSection.tsx +0 -157
@@ -15,15 +15,19 @@ 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
20
  import { useOutsideClick } from '../hooks/use-outside-click.js'
20
21
  import { mergeProps } from '../utils/merge-props.js'
22
+ import type { AnchorPlacement } from '../hooks/use-anchor-position.js'
21
23
 
22
24
  interface MenuContextValue {
23
25
  open: boolean
24
26
  send: ReturnType<typeof useAeonMachine<typeof popoverMachine>>[1]
25
27
  triggerId: string
26
28
  menuId: string
29
+ triggerRef: React.RefObject<HTMLButtonElement | null>
30
+ positionerRef: React.RefObject<HTMLDivElement | null>
27
31
  }
28
32
 
29
33
  const MenuCtx = createContext<MenuContextValue | null>(null)
@@ -49,6 +53,8 @@ const Root = forwardRef<HTMLDivElement, MenuRootProps>(function MenuRoot(
49
53
  const [snapshot, send] = useAeonMachine(popoverMachine, { input: { open: defaultOpen } })
50
54
  const resolvedOpen = open ?? snapshot.context.open
51
55
  const rootRef = useRef<HTMLDivElement>(null)
56
+ const triggerRef = useRef<HTMLButtonElement>(null)
57
+ const positionerRef = useRef<HTMLDivElement>(null)
52
58
  const triggerId = useId()
53
59
  const menuId = useId()
54
60
 
@@ -61,10 +67,10 @@ const Root = forwardRef<HTMLDivElement, MenuRootProps>(function MenuRoot(
61
67
  }, [open, send])
62
68
 
63
69
  const close = useCallback(() => send({ type: 'CLOSE' }), [send])
64
- useOutsideClick([rootRef], resolvedOpen, close)
70
+ useOutsideClick([rootRef, positionerRef], resolvedOpen, close)
65
71
 
66
72
  const value = useMemo(
67
- () => ({ open: resolvedOpen, send, triggerId, menuId }),
73
+ () => ({ open: resolvedOpen, send, triggerId, menuId, triggerRef, positionerRef }),
68
74
  [resolvedOpen, send, triggerId, menuId],
69
75
  )
70
76
 
@@ -92,10 +98,14 @@ const Trigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonEle
92
98
  { onClick, ...rest },
93
99
  ref,
94
100
  ) {
95
- const { open, send, triggerId, menuId } = useMenuCtx()
101
+ const { open, send, triggerId, menuId, triggerRef } = useMenuCtx()
96
102
  return (
97
103
  <button
98
- ref={ref}
104
+ ref={(node) => {
105
+ triggerRef.current = node
106
+ if (typeof ref === 'function') ref(node)
107
+ else if (ref) ref.current = node
108
+ }}
99
109
  type="button"
100
110
  id={triggerId}
101
111
  aria-haspopup="menu"
@@ -115,17 +125,34 @@ const Trigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonEle
115
125
  )
116
126
  })
117
127
 
118
- const Positioner = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function MenuPositioner(
119
- props,
128
+ export interface MenuPositionerProps extends HTMLAttributes<HTMLDivElement> {
129
+ /** Mount menu in document.body (default true). */
130
+ portalled?: boolean
131
+ placement?: AnchorPlacement
132
+ children?: ReactNode
133
+ }
134
+
135
+ const Positioner = forwardRef<HTMLDivElement, MenuPositionerProps>(function MenuPositioner(
136
+ { portalled = true, placement = 'bottom-start', children, className, ...rest },
120
137
  ref,
121
138
  ) {
122
- const { open } = useMenuCtx()
123
- if (!open) return null
139
+ const { open, triggerRef, positionerRef } = useMenuCtx()
140
+
124
141
  return (
125
- <div
142
+ <FloatingPositioner
126
143
  ref={ref}
127
- {...mergeProps(partAttrs(menuAnatomy.scope, menuAnatomy.positioner, { state: 'open' }), props)}
128
- />
144
+ open={open}
145
+ scope={menuAnatomy.scope}
146
+ part={menuAnatomy.positioner}
147
+ triggerRef={triggerRef}
148
+ positionerRef={positionerRef}
149
+ portalled={portalled}
150
+ placement={placement}
151
+ className={className}
152
+ {...rest}
153
+ >
154
+ {children}
155
+ </FloatingPositioner>
129
156
  )
130
157
  })
131
158
 
@@ -0,0 +1,223 @@
1
+ import { partAttrs, partOnlyAttrs, pinInputAnatomy } from '@aeon-ui/core'
2
+ import {
3
+ createContext,
4
+ forwardRef,
5
+ useCallback,
6
+ useContext,
7
+ useEffect,
8
+ useMemo,
9
+ useRef,
10
+ useState,
11
+ type ChangeEvent,
12
+ type ClipboardEvent,
13
+ type HTMLAttributes,
14
+ type InputHTMLAttributes,
15
+ type KeyboardEvent,
16
+ type ReactNode,
17
+ } from 'react'
18
+ import { mergeProps } from '../utils/merge-props.js'
19
+
20
+ interface PinInputContextValue {
21
+ length: number
22
+ values: string[]
23
+ disabled: boolean
24
+ type: 'numeric' | 'alphanumeric'
25
+ setChar: (index: number, char: string) => void
26
+ focusInput: (index: number) => void
27
+ registerInput: (index: number, el: HTMLInputElement | null) => void
28
+ }
29
+
30
+ const PinInputCtx = createContext<PinInputContextValue | null>(null)
31
+
32
+ function usePinInputCtx() {
33
+ const ctx = useContext(PinInputCtx)
34
+ if (!ctx) throw new Error('PinInput parts must be used within PinInput.Root')
35
+ return ctx
36
+ }
37
+
38
+ function normalizePinValue(raw: string, length: number) {
39
+ return raw.replace(/\s/g, '').slice(0, length).split('')
40
+ }
41
+
42
+ export interface PinInputRootProps {
43
+ length?: number
44
+ value?: string
45
+ defaultValue?: string
46
+ disabled?: boolean
47
+ onValueChange?: (value: string) => void
48
+ onComplete?: (value: string) => void
49
+ type?: 'numeric' | 'alphanumeric'
50
+ children?: ReactNode
51
+ className?: string
52
+ }
53
+
54
+ const Root = forwardRef<HTMLDivElement, PinInputRootProps>(function PinInputRoot(
55
+ {
56
+ length = 4,
57
+ value,
58
+ defaultValue = '',
59
+ disabled = false,
60
+ onValueChange,
61
+ onComplete,
62
+ type = 'numeric',
63
+ children,
64
+ className,
65
+ ...rest
66
+ },
67
+ ref,
68
+ ) {
69
+ const [uncontrolled, setUncontrolled] = useState(() => normalizePinValue(defaultValue, length))
70
+ const inputRefs = useRef<Array<HTMLInputElement | null>>([])
71
+ const controlled = value !== undefined
72
+ const chars = controlled ? normalizePinValue(value, length) : uncontrolled
73
+ while (chars.length < length) chars.push('')
74
+
75
+ const resolved = chars.slice(0, length).join('')
76
+
77
+ useEffect(() => {
78
+ onValueChange?.(resolved)
79
+ if (resolved.length === length && !resolved.includes('')) onComplete?.(resolved)
80
+ }, [resolved, length, onValueChange, onComplete])
81
+
82
+ const setChars = useCallback(
83
+ (next: string[]) => {
84
+ const joined = next.slice(0, length).join('')
85
+ if (!controlled) setUncontrolled(next.slice(0, length))
86
+ onValueChange?.(joined)
87
+ if (joined.length === length && !joined.includes('')) onComplete?.(joined)
88
+ },
89
+ [controlled, length, onValueChange, onComplete],
90
+ )
91
+
92
+ const setChar = useCallback(
93
+ (index: number, char: string) => {
94
+ const next = [...chars.slice(0, length)]
95
+ while (next.length < length) next.push('')
96
+ next[index] = char
97
+ setChars(next)
98
+ },
99
+ [chars, length, setChars],
100
+ )
101
+
102
+ const focusInput = useCallback((index: number) => {
103
+ const el = inputRefs.current[index]
104
+ el?.focus()
105
+ el?.select()
106
+ }, [])
107
+
108
+ const registerInput = useCallback((index: number, el: HTMLInputElement | null) => {
109
+ inputRefs.current[index] = el
110
+ }, [])
111
+
112
+ const ctx = useMemo(
113
+ () => ({ length, values: chars.slice(0, length), disabled, type, setChar, focusInput, registerInput }),
114
+ [length, chars, disabled, type, setChar, focusInput, registerInput],
115
+ )
116
+
117
+ return (
118
+ <PinInputCtx.Provider value={ctx}>
119
+ <div
120
+ ref={ref}
121
+ className={className}
122
+ {...mergeProps(
123
+ partAttrs(pinInputAnatomy.scope, pinInputAnatomy.root, {
124
+ state: resolved.length === length && !resolved.includes('') ? 'complete' : 'idle',
125
+ disabled,
126
+ }),
127
+ rest as HTMLAttributes<HTMLDivElement>,
128
+ )}
129
+ >
130
+ {children}
131
+ </div>
132
+ </PinInputCtx.Provider>
133
+ )
134
+ })
135
+
136
+ export interface PinInputInputProps
137
+ extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> {
138
+ index: number
139
+ onChange?: InputHTMLAttributes<HTMLInputElement>['onChange']
140
+ }
141
+
142
+ const Input = forwardRef<HTMLInputElement, PinInputInputProps>(function PinInputInput(
143
+ { index, onKeyDown, onChange, onPaste, ...rest },
144
+ ref,
145
+ ) {
146
+ const { length, values, disabled, type, setChar, focusInput, registerInput } = usePinInputCtx()
147
+ const value = values[index] ?? ''
148
+ const pattern = type === 'numeric' ? /^[0-9]$/ : /^[0-9a-zA-Z]$/
149
+
150
+ const acceptChar = (raw: string) => {
151
+ if (!raw) return ''
152
+ const char = raw.slice(-1)
153
+ return pattern.test(char) ? char : ''
154
+ }
155
+
156
+ return (
157
+ <input
158
+ ref={(node) => {
159
+ registerInput(index, node)
160
+ if (typeof ref === 'function') ref(node)
161
+ else if (ref) ref.current = node
162
+ }}
163
+ type="text"
164
+ inputMode={type === 'numeric' ? 'numeric' : 'text'}
165
+ autoComplete={rest.autoComplete ?? 'one-time-code'}
166
+ maxLength={1}
167
+ value={value}
168
+ disabled={disabled}
169
+ aria-label={`Character ${index + 1} of ${length}`}
170
+ {...mergeProps(partOnlyAttrs(pinInputAnatomy.input, { state: value ? 'filled' : 'empty' }), rest, {
171
+ onChange: (e: ChangeEvent<HTMLInputElement>) => {
172
+ onChange?.(e)
173
+ const char = acceptChar(e.target.value)
174
+ setChar(index, char)
175
+ if (char && index < length - 1) focusInput(index + 1)
176
+ },
177
+ onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
178
+ onKeyDown?.(e)
179
+ if (e.key === 'Backspace' && !value && index > 0) {
180
+ e.preventDefault()
181
+ setChar(index - 1, '')
182
+ focusInput(index - 1)
183
+ }
184
+ if (e.key === 'ArrowLeft' && index > 0) {
185
+ e.preventDefault()
186
+ focusInput(index - 1)
187
+ }
188
+ if (e.key === 'ArrowRight' && index < length - 1) {
189
+ e.preventDefault()
190
+ focusInput(index + 1)
191
+ }
192
+ },
193
+ onPaste: (e: ClipboardEvent<HTMLInputElement>) => {
194
+ onPaste?.(e)
195
+ e.preventDefault()
196
+ const text = e.clipboardData.getData('text').replace(/\s/g, '').slice(0, length - index)
197
+ if (!text) return
198
+ for (let i = 0; i < text.length && index + i < length; i++) {
199
+ const char = acceptChar(text[i]!)
200
+ if (char) setChar(index + i, char)
201
+ }
202
+ focusInput(Math.min(index + text.length, length - 1))
203
+ },
204
+ })}
205
+ />
206
+ )
207
+ })
208
+
209
+ /** Convenience row — renders `length` inputs. */
210
+ const Group = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement> & { inputClassName?: string }>(
211
+ function PinInputGroup({ inputClassName, className, ...rest }, ref) {
212
+ const { length } = usePinInputCtx()
213
+ return (
214
+ <div ref={ref} className={className} {...rest}>
215
+ {Array.from({ length }, (_, index) => (
216
+ <Input key={index} index={index} className={inputClassName} />
217
+ ))}
218
+ </div>
219
+ )
220
+ },
221
+ )
222
+
223
+ export const PinInput = { Root, Input, Group }
@@ -15,15 +15,19 @@ 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
20
  import { useOutsideClick } from '../hooks/use-outside-click.js'
20
21
  import { mergeProps } from '../utils/merge-props.js'
22
+ import type { AnchorPlacement } from '../hooks/use-anchor-position.js'
21
23
 
22
24
  interface PopoverContextValue {
23
25
  open: boolean
24
26
  send: ReturnType<typeof useAeonMachine<typeof popoverMachine>>[1]
25
27
  triggerId: string
26
28
  contentId: string
29
+ triggerRef: React.RefObject<HTMLButtonElement | null>
30
+ positionerRef: React.RefObject<HTMLDivElement | null>
27
31
  }
28
32
 
29
33
  const PopoverCtx = createContext<PopoverContextValue | null>(null)
@@ -51,6 +55,8 @@ const Root = forwardRef<HTMLDivElement, PopoverRootProps>(function PopoverRoot(
51
55
  })
52
56
  const resolvedOpen = open ?? snapshot.context.open
53
57
  const rootRef = useRef<HTMLDivElement>(null)
58
+ const triggerRef = useRef<HTMLButtonElement>(null)
59
+ const positionerRef = useRef<HTMLDivElement>(null)
54
60
  const triggerId = useId()
55
61
  const contentId = useId()
56
62
 
@@ -63,10 +69,10 @@ const Root = forwardRef<HTMLDivElement, PopoverRootProps>(function PopoverRoot(
63
69
  }, [open, send])
64
70
 
65
71
  const close = useCallback(() => send({ type: 'CLOSE' }), [send])
66
- useOutsideClick([rootRef], resolvedOpen, close)
72
+ useOutsideClick([rootRef, positionerRef], resolvedOpen, close)
67
73
 
68
74
  const value = useMemo(
69
- () => ({ open: resolvedOpen, send, triggerId, contentId }),
75
+ () => ({ open: resolvedOpen, send, triggerId, contentId, triggerRef, positionerRef }),
70
76
  [resolvedOpen, send, triggerId, contentId],
71
77
  )
72
78
 
@@ -96,10 +102,14 @@ const Trigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonEle
96
102
  { onClick, ...rest },
97
103
  ref,
98
104
  ) {
99
- const { open, send, triggerId, contentId } = usePopoverCtx()
105
+ const { open, send, triggerId, contentId, triggerRef } = usePopoverCtx()
100
106
  return (
101
107
  <button
102
- ref={ref}
108
+ ref={(node) => {
109
+ triggerRef.current = node
110
+ if (typeof ref === 'function') ref(node)
111
+ else if (ref) ref.current = node
112
+ }}
103
113
  type="button"
104
114
  id={triggerId}
105
115
  aria-expanded={open}
@@ -118,17 +128,34 @@ const Trigger = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonEle
118
128
  )
119
129
  })
120
130
 
121
- const Positioner = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function PopoverPositioner(
122
- props,
131
+ export interface PopoverPositionerProps extends HTMLAttributes<HTMLDivElement> {
132
+ /** Mount panel in document.body (default true). */
133
+ portalled?: boolean
134
+ placement?: AnchorPlacement
135
+ children?: ReactNode
136
+ }
137
+
138
+ const Positioner = forwardRef<HTMLDivElement, PopoverPositionerProps>(function PopoverPositioner(
139
+ { portalled = true, placement = 'bottom-start', children, className, ...rest },
123
140
  ref,
124
141
  ) {
125
- const { open } = usePopoverCtx()
126
- if (!open) return null
142
+ const { open, triggerRef, positionerRef } = usePopoverCtx()
143
+
127
144
  return (
128
- <div
145
+ <FloatingPositioner
129
146
  ref={ref}
130
- {...mergeProps(partAttrs(popoverAnatomy.scope, popoverAnatomy.positioner, { state: 'open' }), props)}
131
- />
147
+ open={open}
148
+ scope={popoverAnatomy.scope}
149
+ part={popoverAnatomy.positioner}
150
+ triggerRef={triggerRef}
151
+ positionerRef={positionerRef}
152
+ portalled={portalled}
153
+ placement={placement}
154
+ className={className}
155
+ {...rest}
156
+ >
157
+ {children}
158
+ </FloatingPositioner>
132
159
  )
133
160
  })
134
161
 
@@ -0,0 +1,110 @@
1
+ import { partAttrs, partOnlyAttrs, progressAnatomy } from '@aeon-ui/core'
2
+ import {
3
+ createContext,
4
+ forwardRef,
5
+ useContext,
6
+ useMemo,
7
+ type HTMLAttributes,
8
+ type ReactNode,
9
+ } from 'react'
10
+ import { mergeProps } from '../utils/merge-props.js'
11
+
12
+ type ProgressState = 'idle' | 'loading' | 'complete'
13
+
14
+ interface ProgressContextValue {
15
+ value: number
16
+ max: number
17
+ indeterminate: boolean
18
+ state: ProgressState
19
+ }
20
+
21
+ const ProgressCtx = createContext<ProgressContextValue | null>(null)
22
+
23
+ function useProgressCtx() {
24
+ const ctx = useContext(ProgressCtx)
25
+ if (!ctx) throw new Error('Progress parts must be used within Progress.Root')
26
+ return ctx
27
+ }
28
+
29
+ function resolveProgressState(value: number, max: number, indeterminate: boolean): ProgressState {
30
+ if (indeterminate) return 'loading'
31
+ if (value >= max) return 'complete'
32
+ return 'idle'
33
+ }
34
+
35
+ export interface ProgressRootProps extends HTMLAttributes<HTMLDivElement> {
36
+ value?: number
37
+ max?: number
38
+ indeterminate?: boolean
39
+ children?: ReactNode
40
+ className?: string
41
+ }
42
+
43
+ const Root = forwardRef<HTMLDivElement, ProgressRootProps>(function ProgressRoot(
44
+ { value = 0, max = 100, indeterminate = false, children, className, ...rest },
45
+ ref,
46
+ ) {
47
+ const clampedValue = Math.min(Math.max(value, 0), max)
48
+ const state = resolveProgressState(clampedValue, max, indeterminate)
49
+ const ctx = useMemo(
50
+ () => ({ value: clampedValue, max, indeterminate, state }),
51
+ [clampedValue, max, indeterminate, state],
52
+ )
53
+
54
+ return (
55
+ <ProgressCtx.Provider value={ctx}>
56
+ <div
57
+ ref={ref}
58
+ className={className}
59
+ role="progressbar"
60
+ aria-valuemin={0}
61
+ aria-valuemax={max}
62
+ aria-valuenow={indeterminate ? undefined : clampedValue}
63
+ aria-busy={indeterminate || undefined}
64
+ {...mergeProps(
65
+ partAttrs(progressAnatomy.scope, progressAnatomy.root, { state }),
66
+ rest,
67
+ )}
68
+ >
69
+ {children}
70
+ </div>
71
+ </ProgressCtx.Provider>
72
+ )
73
+ })
74
+
75
+ const Track = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function ProgressTrack(
76
+ props,
77
+ ref,
78
+ ) {
79
+ return (
80
+ <div ref={ref} {...mergeProps(partOnlyAttrs(progressAnatomy.track), props)} />
81
+ )
82
+ })
83
+
84
+ const Range = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function ProgressRange(
85
+ { style, ...rest },
86
+ ref,
87
+ ) {
88
+ const { value, max, indeterminate } = useProgressCtx()
89
+ const width = indeterminate ? undefined : `${(value / max) * 100}%`
90
+
91
+ return (
92
+ <div
93
+ ref={ref}
94
+ {...mergeProps(partOnlyAttrs(progressAnatomy.range), rest, {
95
+ style: { ...style, width },
96
+ })}
97
+ />
98
+ )
99
+ })
100
+
101
+ const Label = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(function ProgressLabel(
102
+ props,
103
+ ref,
104
+ ) {
105
+ return (
106
+ <span ref={ref} {...mergeProps(partOnlyAttrs(progressAnatomy.label), props)} />
107
+ )
108
+ })
109
+
110
+ export const Progress = { Root, Track, Range, Label }