mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix(ui): preserve Base UI callback and ref contracts (#42721)
This commit is contained in:
22 files changed
+170
-79
No files matched your search
@@ -187,6 +187,7 @@
|
||||
"type-check": "tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/utils": "catalog:",
|
||||
"cn": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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' })
|
||||
})
|
||||
@@ -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,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' })
|
||||
})
|
||||
@@ -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
|
||||
@@ -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 })} />
|
||||
|
||||
Reference in new issue
Block a user