aeon-ui-engine 1.3.5 → 1.5.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 (104) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CONSUMER.md +67 -27
  3. package/INTEGRATION.md +1 -1
  4. package/README.md +17 -8
  5. package/package.json +4 -1
  6. package/packages/cli/README.md +13 -1
  7. package/packages/cli/dist/bin.js +50 -6
  8. package/packages/cli/dist/chunk-JMNRJCWF.js +301 -0
  9. package/packages/cli/dist/index.d.ts +31 -1
  10. package/packages/cli/dist/index.js +7 -3
  11. package/packages/cli/src/bin.ts +50 -4
  12. package/packages/cli/src/index.ts +2 -0
  13. package/packages/cli/src/init.ts +10 -4
  14. package/packages/cli/src/link.ts +61 -0
  15. package/packages/cli/src/migrate.ts +170 -0
  16. package/packages/core/dist/index.d.ts +35 -7
  17. package/packages/core/dist/index.js +102 -2
  18. package/packages/core/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  19. package/packages/core/src/anatomy.ts +39 -3
  20. package/packages/core/src/identicon.test.ts +39 -0
  21. package/packages/core/src/identicon.ts +79 -0
  22. package/packages/core/src/index.ts +1 -0
  23. package/packages/panda/panda.config.ts +4 -0
  24. package/packages/panda/src/recipes/composer.ts +58 -4
  25. package/packages/panda/src/recipes/file-upload.ts +100 -0
  26. package/packages/panda/src/recipes/index.ts +2 -0
  27. package/packages/panda/src/recipes/range-slider.ts +59 -0
  28. package/packages/panda/src/recipes/thread.ts +77 -2
  29. package/packages/panda/styled-system/recipes/aeon-composer.d.ts +1 -1
  30. package/packages/panda/styled-system/recipes/aeon-composer.mjs +12 -0
  31. package/packages/panda/styled-system/recipes/aeon-file-upload.d.ts +31 -0
  32. package/packages/panda/styled-system/recipes/aeon-file-upload.mjs +61 -0
  33. package/packages/panda/styled-system/recipes/aeon-range-slider.d.ts +31 -0
  34. package/packages/panda/styled-system/recipes/aeon-range-slider.mjs +49 -0
  35. package/packages/panda/styled-system/recipes/aeon-thread.d.ts +1 -1
  36. package/packages/panda/styled-system/recipes/aeon-thread.mjs +20 -0
  37. package/packages/panda/styled-system/recipes/index.d.ts +2 -0
  38. package/packages/panda/styled-system/recipes/index.mjs +2 -0
  39. package/packages/panda/styled-system/styles.css +292 -0
  40. package/packages/primitives/dist/chunk-CV4UXXZF.js +44 -0
  41. package/packages/primitives/dist/chunk-IZKQSCYU.js +164 -0
  42. package/packages/primitives/dist/{chunk-PB3LFHXU.js → chunk-VUPWYBGU.js} +14 -0
  43. package/packages/primitives/dist/index.d.ts +2 -0
  44. package/packages/primitives/dist/index.js +23 -7
  45. package/packages/primitives/dist/machines/file-upload.d.ts +39 -0
  46. package/packages/primitives/dist/machines/file-upload.js +8 -0
  47. package/packages/primitives/dist/machines/range-slider.d.ts +129 -0
  48. package/packages/primitives/dist/machines/range-slider.js +12 -0
  49. package/packages/primitives/dist/machines/slider.d.ts +22 -0
  50. package/packages/primitives/dist/machines/slider.js +1 -1
  51. package/packages/primitives/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  52. package/packages/primitives/src/__tests__/file-upload.test.ts +25 -0
  53. package/packages/primitives/src/__tests__/range-slider.test.ts +39 -0
  54. package/packages/primitives/src/index.ts +2 -0
  55. package/packages/primitives/src/machines/file-upload.ts +55 -0
  56. package/packages/primitives/src/machines/range-slider.ts +209 -0
  57. package/packages/primitives/src/machines/slider.ts +17 -0
  58. package/packages/react/dist/index.d.ts +132 -26
  59. package/packages/react/dist/index.js +1822 -1029
  60. package/packages/react/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  61. package/packages/react/src/button/index.tsx +50 -15
  62. package/packages/react/src/composer/index.tsx +58 -3
  63. package/packages/react/src/file-upload/index.tsx +322 -0
  64. package/packages/react/src/floating/FloatingPositioner.tsx +8 -0
  65. package/packages/react/src/hooks/use-anchor-position.ts +88 -17
  66. package/packages/react/src/index.ts +3 -0
  67. package/packages/react/src/menu/index.tsx +37 -3
  68. package/packages/react/src/popover/index.tsx +7 -1
  69. package/packages/react/src/prompt/index.tsx +45 -0
  70. package/packages/react/src/range-slider/index.tsx +322 -0
  71. package/packages/react/src/slider/index.tsx +54 -14
  72. package/packages/react/src/thread/index.tsx +74 -3
  73. package/packages/react/src/tooltip/index.tsx +7 -1
  74. package/packages/react/src/utils/slot.tsx +47 -0
  75. package/packages/schemas/dist/index.d.ts +4 -0
  76. package/packages/schemas/dist/index.js +67 -1
  77. package/packages/schemas/json/button.json +6 -0
  78. package/packages/schemas/json/catalog.json +333 -0
  79. package/packages/schemas/json/fileUpload.json +168 -0
  80. package/packages/schemas/json/rangeSlider.json +157 -0
  81. package/packages/schemas/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  82. package/packages/schemas/prompts/cursorrules.md +3 -1
  83. package/packages/schemas/prompts/statecharts.md +1 -1
  84. package/packages/schemas/registry/catalog.json +18 -0
  85. package/packages/schemas/registry/components/button.json +6 -0
  86. package/packages/schemas/registry/components/fileUpload.json +168 -0
  87. package/packages/schemas/registry/components/rangeSlider.json +157 -0
  88. package/packages/schemas/registry/machines/fileUpload.json +182 -0
  89. package/packages/schemas/registry/machines/rangeSlider.json +201 -0
  90. package/packages/schemas/scripts/emit.ts +4 -0
  91. package/packages/schemas/src/components.ts +70 -0
  92. package/packages/schemas/src/prompts.ts +3 -1
  93. package/packages/schemas/tsconfig.json +1 -1
  94. package/packages/ui/dist/index.d.ts +100 -46
  95. package/packages/ui/dist/index.js +463 -341
  96. package/packages/ui/src/composer.tsx +37 -11
  97. package/packages/ui/src/file-upload.tsx +36 -0
  98. package/packages/ui/src/index.ts +2 -0
  99. package/packages/ui/src/prompt.tsx +3 -0
  100. package/packages/ui/src/range-slider.tsx +24 -0
  101. package/packages/ui/src/thread.tsx +40 -0
  102. package/scripts/link-scoped-packages.mjs +206 -0
  103. package/scripts/vite-aliases.mjs +6 -8
  104. package/packages/cli/dist/chunk-IW5NT2Z2.js +0 -103
