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,59 @@
|
|
|
1
|
+
import { partAttrs } from '@aeon-ui/core'
|
|
2
|
+
import { forwardRef, type HTMLAttributes, type ReactNode, type RefObject } from 'react'
|
|
3
|
+
import { useAnchorPosition, type AnchorPlacement } from '../hooks/use-anchor-position.js'
|
|
4
|
+
import { renderPortalled, usePortalledMount } from '../hooks/use-portalled.js'
|
|
5
|
+
import { mergeProps } from '../utils/merge-props.js'
|
|
6
|
+
|
|
7
|
+
export interface FloatingPositionerProps extends HTMLAttributes<HTMLDivElement> {
|
|
8
|
+
open: boolean
|
|
9
|
+
scope: string
|
|
10
|
+
part: string
|
|
11
|
+
triggerRef: RefObject<HTMLElement | null>
|
|
12
|
+
positionerRef: RefObject<HTMLDivElement | null>
|
|
13
|
+
portalled?: boolean
|
|
14
|
+
placement?: AnchorPlacement
|
|
15
|
+
matchAnchorWidth?: boolean
|
|
16
|
+
children?: ReactNode
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const FloatingPositioner = forwardRef<HTMLDivElement, FloatingPositionerProps>(
|
|
20
|
+
function FloatingPositioner(
|
|
21
|
+
{
|
|
22
|
+
open,
|
|
23
|
+
scope,
|
|
24
|
+
part,
|
|
25
|
+
triggerRef,
|
|
26
|
+
positionerRef,
|
|
27
|
+
portalled = true,
|
|
28
|
+
placement = 'bottom-start',
|
|
29
|
+
matchAnchorWidth = false,
|
|
30
|
+
children,
|
|
31
|
+
className,
|
|
32
|
+
...rest
|
|
33
|
+
},
|
|
34
|
+
ref,
|
|
35
|
+
) {
|
|
36
|
+
const mounted = usePortalledMount()
|
|
37
|
+
useAnchorPosition(triggerRef, positionerRef, open && portalled, placement, {
|
|
38
|
+
matchWidth: matchAnchorWidth,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!open) return null
|
|
42
|
+
|
|
43
|
+
const node = (
|
|
44
|
+
<div
|
|
45
|
+
ref={(el) => {
|
|
46
|
+
positionerRef.current = el
|
|
47
|
+
if (typeof ref === 'function') ref(el)
|
|
48
|
+
else if (ref) ref.current = el
|
|
49
|
+
}}
|
|
50
|
+
className={className}
|
|
51
|
+
{...mergeProps(partAttrs(scope, part, { state: 'open' }), rest)}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</div>
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return renderPortalled(node, portalled, mounted)
|
|
58
|
+
},
|
|
59
|
+
)
|
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
import { useEffect, type RefObject } from 'react'
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
export type AnchorPlacement = 'bottom-start' | 'top-start'
|
|
4
|
+
|
|
5
|
+
export interface AnchorPositionOptions {
|
|
6
|
+
/** Match floating width to anchor (select listboxes). */
|
|
7
|
+
matchWidth?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Pin floating surface to trigger rect (for portalled overlays). */
|
|
4
11
|
export function useAnchorPosition(
|
|
5
12
|
anchorRef: RefObject<HTMLElement | null>,
|
|
6
13
|
floatingRef: RefObject<HTMLElement | null>,
|
|
7
14
|
active: boolean,
|
|
15
|
+
placement: AnchorPlacement = 'bottom-start',
|
|
16
|
+
options: AnchorPositionOptions = {},
|
|
8
17
|
) {
|
|
18
|
+
const { matchWidth = false } = options
|
|
19
|
+
|
|
9
20
|
useEffect(() => {
|
|
10
21
|
if (!active || !anchorRef.current || !floatingRef.current) return
|
|
11
22
|
|
|
@@ -13,21 +24,38 @@ export function useAnchorPosition(
|
|
|
13
24
|
const anchor = anchorRef.current
|
|
14
25
|
const floating = floatingRef.current
|
|
15
26
|
if (!anchor || !floating) return
|
|
27
|
+
|
|
16
28
|
const rect = anchor.getBoundingClientRect()
|
|
17
29
|
floating.style.position = 'fixed'
|
|
18
|
-
floating.style.top = `${rect.bottom + 4}px`
|
|
19
30
|
floating.style.left = `${rect.left}px`
|
|
20
|
-
floating.style.
|
|
21
|
-
floating.style.
|
|
31
|
+
floating.style.bottom = ''
|
|
32
|
+
floating.style.right = ''
|
|
22
33
|
floating.style.zIndex = '50'
|
|
34
|
+
|
|
35
|
+
if (matchWidth) {
|
|
36
|
+
floating.style.width = `${rect.width}px`
|
|
37
|
+
floating.style.minWidth = `${rect.width}px`
|
|
38
|
+
} else {
|
|
39
|
+
floating.style.width = ''
|
|
40
|
+
floating.style.minWidth = ''
|
|
41
|
+
}
|
|
42
|
+
|
|
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
|
+
}
|
|
23
49
|
}
|
|
24
50
|
|
|
25
51
|
update()
|
|
52
|
+
requestAnimationFrame(update)
|
|
53
|
+
|
|
26
54
|
window.addEventListener('resize', update)
|
|
27
55
|
window.addEventListener('scroll', update, true)
|
|
28
56
|
return () => {
|
|
29
57
|
window.removeEventListener('resize', update)
|
|
30
58
|
window.removeEventListener('scroll', update, true)
|
|
31
59
|
}
|
|
32
|
-
}, [active, anchorRef, floatingRef])
|
|
60
|
+
}, [active, anchorRef, floatingRef, placement, matchWidth])
|
|
33
61
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** True when the primary input supports hover (typical mouse/trackpad). */
|
|
4
|
+
export function usePrefersHover() {
|
|
5
|
+
const [prefersHover, setPrefersHover] = useState(true)
|
|
6
|
+
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const mq = window.matchMedia('(hover: hover) and (pointer: fine)')
|
|
9
|
+
const update = () => setPrefersHover(mq.matches)
|
|
10
|
+
update()
|
|
11
|
+
mq.addEventListener('change', update)
|
|
12
|
+
return () => mq.removeEventListener('change', update)
|
|
13
|
+
}, [])
|
|
14
|
+
|
|
15
|
+
return prefersHover
|
|
16
|
+
}
|
|
@@ -3,6 +3,11 @@ export * from './switch/index.js'
|
|
|
3
3
|
export * from './checkbox/index.js'
|
|
4
4
|
export * from './dialog/index.js'
|
|
5
5
|
export * from './tabs/index.js'
|
|
6
|
+
export * from './separator/index.js'
|
|
7
|
+
export * from './progress/index.js'
|
|
8
|
+
export * from './radio-group/index.js'
|
|
9
|
+
export * from './slider/index.js'
|
|
10
|
+
export * from './pin-input/index.js'
|
|
6
11
|
export * from './accordion/index.js'
|
|
7
12
|
export * from './async/index.js'
|
|
8
13
|
export * from './field/index.js'
|
|
@@ -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={
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
139
|
+
const { open, triggerRef, positionerRef } = useMenuCtx()
|
|
140
|
+
|
|
124
141
|
return (
|
|
125
|
-
<
|
|
142
|
+
<FloatingPositioner
|
|
126
143
|
ref={ref}
|
|
127
|
-
{
|
|
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={
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
|
|
142
|
+
const { open, triggerRef, positionerRef } = usePopoverCtx()
|
|
143
|
+
|
|
127
144
|
return (
|
|
128
|
-
<
|
|
145
|
+
<FloatingPositioner
|
|
129
146
|
ref={ref}
|
|
130
|
-
{
|
|
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 }
|