fix(ui): preserve Base UI callback and ref contracts (#42721)

This commit is contained in:
yyh authored and GitHub committed 2026-09-22 07:31:59 +00:00
1 parent c1316dd30a
commit a78f716216
22 files changed
+170 -79

No files matched your search

+1
View File
@@ -187,6 +187,7 @@
"type-check": "tsc"
},
"dependencies": {
"@base-ui/utils": "catalog:",
"cn": "catalog:"
},
"devDependencies": {
+3 -12
View File
@@ -70,20 +70,11 @@ function AlertDialogActions({ className, ...props }: AlertDialogActionsProps) {
)
}
type AlertDialogCancelButtonProps = Omit<ButtonProps, 'children'> & {
children: React.ReactNode
closeProps?: Omit<BaseAlertDialog.Close.Props, 'children' | 'render'>
}
type AlertDialogCancelButtonProps = ButtonProps
function AlertDialogCancelButton({
children,
closeProps,
...buttonProps
}: AlertDialogCancelButtonProps) {
function AlertDialogCancelButton({ children, ...buttonProps }: AlertDialogCancelButtonProps) {
return (
<BaseAlertDialog.Close {...closeProps} render={<Button {...buttonProps} />}>
{children}
</BaseAlertDialog.Close>
<BaseAlertDialog.Close render={<Button {...buttonProps} />}>{children}</BaseAlertDialog.Close>
)
}
@@ -267,3 +267,24 @@ describe('context-menu wrapper', () => {
})
})
})
it('resolves submenu popup classes with the popup state', async () => {
const screen = await render(
<ContextMenu>
<ContextMenuTrigger>Open audit menu</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub open>
<ContextMenuSubTrigger>More audit actions</ContextMenuSubTrigger>
<ContextMenuSubContent className={(state) => (state.open ? 'opacity-50' : 'opacity-100')}>
<ContextMenuItem>Audit action</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>,
)
await screen.getByText('Open audit menu').click({ button: 'right' })
await expect
.element(screen.getByRole('menu', { name: 'More audit actions' }))
.toHaveStyle({ opacity: '0.5' })
})
+7 -1
View File
@@ -237,7 +237,13 @@ function ContextMenuSubContent({
className="z-50 outline-hidden"
>
<BaseContextMenu.Popup
className={cn(menuPopupClassName, floatingPopupAnimationClassName, className)}
className={(state) =>
cn(
menuPopupClassName,
floatingPopupAnimationClassName,
resolveClassName(className, state),
)
}
{...props}
>
{children}
@@ -398,3 +398,25 @@ describe('dropdown-menu wrapper', () => {
})
})
})
it('resolves submenu popup classes with the popup state', async () => {
const screen = await render(
<DropdownMenu open>
<DropdownMenuTrigger>Open audit menu</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>More audit actions</DropdownMenuSubTrigger>
<DropdownMenuSubContent
className={(state) => (state.open ? 'opacity-50' : 'opacity-100')}
>
<DropdownMenuItem>Audit action</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>,
)
await expect
.element(screen.getByRole('menu', { name: 'More audit actions' }))
.toHaveStyle({ opacity: '0.5' })
})
+4 -1
View File
@@ -232,7 +232,10 @@ function DropdownMenuSubContent({
sideOffset={sideOffset}
alignOffset={alignOffset}
>
<DropdownMenuPopup className={cn(menuPopupSurfaceClassName, className)} {...props}>
<DropdownMenuPopup
className={(state) => cn(menuPopupSurfaceClassName, resolveClassName(className, state))}
{...props}
>
{children}
</DropdownMenuPopup>
</DropdownMenuPositioner>
@@ -1,3 +1,4 @@
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
@@ -365,3 +366,22 @@ describe('Pagination primitive', () => {
await expect.element(screen.getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true')
})
})
it('keeps the public page-jump ref usable after editing and focus restoration', async () => {
const ref = React.createRef<HTMLButtonElement>()
const screen = await render(
<PaginationRoot page={2} totalPages={10} onPageChange={() => {}}>
<PaginationPageJump ref={ref} />
</PaginationRoot>,
)
expect(ref.current).toBe(screen.getByRole('button').element())
ref.current?.focus()
await expect.element(screen.getByRole('button')).toHaveFocus()
await screen.getByRole('button').click()
await expect.element(screen.getByRole('textbox', { name: 'Page number' })).toHaveFocus()
expect(ref.current).toBeNull()
await userEvent.keyboard('{Escape}')
await expect.element(screen.getByRole('button')).toHaveFocus()
expect(ref.current).toBe(screen.getByRole('button').element())
})
+4 -1
View File
@@ -4,6 +4,7 @@ import type { Button as BaseButtonNS } from '@base-ui/react/button'
import { Button as BaseButton } from '@base-ui/react/button'
import { mergeProps } from '@base-ui/react/merge-props'
import { useRender } from '@base-ui/react/use-render'
import { useMergedRefs } from '@base-ui/utils/useMergedRefs'
import * as React from 'react'
import { cn } from '../cn'
import { resolveClassName } from '../internals/resolve-class-name'
@@ -292,6 +293,7 @@ type PaginationPageJumpProps = Omit<BaseButtonNS.Props, 'children'> & {
function PaginationPageJump({
className,
ref,
inputLabel = 'Page number',
children,
'aria-label': ariaLabel,
@@ -301,6 +303,7 @@ function PaginationPageJump({
const [editing, setEditing] = React.useState(false)
const summaryButtonRef = React.useRef<HTMLButtonElement | null>(null)
const restoreSummaryFocusRef = React.useRef(false)
const mergedSummaryRef = useMergedRefs(summaryButtonRef, ref)
useIsoLayoutEffect(() => {
if (editing || !restoreSummaryFocusRef.current) return
@@ -371,7 +374,7 @@ function PaginationPageJump({
return (
<BaseButton
{...props}
ref={summaryButtonRef}
ref={mergedSummaryRef}
type="button"
aria-label={
ariaLabel ?? `Edit page number, current page ${pagination.page} of ${pagination.totalPages}`
@@ -212,3 +212,24 @@ describe('RadioSkeleton', () => {
expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument()
})
})
it('resolves Radio classes again when selection changes', async () => {
const screen = await render(
<RadioGroup defaultValue="a">
<Radio
value="a"
aria-label="Option A"
className={(state) => (state.checked ? 'opacity-50' : 'opacity-100')}
/>
<Radio value="b" aria-label="Option B" />
</RadioGroup>,
)
await expect
.element(screen.getByRole('radio', { name: 'Option A' }))
.toHaveStyle({ opacity: '0.5' })
await screen.getByRole('radio', { name: 'Option B' }).click()
await expect
.element(screen.getByRole('radio', { name: 'Option A' }))
.toHaveStyle({ opacity: '1' })
})
+14 -12
View File
@@ -55,18 +55,20 @@ type RadioProps<Value = string> = Omit<RadioItemProps<Value>, 'children'>
function Radio<Value = string>({ className, ...props }: RadioProps<Value>) {
return (
<BaseRadio.Root<Value>
className={cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-offset-0 focus-visible:outline-hidden',
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
className,
)}
className={(state) =>
cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
'focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-offset-0 focus-visible:outline-hidden',
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
resolveClassName(className, state),
)
}
{...props}
/>
)
@@ -58,3 +58,20 @@ describe('Switch', () => {
})
})
})
describe('Switch change cancellation', () => {
it('lets event details cancel an uncontrolled change', async () => {
const screen = await render(
<Switch
aria-label="Notifications"
defaultChecked={false}
onCheckedChange={(checked, details) => {
if (checked) details.cancel()
}}
/>,
)
await screen.getByRole('switch').click()
await expect.element(screen.getByRole('switch')).not.toBeChecked()
})
})
@@ -0,0 +1,19 @@
import type { SwitchProps } from '@langgenius/dify-ui/switch'
import { Switch } from '@langgenius/dify-ui/switch'
function SwitchTypeExamples() {
const onCheckedChange: NonNullable<SwitchProps['onCheckedChange']> = (checked, details) => {
if (checked) details.cancel()
}
return (
<>
<Switch defaultChecked onCheckedChange={onCheckedChange} />
<Switch checked={false} onCheckedChange={(checked) => void checked} />
{/* @ts-expect-error the composite owns its thumb and loading indicator */}
<Switch>Custom thumb</Switch>
</>
)
}
void SwitchTypeExamples
+1 -4
View File
@@ -68,11 +68,10 @@ type SwitchControlProps = ControlledSwitchProps | UncontrolledSwitchProps
type SwitchProps = Omit<
BaseSwitchNS.Root.Props,
'checked' | 'defaultChecked' | 'size' | 'onCheckedChange'
'checked' | 'children' | 'defaultChecked' | 'size'
> &
VariantProps<typeof switchRootVariants> &
SwitchControlProps & {
onCheckedChange?: (checked: boolean) => void
loading?: boolean
}
@@ -82,7 +81,6 @@ function Switch({
disabled,
loading = false,
className,
onCheckedChange,
...props
}: SwitchProps) {
const isDisabled = disabled || loading
@@ -93,7 +91,6 @@ function Switch({
disabled={isDisabled}
aria-busy={loading || undefined}
className={(state) => cn(switchRootVariants({ size }), resolveClassName(className, state))}
onCheckedChange={(value) => onCheckedChange?.(value)}
{...props}
>
<BaseSwitch.Thumb className={switchThumbVariants({ size })} />