@@ -1 +1 @@
1
- {"version":"3.2.4","results":[[":src/select/select.test.tsx",{"duration":226.72845800000005,"failed":false}],[":src/dialog/dialog.test.tsx",{"duration":181.9869580000002,"failed":false}],[":src/avatar/avatar.test.tsx",{"duration":144.93595800000003,"failed":false}],[":src/tooltip/tooltip.test.tsx",{"duration":226.42358300000024,"failed":false}],[":src/toast/toast.test.tsx",{"duration":126.48416699999984,"failed":false}]]}
1
+ {"version":"3.2.4","results":[[":src/select/select.test.tsx",{"duration":374.59737499999983,"failed":false}],[":src/dialog/dialog.test.tsx",{"duration":302.22662500000024,"failed":false}],[":src/avatar/avatar.test.tsx",{"duration":222.467666,"failed":false}],[":src/tooltip/tooltip.test.tsx",{"duration":394.93274999999994,"failed":false}],[":src/toast/toast.test.tsx",{"duration":290.9687079999999,"failed":false}]]}
@@ -1,6 +1,12 @@
1
1
  import { buttonAnatomy, partAttrs } from '@aeon-ui/core'
2
- import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from 'react'
2
+ import {
3
+ forwardRef,
4
+ type ButtonHTMLAttributes,
5
+ type HTMLAttributes,
6
+ type ReactNode,
7
+ } from 'react'
3
8
  import { mergeProps } from '../utils/merge-props.js'
9
+ import { Slot } from '../utils/slot.js'
4
10
 
5
11
  /** Action button lifecycle — see STATES.md and `buttonLifecycleMachine`. */
6
12
  export type ButtonStatus = 'idle' | 'pending' | 'success' | 'failure' | 'disabled'
