mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 14:51:39 +08:00
fix(dify-ui): align read-only controls with Base UI (#45375)
This commit is contained in:
33 files changed
+365
-82
No files matched your search
@@ -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()
|
||||
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user