@pantoia/ui 0.3.0 → 0.5.0

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 (63) hide show
  1. package/README.md +46 -0
  2. package/accordion/index.module.css +4 -4
  3. package/accordion/index.tsx +21 -51
  4. package/alert-dialog/index.tsx +36 -42
  5. package/badge/index.tsx +1 -1
  6. package/button/index.tsx +31 -30
  7. package/callout/index.tsx +4 -4
  8. package/checkbox/index.tsx +20 -85
  9. package/combobox/index.tsx +122 -316
  10. package/command-palette/index.tsx +51 -32
  11. package/dialog/index.tsx +28 -24
  12. package/dist/index.js +5 -5
  13. package/drawer/index.module.css +2 -2
  14. package/drawer/index.tsx +52 -130
  15. package/dropdown-menu/index.tsx +87 -95
  16. package/field/index.tsx +27 -86
  17. package/input/index.tsx +7 -3
  18. package/kbd/index.tsx +2 -2
  19. package/label/index.tsx +6 -8
  20. package/number-field/index.tsx +122 -173
  21. package/package.json +19 -11
  22. package/popover/index.tsx +61 -32
  23. package/radio-group/index.tsx +35 -38
  24. package/registry.json +64 -154
  25. package/select/index.tsx +141 -70
  26. package/switch/index.tsx +13 -6
  27. package/table/index.tsx +1 -2
  28. package/tabs/index.module.css +3 -3
  29. package/tabs/index.tsx +22 -76
  30. package/textarea/index.tsx +19 -7
  31. package/toast/index.module.css +187 -0
  32. package/toast/index.tsx +141 -46
  33. package/toggle/index.tsx +4 -3
  34. package/tokens.css +7 -1
  35. package/tooltip/index.tsx +61 -30
  36. package/utilities.css +11 -6
  37. package/accordion/AGENTS.md +0 -60
  38. package/alert-dialog/AGENTS.md +0 -78
  39. package/badge/AGENTS.md +0 -35
  40. package/button/AGENTS.md +0 -39
  41. package/callout/AGENTS.md +0 -37
  42. package/checkbox/AGENTS.md +0 -54
  43. package/combobox/AGENTS.md +0 -150
  44. package/command-palette/AGENTS.md +0 -41
  45. package/dialog/AGENTS.md +0 -81
  46. package/drawer/AGENTS.md +0 -66
  47. package/dropdown-menu/AGENTS.md +0 -133
  48. package/field/AGENTS.md +0 -92
  49. package/input/AGENTS.md +0 -35
  50. package/kbd/AGENTS.md +0 -17
  51. package/label/AGENTS.md +0 -21
  52. package/number-field/AGENTS.md +0 -49
  53. package/popover/AGENTS.md +0 -64
  54. package/radio-group/AGENTS.md +0 -81
  55. package/select/AGENTS.md +0 -90
  56. package/switch/AGENTS.md +0 -37
  57. package/table/AGENTS.md +0 -82
  58. package/tabs/AGENTS.md +0 -61
  59. package/test-utils.ts +0 -10
  60. package/textarea/AGENTS.md +0 -35
  61. package/toast/AGENTS.md +0 -34
  62. package/toggle/AGENTS.md +0 -37
  63. package/tooltip/AGENTS.md +0 -50
@@ -1,6 +1,7 @@
1
1
  'use client'
2
2
 