@@ -10,34 +16,63 @@ export interface ButtonRootProps extends ButtonHTMLAttributes<HTMLButtonElement>
10
16
  size?: 'xs' | 'sm' | 'md' | 'lg'
11
17
  /** Machine-aligned lifecycle; `disabled` prop overrides to `disabled`. */
12
18
  status?: ButtonStatus
19
+ /**
20
+ * Merge button attrs onto a single child (e.g. Next.js `<Link>` / `<a>`).
21
+ * Child must be one React element.
22
+ */
23
+ asChild?: boolean
13
24
  children?: ReactNode
14
25
  }
15
26
 
16
27
  const Root = forwardRef<HTMLButtonElement, ButtonRootProps>(function ButtonRoot(
17
- { variant = 'solid', size = 'md', disabled, status = 'idle', children, className, ...rest },
28
+ {
29
+ variant = 'solid',
30
+ size = 'md',
31
+ disabled,
32
+ status = 'idle',
33
+ asChild = false,
34
+ children,
35
+ className,
36
+ type,
37
+ ...rest
38
+ },
18
39
  ref,
19
40
  ) {
20
41
  const resolvedStatus: ButtonStatus = disabled ? 'disabled' : status
21
42
  const isBusy = resolvedStatus === 'pending'
22
43
  // Success is terminal until RESET — keep data-aeon-state="success" but block input.
23
44
  const isLocked = resolvedStatus === 'success'
45
+ const isDisabled = Boolean(disabled || isBusy || isLocked || resolvedStatus === 'disabled')
46
+
47
+ const aeonProps = {
48
+ className,
49
+ 'data-aeon-variant': variant,
50
+ 'data-aeon-size': size,
51
+ 'aria-busy': isBusy || undefined,
52
+ 'aria-disabled': asChild && isDisabled ? true : undefined,
53
+ ...mergeProps(
54
+ partAttrs(buttonAnatomy.scope, buttonAnatomy.root, {
55
+ state: resolvedStatus,
56
+ disabled: isDisabled,
57
+ }),
58
+ rest,
59
+ ),
60
+ }
61
+
62
+ if (asChild) {
63
+ return (
64
+ <Slot ref={ref as never} {...aeonProps}>
65
+ {children}
66
+ </Slot>
67
+ )
68
+ }
24
69
 
25
70
  return (
26
71
  <button
27
72
  ref={ref}
28
- type="button"
29
- disabled={disabled || isBusy || isLocked || resolvedStatus === 'disabled'}
30
- aria-busy={isBusy || undefined}
31
- className={className}
32
- data-aeon-variant={variant}
33
- data-aeon-size={size}
34
- {...mergeProps(
35
- partAttrs(buttonAnatomy.scope, buttonAnatomy.root, {
36
- state: resolvedStatus,
37
- disabled: Boolean(disabled || resolvedStatus === 'disabled' || isLocked),
38
- }),
39
- rest,
40
- )}
73
+ type={type ?? 'button'}
74
+ disabled={isDisabled}
75
+ {...aeonProps}
41
76
  >
42
77
  {children}
43
78
  </button>
@@ -1,6 +1,7 @@
1
1
  import { composerAnatomy, partAttrs, scopeAttrs } from '@aeon-ui/core'
2
2
  import {
3
3
  forwardRef,
4
+ type ButtonHTMLAttributes,
4
5
  type FormHTMLAttributes,
5
6
  type HTMLAttributes,
6
7
  type ReactNode,
@@ -8,7 +9,21 @@ import {
8
9
  } from 'react'
9
10
  import { mergeProps } from '../utils/merge-props.js'
10
11
 
11
- export type ComposerState = 'idle' | 'sending' | 'disabled' | 'error'
12
+ /**
13
+ * Composer root states.
14
+ * - chat: plain message compose
15
+ * - command: local slash input (BRC-218) — never from received text
16
+ * - lookup: non-confirming verbs (/whois, /help)
17
+ * - idle | sending | disabled | error: transport chrome
18
+ */
19
+ export type ComposerState =
20
+ | 'idle'
21
+ | 'sending'
22
+ | 'disabled'
23
+ | 'error'
24
+ | 'chat'
25
+ | 'command'
26
+ | 'lookup'
12
27
 
13
28
  export interface ComposerRootProps extends FormHTMLAttributes<HTMLFormElement> {
14
29
  state?: ComposerState
@@ -16,8 +31,8 @@ export interface ComposerRootProps extends FormHTMLAttributes<HTMLFormElement> {
16
31
  }
17
32
 
18
33
  /**
19
- * Composer — bottom compose chrome for threads (input + send).
20
- * Root state: idle | sending | disabled | error.
34
+ * Composer — bottom compose chrome for threads (input + send + command palette).
35
+ * Only local user input is command-eligible (BRC-218 §2.4).
21
36
  */
22
37
  const Root = forwardRef<HTMLFormElement, ComposerRootProps>(function ComposerRoot(
23
38
  { state = 'idle', children, ...rest },
@@ -67,9 +82,49 @@ const Send = forwardRef<HTMLSpanElement, HTMLAttributes<HTMLSpanElement>>(
67
82
  },
68
83
  )
69
84
 
85
+ const Toolbar = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
86
+ function ComposerToolbar(props, ref) {
87
+ return (
88
+ <div
89
+ ref={ref}
90
+ role="toolbar"
91
+ {...mergeProps(partAttrs(composerAnatomy.scope, composerAnatomy.toolbar), props)}
92
+ />
93
+ )
94
+ },
95
+ )
96
+
97
+ const Suggestions = forwardRef<HTMLUListElement, HTMLAttributes<HTMLUListElement>>(
98
+ function ComposerSuggestions(props, ref) {
99
+ return (
100
+ <ul
101
+ ref={ref}
102
+ role="listbox"
103
+ {...mergeProps(partAttrs(composerAnatomy.scope, composerAnatomy.suggestions), props)}
104
+ />
105
+ )
106
+ },
107
+ )
108
+
109
+ const Suggestion = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
110
+ function ComposerSuggestion(props, ref) {
111
+ return (
112
+ <button
113
+ ref={ref}
114
+ type="button"
115
+ role="option"
116
+ {...mergeProps(partAttrs(composerAnatomy.scope, composerAnatomy.suggestion), props)}
117
+ />
118
+ )
119
+ },
120
+ )
121
+
70
122
  export const Composer = {
71
123
  Root,
72
124
  Input,
73
125
  Actions,
74
126
  Send,
127
+ Toolbar,
128
+ Suggestions,
129
+ Suggestion,
75
130
  }
@@ -0,0 +1,322 @@
1
+ import { fileUploadAnatomy, partAttrs, partOnlyAttrs } from '@aeon-ui/core'
2
+ import { fileUploadMachine, getFileUploadPhase } from '@aeon-ui/primitives'
3
+ import {
4
+ createContext,
5
+ forwardRef,
6
+ useCallback,
7
+ useContext,
8
+ useId,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ type ChangeEvent,
13
+ type DragEvent,
14
+ type HTMLAttributes,
15
+ type InputHTMLAttributes,
16
+ type MouseEvent,
17
+ type ReactNode,
18
+ } from 'react'
19
+ import { useAeonMachine } from '../hooks/use-aeon-machine.js'
20
+ import { mergeProps } from '../utils/merge-props.js'
21
+
22
+ function isFileTypeAccepted(file: File, acceptString: string) {
23
+ if (!acceptString || acceptString === '*') return true
24
+ const acceptedTypes = acceptString.split(',').map((type) => type.trim()).filter(Boolean)
25
+ if (acceptedTypes.length === 0) return true
26
+ return acceptedTypes.some((acceptedType) => {
27
+ if (acceptedType.startsWith('.')) {
28
+ return file.name.toLowerCase().endsWith(acceptedType.toLowerCase())
29
+ }
30
+ if (acceptedType.includes('*')) {
31
+ const baseType = acceptedType.split('/')[0]
32
+ return file.type.startsWith(`${baseType}/`)
33
+ }
34
+ return file.type === acceptedType
35
+ })
36
+ }
37
+
38
+ interface FileUploadContextValue {
39
+ phase: 'idle' | 'dragging' | 'rejected'
40
+ accept: string
41
+ disabled: boolean
42
+ file: File | null
43
+ inputId: string
44
+ inputRef: React.RefObject<HTMLInputElement | null>
45
+ send: ReturnType<typeof useAeonMachine<typeof fileUploadMachine>>[1]
46
+ selectFile: (file: File | null) => void
47
+ clear: () => void
48
+ openPicker: () => void
49
+ }
50
+
51
+ const FileUploadCtx = createContext<FileUploadContextValue | null>(null)
52
+
53
+ function useFileUploadCtx() {
54
+ const ctx = useContext(FileUploadCtx)
55
+ if (!ctx) throw new Error('FileUpload parts must be used within FileUpload.Root')
56
+ return ctx
57
+ }
58
+
59
+ export interface FileUploadRootProps {
60
+ accept?: string
61
+ disabled?: boolean
62
+ /** Controlled selected file (bytes stay outside the machine). */
63
+ file?: File | null
64
+ defaultFile?: File | null
65
+ onFileSelect?: (file: File | null) => void
66
+ children?: ReactNode
67
+ className?: string
68
+ }
69
+
70
+ const Root = forwardRef<HTMLDivElement, FileUploadRootProps>(function FileUploadRoot(
71
+ {
72
+ accept = '*',
73
+ disabled = false,
74
+ file: fileProp,
75
+ defaultFile = null,
76
+ onFileSelect,
77
+ children,
78
+ className,
79
+ ...rest
80
+ },
81
+ ref,
82
+ ) {
83
+ const inputId = useId()
84
+ const inputRef = useRef<HTMLInputElement>(null)
85
+ const [snapshot, send] = useAeonMachine(fileUploadMachine)
86
+ const [uncontrolledFile, setUncontrolledFile] = useState<File | null>(defaultFile)
87
+ const file = fileProp !== undefined ? fileProp : uncontrolledFile
88
+ const phase = getFileUploadPhase(snapshot.value)
89
+
90
+ const selectFile = useCallback(
91
+ (next: File | null) => {
92
+ if (fileProp === undefined) setUncontrolledFile(next)
93
+ onFileSelect?.(next)
94
+ },
95
+ [fileProp, onFileSelect],
96
+ )
97
+
98
+ const clear = useCallback(() => {
99
+ send({ type: 'RESET' })
100
+ selectFile(null)
101
+ if (inputRef.current) inputRef.current.value = ''
102
+ }, [send, selectFile])
103
+
104
+ const openPicker = useCallback(() => {
105
+ if (disabled) return
106
+ inputRef.current?.click()
107
+ }, [disabled])
108
+
109
+ const ctx = useMemo(
110
+ () => ({
111
+ phase,
112
+ accept,
113
+ disabled,
114
+ file,
115
+ inputId,
116
+ inputRef,
117
+ send,
118
+ selectFile,
119
+ clear,
120
+ openPicker,
121
+ }),
122
+ [phase, accept, disabled, file, inputId, send, selectFile, clear, openPicker],
123
+ )
124
+
125
+ return (
126
+ <FileUploadCtx.Provider value={ctx}>
127
+ <div
128
+ ref={ref}
129
+ className={className}
130
+ {...mergeProps(
131
+ partAttrs(fileUploadAnatomy.scope, fileUploadAnatomy.root, {
132
+ state: phase,
133
+ disabled,
134
+ }),
135
+ rest as HTMLAttributes<HTMLDivElement>,
136
+ )}
137
+ >
138
+ {children}
139
+ </div>
140
+ </FileUploadCtx.Provider>
141
+ )
142
+ })
143
+
144
+ const Label = forwardRef<HTMLLabelElement, HTMLAttributes<HTMLLabelElement>>(function FileUploadLabel(
145
+ props,
146
+ ref,
147
+ ) {
148
+ const { inputId } = useFileUploadCtx()
149
+ return (
150
+ <label
151
+ ref={ref}
152
+ htmlFor={inputId}
153
+ {...mergeProps(partOnlyAttrs(fileUploadAnatomy.label), props)}
154
+ />
155
+ )
156
+ })
157
+
158
+ const Dropzone = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function FileUploadDropzone(
159
+ { onClick, onDragOver, onDragLeave, onDrop, ...rest },
160
+ ref,
161
+ ) {
162
+ const { accept, disabled, send, selectFile, openPicker, phase } = useFileUploadCtx()
163
+
164
+ return (
165
+ <div
166
+ ref={ref}
167
+ role="button"
168
+ tabIndex={disabled ? -1 : 0}
169
+ aria-disabled={disabled || undefined}
170
+ onKeyDown={(e) => {
171
+ if (disabled) return
172
+ if (e.key === 'Enter' || e.key === ' ') {
173
+ e.preventDefault()
174
+ openPicker()
175
+ }
176
+ }}
177
+ {...mergeProps(
178
+ partOnlyAttrs(fileUploadAnatomy.dropzone, {
179
+ state: phase,
180
+ disabled,
181
+ }),
182
+ {
183
+ onClick: (e: MouseEvent<HTMLDivElement>) => {
184
+ onClick?.(e)
185
+ if (disabled || e.defaultPrevented) return
186
+ openPicker()
187
+ },
188
+ onDragOver: (e: DragEvent<HTMLDivElement>) => {
189
+ onDragOver?.(e)
190
+ e.preventDefault()
191
+ if (disabled) return
192
+ send({ type: 'DRAG_OVER' })
193
+ },
194
+ onDragLeave: (e: DragEvent<HTMLDivElement>) => {
195
+ onDragLeave?.(e)
196
+ e.preventDefault()
197
+ if (disabled) return
198
+ send({ type: 'DRAG_LEAVE' })
199
+ },
200
+ onDrop: (e: DragEvent<HTMLDivElement>) => {
201
+ onDrop?.(e)
202
+ e.preventDefault()
203
+ if (disabled) return
204
+ const next = Array.from(e.dataTransfer.files)[0]
205
+ if (next && isFileTypeAccepted(next, accept)) {
206
+ send({ type: 'DROP_ACCEPT' })
207
+ selectFile(next)
208
+ } else {
209
+ send({ type: 'DROP_REJECT' })
210
+ }
211
+ },
212
+ },
213
+ rest,
214
+ )}
215
+ />
216
+ )
217
+ })
218
+
219
+ const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
220
+ function FileUploadInput({ onChange, ...rest }, ref) {
221
+ const { accept, disabled, inputId, inputRef, selectFile, send } = useFileUploadCtx()
222
+
223
+ return (
224
+ <input
225
+ ref={(node) => {
226
+ inputRef.current = node
227
+ if (typeof ref === 'function') ref(node)
228
+ else if (ref) ref.current = node
229
+ }}
230
+ id={inputId}
231
+ type="file"
232
+ accept={accept}
233
+ disabled={disabled}
234
+ {...mergeProps(
235
+ partOnlyAttrs(fileUploadAnatomy.input),
236
+ {
237
+ onChange: (e: ChangeEvent<HTMLInputElement>) => {
238
+ onChange?.(e)
239
+ const next = e.target.files?.[0] ?? null
240
+ if (next) {
241
+ send({ type: 'RESET' })
242
+ selectFile(next)
243
+ }
244
+ },
245
+ },
246
+ rest,
247
+ )}
248
+ />
249
+ )
250
+ },
251
+ )
252
+
253
+ const Hint = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
254
+ function FileUploadHint(props, ref) {
255
+ return <p ref={ref} {...mergeProps(partOnlyAttrs(fileUploadAnatomy.hint), props)} />
256
+ },
257
+ )
258
+
259
+ const Preview = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(function FileUploadPreview(
260
+ { children, ...rest },
261
+ ref,
262
+ ) {
263
+ const { file } = useFileUploadCtx()
264
+ if (!file && !children) return null
265
+ return (
266
+ <div ref={ref} {...mergeProps(partOnlyAttrs(fileUploadAnatomy.preview), rest)}>
267
+ {children ?? file?.name}
268
+ </div>
269
+ )
270
+ })
271
+
272
+ const ClearTrigger = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonElement>>(
273
+ function FileUploadClearTrigger({ onClick, ...rest }, ref) {
274
+ const { clear, disabled, file } = useFileUploadCtx()
275
+ if (!file) return null
276
+ return (
277
+ <button
278
+ ref={ref}
279
+ type="button"
280
+ disabled={disabled}
281
+ {...mergeProps(
282
+ partOnlyAttrs(fileUploadAnatomy.clearTrigger),
283
+ {
284
+ onClick: (e: MouseEvent<HTMLButtonElement>) => {
285
+ onClick?.(e)
286
+ if (e.defaultPrevented) return
287
+ clear()
288
+ },
289
+ },
290
+ rest,
291
+ )}
292
+ />
293
+ )
294
+ },
295
+ )
296
+
297
+ const RejectionMessage = forwardRef<HTMLParagraphElement, HTMLAttributes<HTMLParagraphElement>>(
298
+ function FileUploadRejectionMessage(props, ref) {
299
+ const { phase } = useFileUploadCtx()
300
+ if (phase !== 'rejected') return null
301
+ return (
302
+ <p
303
+ ref={ref}
304
+ role="alert"
305
+ {...mergeProps(partOnlyAttrs(fileUploadAnatomy.rejectionMessage), props)}
306
+ />
307
+ )
308
+ },
309
+ )
310
+
311
+ export const FileUpload = {
312
+ Root,
313
+ Label,
314
+ Dropzone,
315
+ Input,
316
+ Hint,
317
+ Preview,
318
+ ClearTrigger,
319
+ RejectionMessage,
320
+ }
321
+
322
+ export { useFileUploadCtx as useFileUpload }
@@ -13,6 +13,10 @@ export interface FloatingPositionerProps extends HTMLAttributes<HTMLDivElement>
13
13
  portalled?: boolean
14
14
  placement?: AnchorPlacement
15
15
  matchAnchorWidth?: boolean
16
+ /** Gap between trigger and surface. */
17
+ gutter?: number
18
+ /** Stack level for the portalled surface. */
19
+ zIndex?: number
16
20
  children?: ReactNode
17
21
  }
