fix(dify-ui): align read-only controls with Base UI (#45375)

This commit is contained in:
yyh authored and GitHub committed 2026-11-03 03:21:07 +00:00
1 parent b98a134953
commit add8fce5c5
33 files changed
+365 -82

No files matched your search

+5
View File
@@ -12,6 +12,11 @@ existing interaction variants when adding error styles so call-site overrides ke
Disabled surfaces take precedence over invalid and read-only appearance. Keep invalid state and
error associations intact while limiting error colors to enabled controls.
Read-only locks the value, not the control. It stays focusable, keeps its indicators and drops
hover feedback without looking disabled. Its popup opens for browsing; choosing a value and the
parts that only commit or clear one are unavailable. Expose it with `aria-readonly`, never
`aria-disabled`.
Where supported by the upstream part, `className(state)` and `style(state)` read that part's state.
At call sites, before using a callback or React state to calculate styles, check whether data attributes or CSS
variables already express the requirement. When direct state access is needed, add a short comment
@@ -39,13 +39,20 @@ const renderAutocomplete = ({
children,
open = false,
defaultValue = 'workflow',
readOnly = false,
}: {
children?: React.ReactNode
open?: boolean
defaultValue?: string
readOnly?: boolean
} = {}) =>
renderWithSafeViewport(
<Autocomplete open={open} defaultValue={defaultValue} items={['workflow', 'dataset']}>
<Autocomplete
open={open}
readOnly={readOnly}
defaultValue={defaultValue}
items={['workflow', 'dataset']}
>
{children ?? (
<React.Fragment>
<AutocompleteInputGroup data-testid="input-group">
@@ -130,6 +137,13 @@ describe('Autocomplete wrappers', () => {
})
describe('Controls', () => {
it('should keep the popup trigger and hide clear when read-only', async () => {
const screen = await renderAutocomplete({ readOnly: true })
await expect.element(screen.getByTestId('trigger')).toBeVisible()
await expect.element(screen.getByTestId('clear')).not.toBeVisible()
})
it('should provide fallback aria labels and decorative icons when labels are omitted', async () => {
const screen = await renderAutocomplete()
+2 -2
View File
@@ -174,7 +174,7 @@ const autocompleteControlVariants = cva(
insetFocusRingClassName,
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent',
'group-data-disabled/autocomplete:cursor-not-allowed group-data-disabled/autocomplete:hover:bg-transparent group-data-disabled/autocomplete:focus-visible:bg-transparent',
'group-data-readonly/autocomplete:hidden',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:bg-transparent data-readonly:not-data-disabled:hover:text-text-tertiary',
],
{
variants: {
@@ -236,7 +236,7 @@ function AutocompleteClear({
className={(state) =>
cn(
autocompleteControlVariants({ size }),
'data-ending-style:opacity-0 data-starting-style:opacity-0',
'group-data-readonly/autocomplete:hidden data-ending-style:opacity-0 data-starting-style:opacity-0',
resolveClassName(className, state),
)
}
@@ -1,3 +1,4 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Checkbox, CheckboxSkeleton } from '../index'
@@ -67,6 +68,24 @@ describe('Checkbox', () => {
await expect.element(checkbox).toHaveAttribute('data-disabled', '')
})
it.each([false, true])(
'should keep a read-only checkbox unchanged on hover and click (checked: %s)',
async (checked) => {
const screen = await render(
<Checkbox readOnly defaultChecked={checked} aria-label="Accept terms" />,
)
const checkbox = screen.getByRole('checkbox', { name: 'Accept terms' })
const style = getComputedStyle(checkbox.element())
const resting = [style.backgroundColor, style.borderColor]
await userEvent.hover(checkbox)
expect([style.backgroundColor, style.borderColor]).toEqual(resting)
expect(style.cursor).toBe('default')
await checkbox.click()
await expect.element(checkbox).toHaveAttribute('aria-checked', String(checked))
},
)
it('should submit checked and unchecked form values through the hidden input', async () => {
const screen = await render(
<form>
@@ -102,6 +102,9 @@ function StateMatrixDemo() {
{ label: 'Unchecked', checked: false },
{ label: 'Checked', checked: true },
{ label: 'Indeterminate', checked: false, indeterminate: true },
{ label: 'Read-only unchecked', checked: false, readOnly: true },
{ label: 'Read-only checked', checked: true, readOnly: true },
{ label: 'Read-only indeterminate', checked: false, indeterminate: true, readOnly: true },
{ label: 'Disabled unchecked', checked: false, disabled: true },
{ label: 'Disabled checked', checked: true, disabled: true },
{ label: 'Disabled indeterminate', checked: false, indeterminate: true, disabled: true },
@@ -117,6 +120,7 @@ function StateMatrixDemo() {
<Checkbox
checked={state.checked}
indeterminate={state.indeterminate}
readOnly={state.readOnly}
disabled={state.disabled}
/>
{state.label}
@@ -136,7 +140,7 @@ export const StateMatrix: Story = {
docs: {
description: {
story:
'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, and data-disabled.',
'The full visual matrix for Dify checkbox states. State styling comes from Base UI data attributes such as data-checked, data-indeterminate, data-readonly, and data-disabled.',
},
},
},
+3
View File
@@ -13,6 +13,9 @@ const checkboxRootClassName = cn(
checkedControlFocusClassName,
'data-checked:border-transparent data-checked:bg-components-checkbox-bg data-checked:hover:bg-components-checkbox-bg-hover',
'data-indeterminate:border-transparent data-indeterminate:bg-components-checkbox-bg data-indeterminate:hover:bg-components-checkbox-bg-hover',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:border-components-checkbox-border data-readonly:not-data-disabled:hover:bg-components-checkbox-bg-unchecked',
'data-readonly:not-data-disabled:data-checked:hover:border-transparent data-readonly:not-data-disabled:data-checked:hover:bg-components-checkbox-bg',
'data-readonly:not-data-disabled:data-indeterminate:hover:border-transparent data-readonly:not-data-disabled:data-indeterminate:hover:bg-components-checkbox-bg',
'data-disabled:cursor-not-allowed data-disabled:border-components-checkbox-border-disabled data-disabled:bg-components-checkbox-bg-disabled',
'data-disabled:hover:border-components-checkbox-border-disabled data-disabled:hover:bg-components-checkbox-bg-disabled',
'data-disabled:data-checked:border-transparent data-disabled:data-checked:bg-components-checkbox-bg-disabled-checked data-disabled:data-checked:text-components-checkbox-icon-disabled',
@@ -120,12 +120,19 @@ const renderSelectLikeCombobox = ({
const renderInputCombobox = ({
children,
open = false,
readOnly = false,
}: {
children?: React.ReactNode
open?: boolean
readOnly?: boolean
} = {}) =>
renderWithSafeViewport(
<Combobox open={open} defaultValue="workflow" items={['workflow', 'dataset']}>
<Combobox
open={open}
readOnly={readOnly}
defaultValue="workflow"
items={['workflow', 'dataset']}
>
{children ?? (
<React.Fragment>
<ComboboxInputGroup data-testid="input-group">
@@ -190,6 +197,13 @@ describe('Combobox wrappers', () => {
})
describe('Input group and controls', () => {
it('should keep the popup trigger and hide clear when read-only', async () => {
const screen = await renderInputCombobox({ readOnly: true })
await expect.element(screen.getByTestId('input-trigger')).toBeVisible()
await expect.element(screen.getByTestId('clear')).not.toBeVisible()
})
it('should show the compound focus surface when keyboard users enter without Field', async () => {
const screen = await renderInputCombobox()
const inputGroup = screen.getByTestId('input-group')
+3 -3
View File
@@ -199,7 +199,7 @@ const comboboxControlVariants = cva(
insetFocusRingClassName,
'disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-text-tertiary disabled:focus-visible:bg-transparent',
'group-data-disabled/combobox:cursor-not-allowed group-data-disabled/combobox:hover:bg-transparent group-data-disabled/combobox:focus-visible:bg-transparent',
'group-data-readonly/combobox:hidden',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:bg-transparent data-readonly:not-data-disabled:hover:text-text-tertiary',
],
{
variants: {
@@ -232,7 +232,7 @@ function ComboboxClear({
className={(state) =>
cn(
comboboxControlVariants({ size }),
'data-ending-style:opacity-0 data-starting-style:opacity-0',
'group-data-readonly/combobox:hidden data-ending-style:opacity-0 data-starting-style:opacity-0',
resolveClassName(className, state),
)
}
@@ -483,7 +483,7 @@ function ComboboxChipRemove({
}
className={(state) =>
cn(
'flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary hover:bg-state-base-hover-alt hover:text-text-secondary',
'flex size-3.5 shrink-0 items-center justify-center rounded-sm text-text-tertiary group-data-readonly/combobox:hidden hover:bg-state-base-hover-alt hover:text-text-secondary',
focusRingClassName,
resolveClassName(className, state),
)
+1 -1
View File
@@ -142,7 +142,7 @@ With `name`, the committed value participates in native FormData; an empty field
`form` can associate an external form. Required/invalid fields block native validated submission
and focus the first invalid trigger. Reset restores `defaultValue` and closes the popup; a
controlled owner must accept the reset callback. Disabled fields are omitted from submission;
read-only fields remain focusable and submit their value without allowing edits. With a surrounding `Field`, pickers also register with Base UI Form:
read-only fields remain focusable, open for browsing and submit their value without allowing edits. With a surrounding `Field`, pickers also register with Base UI Form:
- `Field.name` takes precedence over the picker's `name`; otherwise the picker supplies the name.
- `Field.validate` receives the committed domain value (`string | null` or `Date | null`).
@@ -268,7 +268,7 @@ describe('DatePicker keyboard and form contract', () => {
},
)
it('keeps read-only values in form data while preventing edits', async () => {
it('keeps read-only values in form data while allowing browsing without edits', async () => {
const onChange = vi.fn()
const screen = await render(
<form id="readonly-form">
@@ -279,10 +279,17 @@ describe('DatePicker keyboard and form contract', () => {
</form>,
)
const trigger = screen.getByRole('button', { name: /^Date Jan/ })
await expect.element(trigger).toHaveAttribute('data-readonly')
await expect.element(trigger).not.toHaveAttribute('aria-disabled')
await expect.element(screen.getByRole('button', { name: 'Clear date' })).not.toBeInTheDocument()
await userEvent.tab()
await expect.element(trigger).toHaveFocus()
await userEvent.keyboard('{Enter}')
await expect.element(screen.getByRole('dialog')).not.toBeInTheDocument()
await expect.element(screen.getByRole('grid')).toHaveAttribute('aria-readonly', 'true')
await screen.getByRole('button', { name: /January 20th/ }).click()
await expect.element(screen.getByRole('dialog')).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
await expect.element(trigger).toHaveAccessibleName(/Jan 15, 2025/)
expect(
new FormData(document.querySelector<HTMLFormElement>('#readonly-form')!).get('date'),
).toBe('2025-01-15')
@@ -145,7 +145,7 @@ function DatePickerContent(props: DatePickerContentProps) {
}
function Session() {
const { value, commit, locale = enUS, minDate, maxDate, isDateUnavailable, labels } = useContext()
const { close, canEdit } = usePickerContext()
const { close, canEdit, readOnly } = usePickerContext()
const [displayMonth, setDisplayMonth] = React.useState(() =>
getInitialMonth(value ?? formatDateValue(new Date()), minDate, maxDate),
)
@@ -169,6 +169,7 @@ function Session() {
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
labels={labels}
readOnly={readOnly}
/>
)
}
@@ -216,6 +216,38 @@ describe('DateTimePicker', () => {
await screen.getByRole('button', { name: 'OK' }).click()
expect(onValueChange).not.toHaveBeenCalled()
})
it('opens a read-only picker for browsing both views without editing', async () => {
const onValueChange = vi.fn()
const screen = await render(
<DateTimePicker
readOnly
timeZone="UTC"
defaultValue={new Date('2025-01-15T13:30:00Z')}
onValueChange={onValueChange}
>
<DateTimePickerLabel className="sr-only">Meeting</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>,
)
await screen.getByRole('button', { name: 'Meeting Jan 15, 2025, 1:30 PM' }).click()
await expect.element(screen.getByRole('grid')).toHaveAttribute('aria-readonly', 'true')
await screen.getByRole('button', { name: /January 20th/ }).click()
await expect
.element(screen.getByRole('button', { name: /January 15th, 2025, selected/ }))
.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'OK' })).not.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Now' })).not.toBeInTheDocument()
await screen.getByRole('button', { name: 'Pick Time: 1:30 PM' }).click()
await expect
.element(screen.getByRole('listbox', { name: 'Hour' }))
.toHaveAttribute('aria-readonly', 'true')
await expect.element(screen.getByRole('button', { name: 'OK' })).not.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Pick Date' })).toBeInTheDocument()
expect(onValueChange).not.toHaveBeenCalled()
})
})
it('rejects a daylight-saving gap and lets the user repair it before committing', async () => {
@@ -70,6 +70,16 @@ export const Default: Story = {
},
}
export const ReadOnly: Story = {
render: () => (
<DateTimePicker timeZone="UTC" defaultValue={new Date('2025-01-15T13:30:00Z')} readOnly>
<DateTimePickerLabel>Meeting time</DateTimePickerLabel>
<DateTimePickerTrigger />
<DateTimePickerContent />
</DateTimePicker>
),
}
export const RTL: Story = {
parameters: {
docs: {
@@ -178,7 +178,7 @@ function Session() {
labels,
hourCycle,
} = useContext()
const { close, canEdit } = usePickerContext()
const { close, canEdit, readOnly } = usePickerContext()
const [draft, setDraft] = React.useState(() => getZonedWallTime(value ?? new Date(), timeZone))
const [displayMonth, setDisplayMonth] = React.useState(() =>
getInitialMonth(draft.date, minDate, maxDate),
@@ -256,7 +256,9 @@ function Session() {
{view === 'date' ? (
<CalendarPanel
value={draft.date}
onSelect={(date) => setDraft({ ...draft, date })}
onSelect={(date) => {
if (!readOnly) setDraft({ ...draft, date })
}}
displayMonth={displayMonth}
onDisplayMonthChange={setDisplayMonth}
locale={locale}
@@ -265,6 +267,7 @@ function Session() {
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
labels={labels}
readOnly={readOnly}
footer={
<React.Fragment>
<Button
@@ -280,12 +283,14 @@ function Session() {
/>
{timeText}
</Button>
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{labels.now}
</Button>
{apply}
</div>
{!readOnly && (
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{labels.now}
</Button>
{apply}
</div>
)}
</React.Fragment>
}
/>
@@ -306,12 +311,14 @@ function Session() {
/>
{labels.backToDate}
</Button>
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{labels.now}
</Button>
{apply}
</div>
{!readOnly && (
<div className="flex gap-1">
<Button size="small" variant="ghost-accent" onClick={setNow}>
{labels.now}
</Button>
{apply}
</div>
)}
</React.Fragment>
}
>
@@ -324,6 +331,7 @@ function Session() {
labels={labels}
locale={locale.code}
hourCycle={hourCycle}
readOnly={readOnly}
/>
</PickerPanel>
)}
@@ -24,6 +24,7 @@ type CalendarGridProps = {
minDate?: string
maxDate?: string
isDateUnavailable?: (date: string) => boolean
readOnly?: boolean
}
function toCalendarDate(value: string): Date {
@@ -36,6 +37,10 @@ function fromCalendarDate(date: Date): string {
return formatDateValue(new TZDate(date.getTime(), 'UTC'))
}
function ReadOnlyMonthGrid(props: React.ComponentProps<'table'>) {
return <table {...props} aria-readonly />
}
function StyledDayButton(props: React.ComponentProps<typeof DayButton>) {
// Keep the visible numeral independently addressable (e.g. 15, not only 15th),
// while retaining DayPicker's localized full date, today and selected labels.
@@ -134,6 +139,7 @@ function CalendarGrid({
minDate,
maxDate,
isDateUnavailable,
readOnly,
}: CalendarGridProps) {
const [today] = React.useState(() =>
toCalendarDate(formatDateValue(timeZone ? new TZDate(Date.now(), timeZone) : new Date())),
@@ -187,7 +193,11 @@ function CalendarGrid({
(minDate && day < minDate) || (maxDate && day > maxDate) || isDateUnavailable?.(day),
)
}}
components={{ DayButton: StyledDayButton, Nav: CalendarNavigation }}
components={{
DayButton: StyledDayButton,
Nav: CalendarNavigation,
...(readOnly && { MonthGrid: ReadOnlyMonthGrid }),
}}
classNames={calendarClassNames}
/>
</div>
@@ -29,6 +29,7 @@ type CalendarPanelProps = {
isDateUnavailable?: (date: string) => boolean
labels: CalendarPanelLabels
footer?: React.ReactNode
readOnly?: boolean
}
function CalendarPanel({
@@ -43,6 +44,7 @@ function CalendarPanel({
isDateUnavailable,
labels,
footer,
readOnly,
}: CalendarPanelProps) {
const [monthPickerHeight, setMonthPickerHeight] = React.useState<number | null>(null)
const choosingMonth = monthPickerHeight !== null
@@ -241,6 +243,7 @@ function CalendarPanel({
minDate={minDate}
maxDate={maxDate}
isDateUnavailable={isDateUnavailable}
readOnly={readOnly}
/>
)}
</PickerPanel>
@@ -14,6 +14,7 @@ type OptionColumnProps = {
onEscape?: () => void
scrollRequest?: number
focusOnMount?: boolean
readOnly?: boolean
}
function OptionColumn({
@@ -25,6 +26,7 @@ function OptionColumn({
focusOnMount,
onEscape,
scrollRequest = 0,
readOnly,
}: OptionColumnProps) {
const ref = React.useRef<HTMLDivElement>(null)
const id = React.useId()
@@ -78,12 +80,17 @@ function OptionColumn({
const option = nearestOption()
if (!option) return undefined
const next = Number(option.dataset.optionValue)
if (next !== value) onValueChange(next)
if (next !== value && !readOnly) onValueChange(next)
return option
})
const finishScroll = React.useEffectEvent(() => {
cancelGesture()
scrollRef.current.target = null
if (readOnly) {
const selected = ref.current?.querySelector<HTMLElement>('[aria-selected="true"]')
if (selected) scrollToOption(selected)
return
}
const option = selectNearest()
if (option && ref.current?.contains(ref.current.ownerDocument.activeElement))
option.focus({ preventScroll: true })
@@ -142,9 +149,13 @@ function OptionColumn({
role="listbox"
tabIndex={-1}
aria-orientation="vertical"
aria-readonly={readOnly || undefined}
ref={ref}
aria-label={label}
className="relative flex h-full min-h-0 min-w-0 flex-1 snap-y snap-mandatory scrollbar-none flex-col items-stretch gap-0.5 overflow-y-auto overscroll-none scroll-auto after:pointer-events-none after:h-[max(0px,calc(100%-1.625rem))] after:shrink-0 after:content-['']"
className={cn(
"relative flex h-full min-h-0 min-w-0 flex-1 snap-y snap-mandatory scrollbar-none flex-col items-stretch gap-0.5 overflow-y-auto overscroll-none scroll-auto after:pointer-events-none after:h-[max(0px,calc(100%-1.625rem))] after:shrink-0 after:content-['']",
readOnly && 'overflow-y-hidden',
)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
searchRef.current = { text: '', time: 0, startIndex: 0 }
@@ -157,6 +168,7 @@ function OptionColumn({
onEscape()
return
}
if (readOnly) return
if (event.altKey || event.ctrlKey || event.metaKey || event.nativeEvent.isComposing) return
if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Home', 'End'].includes(event.key))
searchRef.current = { text: '', time: 0, startIndex: 0 }
@@ -239,7 +251,9 @@ function OptionColumn({
aria-selected={option.value === value}
tabIndex={index === tabbableIndex ? 0 : -1}
data-option-value={option.value}
onClick={(event) => focusValue(option.value, event.detail > 0)}
onClick={(event) => {
if (!readOnly) focusValue(option.value, event.detail > 0)
}}
className={cn(
'aria-selected:bg-components-button-ghost-bg-hover',
'flex min-h-6 shrink-0 snap-start items-center justify-center rounded-md px-1 system-xs-medium text-text-secondary tabular-nums hover:not-aria-selected:bg-state-base-hover-subtle focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-state-accent-solid forced-colors:aria-selected:outline forced-colors:aria-selected:outline-[Highlight]',
@@ -142,9 +142,8 @@ function PickerRoot({
? (props.validationMessage ?? 'Choose a value.')
: undefined
const invalid = Boolean(errorMessage || field.state.valid === false)
const canEdit = useStableCallback(
() => !disabled && !props.readOnly && !inputRef.current?.matches(':disabled'),
)
const canOpen = useStableCallback(() => !disabled && !inputRef.current?.matches(':disabled'))
const canEdit = useStableCallback(() => canOpen() && !props.readOnly)
const formValue = useStableCallback(() => serializedValue)
useRegisterFieldControl(triggerRef, triggerId, value, formValue, eligible, props.name)
@@ -366,7 +365,7 @@ function PickerRoot({
open={props.open}
defaultOpen={props.defaultOpen}
onOpenChange={(open, details) => {
if (open && !canEdit()) details.cancel()
if (open && !canOpen()) details.cancel()
props.onOpenChange?.(open, details)
if (
!details.isCanceled &&
@@ -513,7 +512,6 @@ function PickerTrigger({
data-readonly={field.readOnly || undefined}
data-required={field.required || undefined}
aria-invalid={field.invalid || undefined}
aria-disabled={field.readOnly || undefined}
{...mergeProps(
baseField.validation.getValidationProps(Boolean(field.disabled), {
'aria-describedby':
@@ -532,7 +530,8 @@ function PickerTrigger({
'flex h-8 w-63 max-w-full items-center justify-between gap-0.5 rounded-lg bg-components-input-bg-normal ps-3 pe-2 text-start system-sm-regular text-components-input-text-filled',
'hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
'aria-invalid:not-data-disabled:bg-components-input-bg-destructive aria-invalid:not-data-disabled:inset-ring-1 aria-invalid:not-data-disabled:inset-ring-components-input-border-destructive',
'aria-disabled:not-data-disabled:cursor-default data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:not-aria-invalid:bg-components-input-bg-normal data-readonly:not-data-disabled:not-aria-invalid:hover:bg-components-input-bg-normal',
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled',
!field.serializedValue && 'text-text-tertiary in-data-[theme=dark]:text-text-secondary',
resolveClassName(className, state),
)
@@ -13,6 +13,7 @@ type TimePanelProps = {
hourCycle?: 12 | 24
scrollRequest?: number
minuteStep?: 1 | 5 | 10 | 15 | 20 | 30
readOnly?: boolean
}
function TimePanel({
@@ -23,6 +24,7 @@ function TimePanel({
hourCycle = 12,
scrollRequest,
minuteStep = 1,
readOnly,
}: TimePanelProps) {
const numbers = usePickerNumbers(locale)
const current = parseTimeValue(value)
@@ -43,6 +45,7 @@ function TimePanel({
<PickerColumns>
<OptionColumn
scrollRequest={scrollRequest}
readOnly={readOnly}
normalizeDigits={numbers.normalizeDigits}
label={labels.hour}
value={displayHour}
@@ -54,6 +57,7 @@ function TimePanel({
/>
<OptionColumn
scrollRequest={scrollRequest}
readOnly={readOnly}
normalizeDigits={numbers.normalizeDigits}
label={labels.minute}
value={current.minute}
@@ -66,6 +70,7 @@ function TimePanel({
{hourCycle === 12 && (
<OptionColumn
scrollRequest={scrollRequest}
readOnly={readOnly}
normalizeDigits={numbers.normalizeDigits}
label={labels.period}
value={period}
@@ -338,3 +338,25 @@ it('announces the complete custom displayed value including its timezone', async
.element(screen.getByRole('button', { name: 'Time 1:30 PM UTC+08:00', exact: true }))
.toBeVisible()
})
it('opens a read-only picker for browsing without changing its time', async () => {
const changed = vi.fn()
const screen = await render(
<TimePicker readOnly defaultValue="09:30" onValueChange={changed}>
<TimePickerLabel className="sr-only">Time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>,
)
await screen.getByRole('button', { name: 'Time 9:30 AM' }).click()
const hours = screen.getByRole('listbox', { name: 'Hour' })
await expect.element(hours).toHaveAttribute('aria-readonly', 'true')
await hours.getByRole('option', { name: '10', exact: true }).click()
await userEvent.keyboard('{ArrowDown}')
await expect
.element(hours.getByRole('option', { name: '9', exact: true }))
.toHaveAttribute('aria-selected', 'true')
await expect.element(screen.getByRole('button', { name: 'OK' })).not.toBeInTheDocument()
await expect.element(screen.getByRole('button', { name: 'Now' })).not.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
})
@@ -74,6 +74,16 @@ export const Default: Story = {
},
}
export const ReadOnly: Story = {
render: () => (
<TimePicker defaultValue="13:30" readOnly>
<TimePickerLabel>Start time</TimePickerLabel>
<TimePickerTrigger />
<TimePickerContent />
</TimePicker>
),
}
export const RTL: Story = {
parameters: {
docs: {
@@ -180,7 +180,7 @@ function Session() {
timeZone,
minuteStep = 1,
} = useContext()
const { close, canEdit } = usePickerContext()
const { close, canEdit, readOnly } = usePickerContext()
const [draft, setDraft] = React.useState(() => value ?? currentTime(timeZone, minuteStep))
const unavailable = Boolean(
isTimeUnavailable?.(draft) || Number(draft.slice(3)) % minuteStep !== 0,
@@ -198,37 +198,39 @@ function Session() {
)
}
footer={
<React.Fragment>
<Button
size="small"
variant="ghost-accent"
onClick={() => {
setDraft(currentTime(timeZone, minuteStep))
requestScroll()
}}
>
{labels.now}
</Button>
<Button
variant="primary"
className="min-w-16"
size="small"
disabled={unavailable}
aria-describedby={unavailable ? errorId : undefined}
onClick={(event) => {
if (
canEdit() &&
commit(
draft,
createChangeEventDetails('item-press', event.nativeEvent, event.currentTarget),
!readOnly && (
<React.Fragment>
<Button
size="small"
variant="ghost-accent"
onClick={() => {
setDraft(currentTime(timeZone, minuteStep))
requestScroll()
}}
>
{labels.now}
</Button>
<Button
variant="primary"
className="min-w-16"
size="small"
disabled={unavailable}
aria-describedby={unavailable ? errorId : undefined}
onClick={(event) => {
if (
canEdit() &&
commit(
draft,
createChangeEventDetails('item-press', event.nativeEvent, event.currentTarget),
)
)
)
close()
}}
>
{labels.apply}
</Button>
</React.Fragment>
close()
}}
>
{labels.apply}
</Button>
</React.Fragment>
)
}
>
<TimePanel
@@ -236,6 +238,7 @@ function Session() {
scrollRequest={scrollRequest}
value={draft}
onValueChange={setDraft}
readOnly={readOnly}
labels={labels}
locale={locale}
hourCycle={hourCycle}
@@ -1,5 +1,6 @@
import type { RadioGroupProps, RadioProps } from '../index'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Field, FieldItem, FieldLabel } from '../../field'
import { Fieldset, FieldsetLegend } from '../../fieldset'
@@ -163,6 +164,28 @@ describe('Radio', () => {
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
})
it('should keep read-only radios unchanged on hover and click', async () => {
const screen = await render(
<TestRadioGroup readOnly defaultValue="ssd" label="Storage type">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>,
)
for (const name of ['SSD', 'HDD']) {
const radio = screen.getByRole('radio', { name })
const style = getComputedStyle(radio.element())
const resting = [style.backgroundColor, style.borderColor]
await userEvent.hover(radio)
expect([style.backgroundColor, style.borderColor]).toEqual(resting)
expect(style.cursor).toBe('default')
}
await screen.getByRole('radio', { name: 'HDD' }).click()
await expect
.element(screen.getByRole('radio', { name: 'SSD' }))
.toHaveAttribute('aria-checked', 'true')
})
it('should submit the selected group value through the hidden input', async () => {
const screen = await render(
<form>
@@ -249,6 +249,31 @@ export const StateMatrix: Story = {
</FieldItem>
</Fieldset>
</Field>
<Field name="readOnlyRadioStates">
<Fieldset
render={
<RadioGroup
defaultValue="read-only-checked"
readOnly
className="flex-col items-start gap-3"
/>
}
>
<FieldsetLegend>Read-only radio states</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="read-only-unchecked" />
Read-only unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="read-only-checked" />
Read-only checked
</FieldLabel>
</FieldItem>
</Fieldset>
</Field>
<Field name="disabledRadioStates">
<Fieldset
render={
@@ -284,7 +309,7 @@ export const StateMatrix: Story = {
docs: {
description: {
story:
'The visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.',
'The visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked, data-readonly and data-disabled.',
},
},
},
@@ -39,6 +39,8 @@ function RadioControl({ className, ...props }: RadioControlProps) {
'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',
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:border-components-radio-border data-readonly:not-data-disabled:hover:bg-components-radio-bg',
'data-readonly:not-data-disabled:data-checked:hover:border-components-radio-border-checked',
'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',
@@ -62,6 +64,8 @@ function Radio<Value = string>({ className, ...props }: RadioProps<Value>) {
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
checkedControlFocusClassName,
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:border-components-radio-border data-readonly:not-data-disabled:hover:bg-components-radio-bg',
'data-readonly:not-data-disabled:data-checked:hover:border-components-radio-border-checked',
'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',
@@ -169,10 +169,12 @@ describe('Select wrappers', () => {
const screen = await renderOpenSelect({
rootProps: { readOnly: true },
})
const trigger = screen.getByRole('combobox', { name: 'city select' })
await expect
.element(screen.getByRole('combobox', { name: 'city select' }))
.toHaveAttribute('data-readonly')
await expect.element(trigger).toHaveAttribute('data-readonly')
expect(
getComputedStyle(trigger.element().querySelector('.i-ri-arrow-down-s-line')!).display,
).not.toBe('none')
})
})
+1 -1
View File
@@ -106,7 +106,7 @@ function SelectTrigger({ className, children, size, ...props }: SelectTriggerPro
{...props}
>
<span className="min-w-0 grow truncate">{children}</span>
<BaseSelect.Icon className="shrink-0 text-text-quaternary group-data-readonly/select-trigger:hidden group-[:hover:not([data-disabled])]/select-trigger:text-text-secondary data-popup-open:text-text-secondary">
<BaseSelect.Icon className="shrink-0 text-text-quaternary group-[:hover:not([data-disabled],[data-readonly])]/select-trigger:text-text-secondary data-popup-open:text-text-secondary">
<span className="i-ri-arrow-down-s-line h-4 w-4" aria-hidden="true" />
</BaseSelect.Icon>
</BaseSelect.Trigger>
@@ -1,3 +1,4 @@
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Switch } from '../index'
@@ -27,6 +28,25 @@ describe('Switch', () => {
expect(getThumb(screen.getByRole('switch').element())).toHaveAttribute('data-checked', '')
})
it.each([false, true])(
'should keep a read-only switch unchanged on hover and click (checked: %s)',
async (checked) => {
const screen = await render(<Switch readOnly defaultChecked={checked} aria-label="Sync" />)
const switchElement = screen.getByRole('switch', { name: 'Sync' })
const rootStyle = getComputedStyle(switchElement.element())
const thumbStyle = getComputedStyle(getThumb(switchElement.element())!)
const resting = [rootStyle.backgroundColor, thumbStyle.backgroundColor, thumbStyle.boxShadow]
await userEvent.hover(switchElement)
expect([rootStyle.backgroundColor, thumbStyle.backgroundColor, thumbStyle.boxShadow]).toEqual(
resting,
)
expect(rootStyle.cursor).toBe('default')
await switchElement.click()
await expect.element(switchElement).toHaveAttribute('aria-checked', String(checked))
},
)
describe('loading state', () => {
it('should render as disabled when loading', async () => {
const screen = await render(<Switch checked={false} loading />)
@@ -125,6 +125,7 @@ function AllStatesDemo() {
<tr className="text-start text-gray-500">
<th className="pb-3 font-medium">Size</th>
<th className="pb-3 font-medium">Default</th>
<th className="pb-3 font-medium">Read-only</th>
<th className="pb-3 font-medium">Disabled</th>
<th className="pb-3 font-medium">Loading</th>
<th className="pb-3 font-medium">Skeleton</th>
@@ -150,6 +151,22 @@ function AllStatesDemo() {
/>
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch
size={size}
checked={false}
readOnly
aria-label={`${size} read-only unchecked switch`}
/>
<Switch
size={size}
checked={true}
readOnly
aria-label={`${size} read-only checked switch`}
/>
</div>
</td>
<td className="py-3">
<div className="flex gap-2">
<Switch
+2 -1
View File
@@ -13,6 +13,7 @@ const switchRootVariants = cva(
'group relative inline-flex shrink-0 touch-manipulation items-center transition-colors duration-200 ease-in-out motion-reduce:transition-none',
checkedControlFocusClassName,
'bg-components-toggle-bg-unchecked hover:bg-components-toggle-bg-unchecked-hover data-checked:bg-components-toggle-bg data-checked:hover:bg-components-toggle-bg-hover',
'data-readonly:not-data-disabled:cursor-default data-readonly:not-data-disabled:hover:bg-components-toggle-bg-unchecked data-readonly:not-data-disabled:data-checked:hover:bg-components-toggle-bg',
'data-disabled:cursor-not-allowed data-disabled:bg-components-toggle-bg-unchecked-disabled data-disabled:hover:bg-components-toggle-bg-unchecked-disabled data-disabled:data-checked:bg-components-toggle-bg-disabled data-disabled:data-checked:hover:bg-components-toggle-bg-disabled',
],
{
@@ -31,7 +32,7 @@ const switchRootVariants = cva(
)
const switchThumbVariants = cva(
'block bg-components-toggle-knob shadow-sm transition-transform duration-200 ease-in-out group-hover:bg-components-toggle-knob-hover group-hover:shadow-md group-data-disabled:bg-components-toggle-knob-disabled group-data-disabled:shadow-none motion-reduce:transition-none',
'block bg-components-toggle-knob shadow-sm transition-transform duration-200 ease-in-out group-hover:bg-components-toggle-knob-hover group-hover:shadow-md group-data-disabled:bg-components-toggle-knob-disabled group-data-disabled:shadow-none group-data-readonly:group-not-data-disabled:bg-components-toggle-knob group-data-readonly:group-not-data-disabled:shadow-sm motion-reduce:transition-none',
{
variants: {
size: {
@@ -13,7 +13,7 @@ vi.mock('@/hooks/use-timestamp', () => ({
const render = (ui: ReactElement) => rtlRender(ui, { wrapper: createAccountProfileQueryWrapper() })
describe('Metadata date field', () => {
it('keeps a read-only metadata date focusable without editing or clearing it', async () => {
it('keeps a read-only metadata date focusable and browsable without editing or clearing it', async () => {
const user = userEvent.setup()
const changed = vi.fn()
render(
@@ -29,7 +29,8 @@ describe('Metadata date field', () => {
await user.tab()
expect(trigger).toHaveFocus()
await user.keyboard('{Enter}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(await screen.findByRole('grid')).toHaveAttribute('aria-readonly', 'true')
await user.keyboard('{Escape}')
expect(screen.queryByRole('button', { name: 'common.operation.clear' })).not.toBeInTheDocument()
expect(changed).not.toHaveBeenCalled()
})
@@ -16,7 +16,7 @@ vi.mock('react-i18next', async () => {
})
describe('ToolDatePicker', () => {
it('disables interaction and clearing when read only', () => {
it('marks the trigger read only and hides clearing', () => {
render(
<ToolDatePicker
aria-label="Select date"
@@ -28,8 +28,7 @@ describe('ToolDatePicker', () => {
)
expect(screen.getByRole('button', { name: 'Select date May 1, 2024' })).toHaveAttribute(
'aria-disabled',
'true',
'data-readonly',
)
expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument()
})
@@ -1,5 +1,5 @@
import type { ScheduleTriggerNodeType } from '../types'
import { screen } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BlockEnum } from '@/app/components/workflow/types'
import { renderNodePanel } from '../../__tests__/render-node-panel'
@@ -35,13 +35,21 @@ const cronButton = () =>
const frequencyPicker = () => screen.getByRole('combobox', { name: /frequencyLabel/ })
describe('TriggerSchedulePanel', () => {
it('prevents time editing when the workflow is read only', async () => {
it('lets a read-only workflow browse the time without editing it', async () => {
const user = userEvent.setup()
const { data } = renderPanel(createData(), true)
const trigger = screen.getByRole('button', { name: /11:30 AM.*UTC/ })
expect(trigger).toHaveAttribute('data-readonly')
await user.click(trigger)
expect(trigger).toHaveAttribute('aria-disabled', 'true')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
expect(await screen.findByRole('dialog')).toBeInTheDocument()
const [hours] = screen.getAllByRole('listbox')
await user.click(within(hours!).getByRole('option', { name: '2' }))
expect(within(hours!).getByRole('option', { name: '11' })).toHaveAttribute(
'aria-selected',
'true',
)
expect(screen.queryByRole('button', { name: /operation\.ok/i })).not.toBeInTheDocument()
await user.keyboard('{Escape}')
expect(data()).toMatchObject(createData())
})