3
- import { useEffect, useRef, useState, type InputHTMLAttributes, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from 'react'
3
+ import { NumberField as NumberFieldPrimitive } from '@base-ui/react/number-field'
4
+ import { useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
4
5
 
5
6
  import { tv } from '../tv'
6
7
 
@@ -38,200 +39,148 @@ export const numberField = tv({
38
39
  defaultVariants: { surface: 'bare', size: 'md' },
39
40
  })
40
41
 
41
- const HOLD_DELAY = 400
42
- const HOLD_EVERY = 60
43
-
44
- const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n))
45
-
46
- const GROUPED = new Intl.NumberFormat('en-US')
47
-
48
- const TYPING = /[^0-9.-]/g
49
-
50
- function snap(n: number, min: number, step: number) {
51
- const from = Number.isFinite(min) ? min : 0
52
- return from + Math.round((n - from) / step) * step
42
+ const PAGE_KEYS: Record<string, 'ArrowUp' | 'ArrowDown'> = {
43
+ PageUp: 'ArrowUp',
44
+ PageDown: 'ArrowDown',
53
45
  }
54
46
 
55
- function useHold(step: () => void, blocked: boolean) {
56
- const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
57
- const beat = useRef<ReturnType<typeof setInterval> | undefined>(undefined)
58
- const latest = useRef(step)
59
- const [pressed, setPressed] = useState(false)
60
-
61
- useEffect(() => { latest.current = step })
62
-
63
- const stop = () => {
64
- clearTimeout(timer.current)
65
- clearInterval(beat.current)
66
- setPressed(false)
67
- }
68
-
69
- useEffect(() => stop, [])
70
-
71
- const onPointerDown = (e: ReactPointerEvent<HTMLButtonElement>) => {
72
- if (e.button !== 0 || blocked) return
73
-
74
- latest.current()
75
- setPressed(true)
76
- e.currentTarget.setPointerCapture(e.pointerId)
47
+ function snap(n: number, min: number | undefined, max: number | undefined, step: number) {
48
+ const from = min ?? 0
49
+ const snapped = from + Math.round((n - from) / step) * step
50
+ return Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, snapped))
51
+ }
77
52
 
78
- timer.current = setTimeout(() => {
79
- beat.current = setInterval(() => { latest.current() }, HOLD_EVERY)
80
- }, HOLD_DELAY)
53
+ function blurDetails(event: Event): NumberFieldPrimitive.Root.ChangeEventDetails {
54
+ const details: NumberFieldPrimitive.Root.ChangeEventDetails = {
55
+ reason: 'input-blur',
56
+ event: event as FocusEvent,
57
+ trigger: event.target instanceof Element ? event.target : undefined,
58
+ isCanceled: false,
59
+ isPropagationAllowed: false,
60
+ cancel: () => { details.isCanceled = true },
61
+ allowPropagation: () => { details.isPropagationAllowed = true },
81
62
  }
82
-
83
- return { pressed, onPointerDown, onPointerUp: stop, onPointerCancel: stop, onLostPointerCapture: stop }
63
+ return details
84
64
  }
85
65
 
86
- export interface NumberFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'size' | 'min' | 'max' | 'step'> {
87
- value?: number | undefined
88
- defaultValue?: number
89
- onValueChange?: (value: number | undefined) => void
90
- decrementLabel: string
91
- incrementLabel: string
92
- min?: number
93
- max?: number
94
- step?: number
95
- surface?: 'bare' | 'well'
96
- size?: 'sm' | 'md' | 'lg'
97
- disabled?: boolean
98
- readOnly?: boolean
99
- className?: string
100
- }
66
+ export type NumberFieldProps = Omit<NumberFieldPrimitive.Root.Props, 'className'>
67
+ & Pick<NumberFieldPrimitive.Input.Props, 'aria-label' | 'aria-labelledby' | 'aria-describedby' | 'aria-invalid' | 'placeholder' | 'autoFocus'>
68
+ & {
69
+ decrementLabel: string
70
+ incrementLabel: string
71
+ surface?: 'bare' | 'well'
72
+ size?: 'sm' | 'md' | 'lg'
73
+ className?: string
74
+ }
101
75
 
102
- export function NumberField(props: NumberFieldProps) {
103
- const {
104
- value,
105
- defaultValue,
106
- onValueChange,
107
- decrementLabel,
108
- incrementLabel,
109
- min = Number.NEGATIVE_INFINITY,
110
- max = Number.POSITIVE_INFINITY,
111
- step = 1,
112
- surface,
113
- size,
114
- disabled = false,
115
- readOnly = false,
116
- className,
117
- ...rest
118
- } = props
119
-
120
- const controlled = 'value' in props
121
- const [own, setOwn] = useState<number | undefined>(defaultValue)
122
- const [typing, setTyping] = useState<string | null>(null)
76
+ export function NumberField({
77
+ value,
78
+ defaultValue,
79
+ onValueChange,
80
+ onValueCommitted,
81
+ decrementLabel,
82
+ incrementLabel,
83
+ step = 1,
84
+ largeStep,
85
+ min,
86
+ max,
87
+ surface,
88
+ size,
89
+ disabled = false,
90
+ readOnly = false,
91
+ locale = 'en-US',
92
+ className,
93
+ 'aria-label': ariaLabel,
94
+ 'aria-labelledby': ariaLabelledBy,
95
+ 'aria-describedby': ariaDescribedBy,
96
+ 'aria-invalid': ariaInvalid,
97
+ placeholder,
98
+ autoFocus,
99
+ ...rest
100
+ }: NumberFieldProps) {
101
+ const [own, setOwn] = useState<number | null>(defaultValue ?? null)
102
+ const controlled = value !== undefined
123
103
  const current = controlled ? value : own
104
+ const unit = step === 'any' ? 1 : step
124
105
 
125
106
  const ui = numberField({ surface, size, readonly: readOnly })
107
+ const stepButton = <button type="button" disabled={disabled || readOnly} />
126
108
 
127
- const set = (next: number | undefined) => {
128
- const after = next === undefined ? undefined : clamp(next, min, max)
129
- if (!controlled) setOwn(after)
130
- if (after !== current) onValueChange?.(after)
109
+ const change: NumberFieldPrimitive.Root.Props['onValueChange'] = (next, details) => {
110
+ onValueChange?.(next, details)
111
+ if (!details.isCanceled && !controlled) setOwn(next)
131
112
  }
132
113
 
133
- const blocked = disabled || readOnly
134
- const from = current ?? (Number.isFinite(min) ? min : 0)
135
- const atMin = current !== undefined && current <= min
136
- const atMax = current !== undefined && current >= max
137
-
138
- const down = useHold(() => { setTyping(null); set(from - step) }, blocked || atMin)
139
- const up = useHold(() => { setTyping(null); set(from + step) }, blocked || atMax)
140
-
141
- const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
142
- if (blocked) return
143
-
144
- const moves: Record<string, number> = {
145
- ArrowUp: from + step,
146
- ArrowDown: from - step,
147
- PageUp: from + step * 10,
148
- PageDown: from - step * 10,
149
- Home: min,
150
- End: max,
114
+ const commit: NumberFieldPrimitive.Root.Props['onValueCommitted'] = (committed, details) => {
115
+ if (details.reason !== 'input-blur' || committed === null || step === 'any') {
116
+ onValueCommitted?.(committed, details)
117
+ return
151
118
  }
152
119
 
153
- const next = moves[e.key]
154
- if (next === undefined || !Number.isFinite(next)) return
120
+ const snapped = snap(committed, min, max, unit)
121
+ if (snapped !== committed) {
122
+ const changeDetails = blurDetails(details.event)
123
+ change(snapped, changeDetails)
124
+ if (changeDetails.isCanceled) return
125
+ }
155
126
 
156
- e.preventDefault()
157
- setTyping(null)
158
- set(next)
127
+ onValueCommitted?.(snapped, details)
159
128
  }
160
129
 
161
- const onWheel = (e: React.WheelEvent<HTMLInputElement>) => {
162
- e.currentTarget.blur()
163
- }
130
+ const onKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
131
+ const arrow = PAGE_KEYS[e.key]
132
+ if (!arrow || e.altKey || e.ctrlKey || e.metaKey) return
164
133
 
165
- const onInput = (e: React.ChangeEvent<HTMLInputElement>) => {
166
- const raw = e.target.value.replace(TYPING, '')
167
- setTyping(min >= 0 ? raw.replace(/-/g, '') : raw)
134
+ e.preventDefault()
135
+ e.currentTarget.dispatchEvent(new KeyboardEvent('keydown', { key: arrow, shiftKey: true, bubbles: true, cancelable: true }))
168
136
  }
169
137
 
170
- const onBlur = () => {
171
- if (typing === null) return
172
-
173
- const parsed = Number(typing)
174
- const empty = typing.trim() === ''
175
- setTyping(null)
176
-
177
- if (empty) set(undefined)
178
- else if (Number.isFinite(parsed)) set(snap(parsed, min, step))
179
- }
138
+ const inputProps = Object.fromEntries(
139
+ Object.entries({
140
+ 'aria-label': ariaLabel,
141
+ 'aria-labelledby': ariaLabelledBy,
142
+ 'aria-describedby': ariaDescribedBy,
143
+ 'aria-invalid': ariaInvalid,
144
+ placeholder,
145
+ autoFocus,
146
+ }).filter(([, v]) => v !== undefined),
147
+ ) as NumberFieldPrimitive.Input.Props
180
148
 
181
149
  return (
182
- <div className={ui.root({ className })}>
183
- <button
184
- type="button"
185
- aria-label={decrementLabel}
186
- disabled={blocked}
187
- aria-disabled={atMin || undefined}
188
- data-pressed={down.pressed ? '' : undefined}
189
- className={ui.step()}
190
- onPointerDown={down.onPointerDown}
191
- onPointerUp={down.onPointerUp}
192
- onPointerCancel={down.onPointerCancel}
193
- onLostPointerCapture={down.onLostPointerCapture}
194
- >
195
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
196
- <path d="M5 12h14" />
197
- </svg>
198
- </button>
199
-
200
- <input
201
- {...rest}
202
- type="text"
203
- inputMode="decimal"
204
- role="spinbutton"
205
- aria-valuenow={current}
206
- aria-valuemin={Number.isFinite(min) ? min : undefined}
207
- aria-valuemax={Number.isFinite(max) ? max : undefined}
208
- value={typing ?? (current === undefined ? '' : GROUPED.format(current))}
209
- disabled={disabled}
210
- readOnly={readOnly}
211
- onChange={onInput}
212
- onBlur={onBlur}
213
- onKeyDown={onKeyDown}
214
- onWheel={onWheel}
215
- className={ui.input()}
216
- />
217
-
218
- <button
219
- type="button"
220
- aria-label={incrementLabel}
221
- disabled={blocked}
222
- aria-disabled={atMax || undefined}
223
- data-pressed={up.pressed ? '' : undefined}
224
- className={ui.step()}
225
- onPointerDown={up.onPointerDown}
226
- onPointerUp={up.onPointerUp}
227
- onPointerCancel={up.onPointerCancel}
228
- onLostPointerCapture={up.onLostPointerCapture}
229
- >
230
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
231
- <path d="M5 12h14" />
232
- <path d="M12 5v14" />
233
- </svg>
234
- </button>
235
- </div>
150
+ <NumberFieldPrimitive.Root
151
+ {...rest}
152
+ value={current}
153
+ onValueChange={change}
154
+ onValueCommitted={commit}
155
+ step={step}
156
+ largeStep={largeStep ?? unit * 10}
157
+ min={min}
158
+ max={max}
159
+ disabled={disabled}
160
+ readOnly={readOnly}
161
+ locale={locale}
162
+ className={className}
163
+ >
164
+ <NumberFieldPrimitive.Group className={ui.root()}>
165
+ <NumberFieldPrimitive.Decrement render={stepButton} aria-label={decrementLabel} className={ui.step()}>
166
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
167
+ <path d="M5 12h14" />
168
+ </svg>
169
+ </NumberFieldPrimitive.Decrement>
170
+
171
+ <NumberFieldPrimitive.Input
172
+ {...inputProps}
173
+ onKeyDown={onKeyDown}
174
+ className={ui.input()}
175
+ />
176
+
177
+ <NumberFieldPrimitive.Increment render={stepButton} aria-label={incrementLabel} className={ui.step()}>
178
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="size-4">
179
+ <path d="M5 12h14" />
180
+ <path d="M12 5v14" />
181
+ </svg>
182
+ </NumberFieldPrimitive.Increment>
183
+ </NumberFieldPrimitive.Group>
184
+ </NumberFieldPrimitive.Root>
236
185
  )
237
186
  }
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@pantoia/ui",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pantoia Design System",
5
5
  "homepage": "https://design.pantoia.com",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/pantoia/pantoia-design.git",
9
- "directory": "packages/registry"
8
+ "url": "git+https://github.com/pantoia/pantoia-ui.git"
10
9
  },
11
10
  "license": "MIT",
12
11
  "type": "module",
@@ -16,10 +15,8 @@
16
15
  "files": [
17
16
  "*/index.tsx",
18
17
  "*/index.module.css",
19
- "*/AGENTS.md",
20
18
  "dist",
21
19
  "tv.ts",
22
- "test-utils.ts",
23
20
  "tailwind.css",
24
21
  "palette.css",
25
22
  "tokens.css",
@@ -33,7 +30,6 @@
33
30
  "./tokens.css": "./tokens.css",
34
31
  "./utilities.css": "./utilities.css",
35
32
  "./registry.json": "./registry.json",
36
- "./test-utils": "./test-utils.ts",
37
33
  "./*": "./*/index.tsx"
38
34
  },
39
35
  "sideEffects": [
@@ -47,11 +43,16 @@
47
43
  },
48
44
  "scripts": {
49
45
  "prepack": "tsc cli/index.ts --ignoreConfig --outDir dist --target es2022 --module esnext --moduleResolution bundler --skipLibCheck --types node",
50
- "typecheck": "tsc --noEmit"
46
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json",
47
+ "check": "tsx scripts/check.ts",
48
+ "lint": "oxlint --deny-warnings",
49
+ "test": "vitest run",
50
+ "verify": "npm run typecheck && npm run check && npm run lint && npm run test",
51
+ "dev:up": "docker compose up -d dev",
52
+ "dev:down": "docker compose down"
51
53
  },
52
54
  "dependencies": {
53
- "radix-ui": "^1.6.7",
54
- "sonner": "^2.0.8",
55
+ "@base-ui/react": "^1.8.0",
55
56
  "tailwind-variants": "^3.3.1"
56
57
  },
57
58
  "peerDependencies": {
@@ -60,12 +61,19 @@
60
61
  "tailwindcss": "^4.0.0"
61
62
  },
62
63
  "devDependencies": {
63
- "@pantoia/typescript-config": "*",
64
+ "@testing-library/dom": "^10.4.1",
65
+ "@testing-library/react": "^16.3.3",
66
+ "@testing-library/user-event": "^14.6.6",
64
67
  "@types/node": "26.4.0",
65
68
  "@types/react": "19.2.18",
66
69
  "@types/react-dom": "19.2.5",
70
+ "happy-dom": "^20.12.0",
71
+ "oxlint": "1.81.0",
67
72
  "react": "19.2.8",
68
73
  "react-dom": "19.2.8",
69
- "typescript": "7.0.2"
74
+ "tailwindcss": "4.3.3",
75
+ "tsx": "4.23.12",
76
+ "typescript": "7.0.2",
77
+ "vitest": "^4.1.11"
70
78
  }
71
79
  }
package/popover/index.tsx CHANGED
@@ -1,41 +1,58 @@
1
1
  'use client'
2
2
 
3
- import { Popover as PopoverPrimitive } from 'radix-ui'
4
- import type { ComponentProps, ReactNode } from 'react'
3
+ import { Popover as PopoverPrimitive } from '@base-ui/react/popover'
4
+ import { useRender } from '@base-ui/react/use-render'
5
+ import { createContext, useContext, useState } from 'react'
6
+ import type { ComponentProps, Dispatch, SetStateAction } from 'react'
5
7
 
6
8
  import { tv } from '../tv'
7
9
 
8
- export interface PopoverProps {
9
- open?: boolean
10
- defaultOpen?: boolean
11
- onOpenChange?: (open: boolean) => void
12
- children?: ReactNode
13
- }
10
+ type AnchorState = [Element | null, Dispatch<SetStateAction<Element | null>>]
11
+
12
+ const AnchorContext = createContext<AnchorState>([null, () => {}])
13
+
14
+ export type PopoverProps = ComponentProps<typeof PopoverPrimitive.Root>
15
+
16
+ export function Popover(props: PopoverProps) {
17
+ const anchor = useState<Element | null>(null)
14
18
 
15
- export function Popover({ open, defaultOpen, onOpenChange, children }: PopoverProps) {
16
19
  return (
17
- <PopoverPrimitive.Root open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange}>
18
- {children}
19
- </PopoverPrimitive.Root>
20
+ <AnchorContext.Provider value={anchor}>
21
+ <PopoverPrimitive.Root {...props} />
22
+ </AnchorContext.Provider>
20
23
  )
21
24
  }
22
25
 
23
- export function PopoverTrigger({ children }: { children: ReactNode }) {
24
- return <PopoverPrimitive.Trigger asChild>{children}</PopoverPrimitive.Trigger>
26
+ export type PopoverTriggerProps = ComponentProps<typeof PopoverPrimitive.Trigger>
27
+
28
+ export function PopoverTrigger(props: PopoverTriggerProps) {
29
+ return <PopoverPrimitive.Trigger {...props} />
25
30
  }
26
31
 
27
- export function PopoverAnchor({ children }: { children: ReactNode }) {
28
- return <PopoverPrimitive.Anchor asChild>{children}</PopoverPrimitive.Anchor>
32
+ export type PopoverAnchorProps = useRender.ComponentProps<'div'>
33
+
34
+ export function PopoverAnchor({ render, ref, ...rest }: PopoverAnchorProps) {
35
+ const [, setAnchor] = useContext(AnchorContext)
36
+
37
+ return useRender({
38
+ defaultTagName: 'div',
39
+ render,
40
+ ref: ref ? [setAnchor, ref] : setAnchor,
41
+ props: rest,
42
+ })
29
43
  }
30
44
 
31
- export function PopoverClose({ children }: { children: ReactNode }) {
32
- return <PopoverPrimitive.Close asChild>{children}</PopoverPrimitive.Close>
45
+ export type PopoverCloseProps = ComponentProps<typeof PopoverPrimitive.Close>
46
+
47
+ export function PopoverClose(props: PopoverCloseProps) {
48
+ return <PopoverPrimitive.Close {...props} />
33
49
  }
34
50
 
35
- export interface PopoverContentProps extends Omit<ComponentProps<typeof PopoverPrimitive.Content>, 'side' | 'align'> {
51
+ export type PopoverContentProps = Omit<ComponentProps<typeof PopoverPrimitive.Popup>, 'className'> & {
36
52
  side?: 'top' | 'right' | 'bottom' | 'left'
37
53
  align?: 'start' | 'center' | 'end'
38
54
  arrow?: boolean
55
+ className?: string
39
56
  }
40
57
 
41
58
  const popoverContent = tv({
@@ -44,6 +61,14 @@ const popoverContent = tv({
44
61
  + ' focus-visible:outline-none',
45
62
  })
46
63
 
64
+ const popoverArrow = tv({
65
+ base: 'data-uncentered:invisible'
66
+ + ' side-top:top-full'
67
+ + ' side-bottom:bottom-full side-bottom:rotate-180'
68
+ + ' side-right:left-0 side-right:origin-top-left side-right:rotate-90 side-right:-translate-y-1/2'
69
+ + ' side-left:right-0 side-left:origin-top-right side-left:-rotate-90 side-left:-translate-y-1/2',
70
+ })
71
+
47
72
  export function PopoverContent({
48
73
  side = 'bottom',
49
74
  align = 'center',
@@ -52,25 +77,29 @@ export function PopoverContent({
52
77
  children,
53
78
  ...rest
54
79
  }: PopoverContentProps) {
80
+ const [anchor] = useContext(AnchorContext)
81
+
55
82
  return (
56
83
  <PopoverPrimitive.Portal>
57
- <PopoverPrimitive.Content
58
- {...rest}
84
+ <PopoverPrimitive.Positioner
85
+ anchor={anchor ?? undefined}
59
86
  side={side}
60
87
  align={align}
61
- sideOffset={8}
88
+ sideOffset={arrow ? 14 : 8}
62
89
  arrowPadding={16}
63
- className={popoverContent({ className })}
90
+ className="z-overlay min-w-max will-change-transform"
64
91
  >
65
- {children}
66
- {arrow && (
67
- <PopoverPrimitive.Arrow asChild width={12} height={6}>
68
- <svg viewBox="0 0 12 6" preserveAspectRatio="none" className="fill-bg-panel -translate-y-px">
69
- <path d="M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0" className="stroke-line stroke-1" />
70
- </svg>
71
- </PopoverPrimitive.Arrow>
72
- )}
73
- </PopoverPrimitive.Content>
92
+ <PopoverPrimitive.Popup {...rest} className={popoverContent({ className })}>
93
+ {children}
94
+ {arrow && (
95
+ <PopoverPrimitive.Arrow className={popoverArrow()}>
96
+ <svg width={12} height={6} viewBox="0 0 12 6" preserveAspectRatio="none" className="block fill-bg-panel -translate-y-px">
97
+ <path d="M0 0L4.58579 4.58579C5.36683 5.36683 6.63316 5.36684 7.41421 4.58579L12 0" className="stroke-line stroke-1" />
98
+ </svg>
99
+ </PopoverPrimitive.Arrow>
100
+ )}
101
+ </PopoverPrimitive.Popup>
102
+ </PopoverPrimitive.Positioner>
74
103
  </PopoverPrimitive.Portal>
75
104
  )
76
105
  }
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
- import { RadioGroup as RadioGroupPrimitive } from 'radix-ui'
4
- import { createContext, use, useId, type ReactNode } from 'react'
3
+ import { Radio } from '@base-ui/react/radio'
4
+ import { RadioGroup as RadioGroupPrimitive } from '@base-ui/react/radio-group'
5
+ import { cloneElement, createContext, use, useEffect, useId, useState, type ComponentProps, type ReactElement, type ReactNode } from 'react'
5
6
 
6
7
  import { Label } from '../label'
7
8
  import { tv } from '../tv'
@@ -14,6 +15,7 @@ export interface RadioItemContext {
14
15
  id: string
15
16
  labelId: string
16
17
  descriptionId: string
18
+ setDescribed: (described: boolean) => void
17
19
  }
18
20
 
19
21
  const ItemContext = createContext<RadioItemContext | null>(null)
@@ -44,69 +46,57 @@ export const radioGroupItem = tv({
44
46
  defaultVariants: { color: 'neutral' },
45
47
  })
46
48
 
47
- export interface RadioGroupProps {
48
- value?: string
49
- defaultValue?: string
50
- onValueChange?: (value: string) => void
49
+ export type RadioGroupProps = Omit<RadioGroupPrimitive.Props<string>, 'className'> & {
51
50
  color?: RadioColor
52
- disabled?: boolean
53
51
  className?: string
54
- children?: ReactNode
55
52
  }
56
53
 
57
54
  const radioGroup = tv({ base: 'flex flex-col gap-3' })
58
55
 
59
- export function RadioGroup({
60
- value,
61
- defaultValue,
62
- onValueChange,
63
- color = 'neutral',
64
- disabled = false,
65
- className,
66
- children,
67
- ...rest
68
- }: RadioGroupProps) {
56
+ export function RadioGroup({ color = 'neutral', className, ...rest }: RadioGroupProps) {
69
57
  return (
70
58
  <ColorContext value={color}>
71
- <RadioGroupPrimitive.Root
72
- {...rest}
73
- value={value}
74
- defaultValue={defaultValue}
75
- onValueChange={onValueChange}
76
- disabled={disabled}
77
- className={radioGroup({ className })}
78
- >
79
- {children}
80
- </RadioGroupPrimitive.Root>
59
+ <RadioGroupPrimitive<string> {...rest} className={radioGroup({ className })} />
81
60
  </ColorContext>
82
61
  )
83
62
  }
84
63
 
85
- export interface RadioGroupItemProps {
64
+ export type RadioGroupItemProps = Omit<ComponentProps<typeof Radio.Root>, 'className' | 'value'> & {
86
65
  value: string
87
- disabled?: boolean
88
66
  className?: string
89
- children?: ReactNode
90
67
  }
91
68
 
92
- export function RadioGroupItem({ value, disabled, className, children }: RadioGroupItemProps) {
69
+ export function RadioGroupItem({
70
+ value,
71
+ className,
72
+ children,
73
+ nativeButton = true,
74
+ render = <button type="button" />,
75
+ ...rest
76
+ }: RadioGroupItemProps) {
93
77
  const id = useId()
78
+ const [described, setDescribed] = useState(false)
94
79
  const color = use(ColorContext)
95
80
  const ui = radioGroupItem({ color })
81
+ const renderWithId: RadioGroupItemProps['render'] = typeof render === 'function'
82
+ ? (props, state) => render({ ...props, id }, state)
83
+ : cloneElement(render as ReactElement<{ id?: string }>, { id })
96
84
 
97
85
  return (
98
- <ItemContext value={{ id, labelId: `${id}-label`, descriptionId: `${id}-description` }}>
86
+ <ItemContext value={{ id, labelId: `${id}-label`, descriptionId: `${id}-description`, setDescribed }}>
99
87
  <div className={ui.root({ className })}>
100
- <RadioGroupPrimitive.Item
88
+ <Radio.Root<string>
89
+ {...rest}
90
+ nativeButton={nativeButton}
91
+ render={renderWithId}
101
92
  id={id}
102
93
  value={value}
103
- disabled={disabled}
104
94
  aria-labelledby={`${id}-label`}
105
- aria-describedby={`${id}-description`}
95
+ aria-describedby={described ? `${id}-description` : undefined}
106
96
  className={ui.control()}
107
97
  >
108
- <RadioGroupPrimitive.Indicator className="text-fg-neutral-solid-contrast size-2.5 rounded-full bg-current" />
109
- </RadioGroupPrimitive.Item>
98
+ <Radio.Indicator className="text-fg-neutral-solid-contrast size-2.5 rounded-full bg-current" />
99
+ </Radio.Root>
110
100
 
111
101
  <div className="flex min-w-0 flex-col gap-0.5">
112
102
  {children}
@@ -130,6 +120,13 @@ const radioGroupItemDescription = tv({ base: 'text-caption text-fg-subtle' })
130
120
 
131
121
  export function RadioGroupItemDescription({ className, children }: { className?: string, children?: ReactNode }) {
132
122
  const ctx = use(ItemContext)
123
+ const setDescribed = ctx?.setDescribed
124
+
125
+ useEffect(() => {
126
+ if (!setDescribed) return
127
+ setDescribed(true)
128
+ return () => setDescribed(false)
129
+ }, [setDescribed])
133
130
 
134
131
  return (
135
132
  <p