18
22
 
@@ -27,6 +31,8 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
27
31
  portalled = true,
28
32
  placement = 'bottom-start',
29
33
  matchAnchorWidth = false,
34
+ gutter,
35
+ zIndex,
30
36
  children,
31
37
  className,
32
38
  ...rest
@@ -36,6 +42,8 @@ export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerP
36
42
  const mounted = usePortalledMount()
37
43
  useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
38
44
  matchWidth: matchAnchorWidth,
45
+ gutter,
46
+ zIndex,
39
47
  })
40
48
 
41
49
  if (!open) return null
@@ -1,13 +1,38 @@
1
1
  import { useEffect, type RefObject } from 'react'
2
2
 
3
- export type AnchorPlacement = 'bottom-start' | 'top-start'
3
+ export type AnchorSide = 'top' | 'bottom'
4
+ export type AnchorAlign = 'start' | 'end' | 'center'
5
+ export type AnchorPlacement = `${AnchorSide}-${AnchorAlign}`
4
6
 
5
7
  export interface AnchorPositionOptions {
6
8
  /** Match floating width to anchor (select listboxes). */
7
9
  matchWidth?: boolean
10
+ /** Gap between anchor and floating surface. */
11
+ gutter?: number
12
+ /** Stack level written to the floating surface. */
13
+ zIndex?: number
14
+ /** Viewport padding kept clear when flipping and clamping. */
15
+ padding?: number
8
16
  }
9
17
 
10
- /** Pin floating surface to trigger rect (for portalled overlays). */
18
+ const DEFAULT_GUTTER = 4
19
+ const DEFAULT_Z_INDEX = 50
20
+ const DEFAULT_PADDING = 8
21
+
22
+ function splitPlacement(placement: AnchorPlacement): [AnchorSide, AnchorAlign] {
23
+ const [side, align] = placement.split('-') as [AnchorSide, AnchorAlign]
24
+ return [side, align]
25
+ }
26
+
27
+ /**
28
+ * Pin floating surface to trigger rect (for portalled overlays).
29
+ *
30
+ * Collision aware: the preferred side flips when it has no room, and the
31
+ * surface is clamped inside the viewport so an end-aligned trigger near an edge
32
+ * never renders offscreen. The resolved geometry is published as
33
+ * `data-side` / `data-align` plus `--aeon-available-height` and
34
+ * `--aeon-anchor-width` so recipes can cap their own scroll area.
35
+ */
11
36
  export function useAnchorPosition(
12
37
  anchorRef: RefObject<HTMLElement | null>,
13
38
  floatingRef: RefObject<HTMLElement | null>,
@@ -15,22 +40,32 @@ export function useAnchorPosition(
15
40
  placement: AnchorPlacement = 'bottom-start',
16
41
  options: AnchorPositionOptions = {},
17
42
  ) {
18
- const { matchWidth = false } = options
43
+ const {
44
+ matchWidth = false,
45
+ gutter = DEFAULT_GUTTER,
46
+ zIndex = DEFAULT_Z_INDEX,
47
+ padding = DEFAULT_PADDING,
48
+ } = options
19
49
 
20
50
  useEffect(() => {
21
51
  if (!active || !anchorRef.current || !floatingRef.current) return
22
52
 
53
+ let frame = 0
54
+
23
55
  const update = () => {
24
56
  const anchor = anchorRef.current
25
57
  const floating = floatingRef.current
26
58
  if (!anchor || !floating) return
27
59
 
60
+ const [preferredSide, align] = splitPlacement(placement)
28
61
  const rect = anchor.getBoundingClientRect()
62
+ const viewportWidth = window.innerWidth
63
+ const viewportHeight = window.innerHeight
64
+
29
65
  floating.style.position = 'fixed'
30
- floating.style.left = `${rect.left}px`
31
66
  floating.style.bottom = ''
32
67
  floating.style.right = ''
33
- floating.style.zIndex = '50'
68
+ floating.style.zIndex = String(zIndex)
34
69
 
35
70
  if (matchWidth) {
36
71
  floating.style.width = `${rect.width}px`
@@ -40,22 +75,58 @@ export function useAnchorPosition(
40
75
  floating.style.minWidth = ''
41
76
  }
42
77
 
43
- if (placement === 'top-start') {
44
- const height = floating.offsetHeight || floating.getBoundingClientRect().height
45
- floating.style.top = `${rect.top - height - 4}px`
46
- } else {
47
- floating.style.top = `${rect.bottom + 4}px`
48
- }
78
+ const width = floating.offsetWidth || floating.getBoundingClientRect().width
79
+ const height = floating.offsetHeight || floating.getBoundingClientRect().height
80
+
81
+ const roomBelow = viewportHeight - rect.bottom - gutter - padding
82
+ const roomAbove = rect.top - gutter - padding
83
+ const room = preferredSide === 'top' ? roomAbove : roomBelow
84
+ const side =
85
+ room >= height
86
+ ? preferredSide
87
+ : roomAbove > roomBelow
88
+ ? 'top'
89
+ : 'bottom'
90
+ const available = side === 'top' ? roomAbove : roomBelow
91
+
92
+ let left = rect.left
93
+ if (align === 'end') left = rect.right - width
94
+ else if (align === 'center') left = rect.left + rect.width / 2 - width / 2
95
+
96
+ const maxLeft = Math.max(padding, viewportWidth - width - padding)
97
+ left = Math.min(Math.max(padding, left), maxLeft)
98
+
99
+ const top =
100
+ side === 'top'
101
+ ? Math.max(padding, rect.top - gutter - Math.min(height, Math.max(available, 0)))
102
+ : Math.min(
103
+ rect.bottom + gutter,
104
+ Math.max(padding, viewportHeight - height - padding),
105
+ )
106
+
107
+ floating.style.left = `${Math.round(left)}px`
108
+ floating.style.top = `${Math.round(top)}px`
109
+ floating.style.setProperty('--aeon-available-height', `${Math.round(Math.max(available, 0))}px`)
110
+ floating.style.setProperty('--aeon-anchor-width', `${Math.round(rect.width)}px`)
111
+ floating.dataset.side = side
112
+ floating.dataset.align = align
113
+ }
114
+
115
+ const schedule = () => {
116
+ cancelAnimationFrame(frame)
117
+ frame = requestAnimationFrame(update)
49
118
  }
50
119
 
51
120
  update()
52
- requestAnimationFrame(update)
121
+ // Measure again once the surface has laid out (flip/clamp need real height).
122
+ schedule()
53
123
 
54
- window.addEventListener('resize', update)
55
- window.addEventListener('scroll', update, true)
124
+ window.addEventListener('resize', schedule)
125
+ window.addEventListener('scroll', schedule, true)
56
126
  return () => {
57
- window.removeEventListener('resize', update)
58
- window.removeEventListener('scroll', update, true)
127
+ cancelAnimationFrame(frame)
128
+ window.removeEventListener('resize', schedule)
129
+ window.removeEventListener('scroll', schedule, true)
59
130
  }
60
- }, [active, anchorRef, floatingRef, placement, matchWidth])
131
+ }, [active, anchorRef, floatingRef, placement, matchWidth, gutter, zIndex, padding])
61
132
  }
@@ -9,6 +9,8 @@ export * from './separator/index.js'
9
9
  export * from './progress/index.js'
10
10
  export * from './radio-group/index.js'
11
11
  export * from './slider/index.js'
12
+ export * from './range-slider/index.js'
13
+ export * from './file-upload/index.js'
12
14
  export * from './pin-input/index.js'
13
15
  export * from './accordion/index.js'
14
16
  export * from './async/index.js'
@@ -40,3 +42,4 @@ export * from './sticky-bar/index.js'
40
42
  export { useAeonMachine } from './hooks/use-aeon-machine.js'
41
43
  export { useLayoutMode } from './hooks/use-layout-mode.js'
42
44
  export { useFitText } from './hooks/use-fit-text.js'
45
+ export { Slot } from './utils/slot.js'