From add8fce5c591316e162d401e4b35d8bcda8adefa Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 3 Nov 2026 03:21:07 +0000 Subject: [PATCH] fix(dify-ui): align read-only controls with Base UI (#45375) --- packages/dify-ui/docs/styling.md | 5 ++ .../src/autocomplete/__tests__/index.spec.tsx | 16 ++++- packages/dify-ui/src/autocomplete/index.tsx | 4 +- .../src/checkbox/__tests__/index.spec.tsx | 19 ++++++ .../dify-ui/src/checkbox/index.stories.tsx | 6 +- packages/dify-ui/src/checkbox/index.tsx | 3 + .../src/combobox/__tests__/index.spec.tsx | 16 ++++- packages/dify-ui/src/combobox/index.tsx | 6 +- packages/dify-ui/src/date-time/README.md | 2 +- .../date-picker/__tests__/behavior.spec.tsx | 11 +++- .../src/date-time/date-picker/index.tsx | 3 +- .../date-time-picker/__tests__/index.spec.tsx | 32 +++++++++ .../date-time-picker/index.stories.tsx | 10 +++ .../src/date-time/date-time-picker/index.tsx | 36 ++++++---- .../src/date-time/internal/calendar-grid.tsx | 12 +++- .../src/date-time/internal/calendar-panel.tsx | 3 + .../src/date-time/internal/option-column.tsx | 20 +++++- .../src/date-time/internal/picker-parts.tsx | 11 ++-- .../src/date-time/internal/time-panel.tsx | 5 ++ .../time-picker/__tests__/index.spec.tsx | 22 +++++++ .../date-time/time-picker/index.stories.tsx | 10 +++ .../src/date-time/time-picker/index.tsx | 65 ++++++++++--------- .../src/radio-group/__tests__/index.spec.tsx | 23 +++++++ .../dify-ui/src/radio-group/index.stories.tsx | 27 +++++++- packages/dify-ui/src/radio-group/index.tsx | 4 ++ .../src/select/__tests__/index.spec.tsx | 8 ++- packages/dify-ui/src/select/index.tsx | 2 +- .../src/switch/__tests__/index.spec.tsx | 20 ++++++ packages/dify-ui/src/switch/index.stories.tsx | 17 +++++ packages/dify-ui/src/switch/index.tsx | 3 +- .../base/__tests__/date-picker.spec.tsx | 5 +- .../__tests__/tool-date-picker.spec.tsx | 5 +- .../trigger-schedule/__tests__/panel.spec.tsx | 16 +++-- 33 files changed, 365 insertions(+), 82 deletions(-) diff --git a/packages/dify-ui/docs/styling.md b/packages/dify-ui/docs/styling.md index 56419e4133c..c8e81bd938c 100644 --- a/packages/dify-ui/docs/styling.md +++ b/packages/dify-ui/docs/styling.md @@ -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 diff --git a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx index df860c2d61a..ddc4b1dad2c 100644 --- a/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/autocomplete/__tests__/index.spec.tsx @@ -39,13 +39,20 @@ const renderAutocomplete = ({ children, open = false, defaultValue = 'workflow', + readOnly = false, }: { children?: React.ReactNode open?: boolean defaultValue?: string + readOnly?: boolean } = {}) => renderWithSafeViewport( - + {children ?? ( @@ -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() diff --git a/packages/dify-ui/src/autocomplete/index.tsx b/packages/dify-ui/src/autocomplete/index.tsx index 8bc6b4d8c95..e9ea6efad95 100644 --- a/packages/dify-ui/src/autocomplete/index.tsx +++ b/packages/dify-ui/src/autocomplete/index.tsx @@ -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), ) } diff --git a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx index 7c4fc8bb123..a2e7c0a50ee 100644 --- a/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/checkbox/__tests__/index.spec.tsx @@ -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( + , + ) + 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(
diff --git a/packages/dify-ui/src/checkbox/index.stories.tsx b/packages/dify-ui/src/checkbox/index.stories.tsx index ee0218841e3..ba92df857a8 100644 --- a/packages/dify-ui/src/checkbox/index.stories.tsx +++ b/packages/dify-ui/src/checkbox/index.stories.tsx @@ -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() { {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.', }, }, }, diff --git a/packages/dify-ui/src/checkbox/index.tsx b/packages/dify-ui/src/checkbox/index.tsx index 0c42a83b277..15e4e6bee61 100644 --- a/packages/dify-ui/src/checkbox/index.tsx +++ b/packages/dify-ui/src/checkbox/index.tsx @@ -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', diff --git a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx index c93475a62c0..c34f9548c13 100644 --- a/packages/dify-ui/src/combobox/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/combobox/__tests__/index.spec.tsx @@ -120,12 +120,19 @@ const renderSelectLikeCombobox = ({ const renderInputCombobox = ({ children, open = false, + readOnly = false, }: { children?: React.ReactNode open?: boolean + readOnly?: boolean } = {}) => renderWithSafeViewport( - + {children ?? ( @@ -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') diff --git a/packages/dify-ui/src/combobox/index.tsx b/packages/dify-ui/src/combobox/index.tsx index 24b4ae15a8c..9c543901b3c 100644 --- a/packages/dify-ui/src/combobox/index.tsx +++ b/packages/dify-ui/src/combobox/index.tsx @@ -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), ) diff --git a/packages/dify-ui/src/date-time/README.md b/packages/dify-ui/src/date-time/README.md index 290a699bf73..d71c293d773 100644 --- a/packages/dify-ui/src/date-time/README.md +++ b/packages/dify-ui/src/date-time/README.md @@ -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`). diff --git a/packages/dify-ui/src/date-time/date-picker/__tests__/behavior.spec.tsx b/packages/dify-ui/src/date-time/date-picker/__tests__/behavior.spec.tsx index 360205ea8f9..686af7fa275 100644 --- a/packages/dify-ui/src/date-time/date-picker/__tests__/behavior.spec.tsx +++ b/packages/dify-ui/src/date-time/date-picker/__tests__/behavior.spec.tsx @@ -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( @@ -279,10 +279,17 @@ describe('DatePicker keyboard and form contract', () => { , ) 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('#readonly-form')!).get('date'), ).toBe('2025-01-15') diff --git a/packages/dify-ui/src/date-time/date-picker/index.tsx b/packages/dify-ui/src/date-time/date-picker/index.tsx index 08c636412c2..6d899c4a916 100644 --- a/packages/dify-ui/src/date-time/date-picker/index.tsx +++ b/packages/dify-ui/src/date-time/date-picker/index.tsx @@ -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} /> ) } diff --git a/packages/dify-ui/src/date-time/date-time-picker/__tests__/index.spec.tsx b/packages/dify-ui/src/date-time/date-time-picker/__tests__/index.spec.tsx index 7c9e9813a83..012e6a5d99a 100644 --- a/packages/dify-ui/src/date-time/date-time-picker/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/date-time/date-time-picker/__tests__/index.spec.tsx @@ -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( + + Meeting + + + , + ) + 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 () => { diff --git a/packages/dify-ui/src/date-time/date-time-picker/index.stories.tsx b/packages/dify-ui/src/date-time/date-time-picker/index.stories.tsx index ef6c6027489..4ec6f9ee138 100644 --- a/packages/dify-ui/src/date-time/date-time-picker/index.stories.tsx +++ b/packages/dify-ui/src/date-time/date-time-picker/index.stories.tsx @@ -70,6 +70,16 @@ export const Default: Story = { }, } +export const ReadOnly: Story = { + render: () => ( + + Meeting time + + + + ), +} + export const RTL: Story = { parameters: { docs: { diff --git a/packages/dify-ui/src/date-time/date-time-picker/index.tsx b/packages/dify-ui/src/date-time/date-time-picker/index.tsx index da891add18c..e7ccda31b63 100644 --- a/packages/dify-ui/src/date-time/date-time-picker/index.tsx +++ b/packages/dify-ui/src/date-time/date-time-picker/index.tsx @@ -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' ? ( 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={ -
- - {apply} -
+ {!readOnly && ( +
+ + {apply} +
+ )}
} /> @@ -306,12 +311,14 @@ function Session() { /> {labels.backToDate} -
- - {apply} -
+ {!readOnly && ( +
+ + {apply} +
+ )}
} > @@ -324,6 +331,7 @@ function Session() { labels={labels} locale={locale.code} hourCycle={hourCycle} + readOnly={readOnly} /> )} diff --git a/packages/dify-ui/src/date-time/internal/calendar-grid.tsx b/packages/dify-ui/src/date-time/internal/calendar-grid.tsx index 77761a41508..9ae0062a069 100644 --- a/packages/dify-ui/src/date-time/internal/calendar-grid.tsx +++ b/packages/dify-ui/src/date-time/internal/calendar-grid.tsx @@ -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 +} + function StyledDayButton(props: React.ComponentProps) { // 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} /> diff --git a/packages/dify-ui/src/date-time/internal/calendar-panel.tsx b/packages/dify-ui/src/date-time/internal/calendar-panel.tsx index 5ca5aa86da9..6e7e6e538fc 100644 --- a/packages/dify-ui/src/date-time/internal/calendar-panel.tsx +++ b/packages/dify-ui/src/date-time/internal/calendar-panel.tsx @@ -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(null) const choosingMonth = monthPickerHeight !== null @@ -241,6 +243,7 @@ function CalendarPanel({ minDate={minDate} maxDate={maxDate} isDateUnavailable={isDateUnavailable} + readOnly={readOnly} /> )} diff --git a/packages/dify-ui/src/date-time/internal/option-column.tsx b/packages/dify-ui/src/date-time/internal/option-column.tsx index 265e80ebef2..6fc8829ddc4 100644 --- a/packages/dify-ui/src/date-time/internal/option-column.tsx +++ b/packages/dify-ui/src/date-time/internal/option-column.tsx @@ -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(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('[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]', diff --git a/packages/dify-ui/src/date-time/internal/picker-parts.tsx b/packages/dify-ui/src/date-time/internal/picker-parts.tsx index 1e8d2a44909..cff5b0489e2 100644 --- a/packages/dify-ui/src/date-time/internal/picker-parts.tsx +++ b/packages/dify-ui/src/date-time/internal/picker-parts.tsx @@ -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), ) diff --git a/packages/dify-ui/src/date-time/internal/time-panel.tsx b/packages/dify-ui/src/date-time/internal/time-panel.tsx index baa0e0daffb..12a8d12cab8 100644 --- a/packages/dify-ui/src/date-time/internal/time-panel.tsx +++ b/packages/dify-ui/src/date-time/internal/time-panel.tsx @@ -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({ { + const changed = vi.fn() + const screen = await render( + + Time + + + , + ) + 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() +}) diff --git a/packages/dify-ui/src/date-time/time-picker/index.stories.tsx b/packages/dify-ui/src/date-time/time-picker/index.stories.tsx index f898f3b4331..4d30c919348 100644 --- a/packages/dify-ui/src/date-time/time-picker/index.stories.tsx +++ b/packages/dify-ui/src/date-time/time-picker/index.stories.tsx @@ -74,6 +74,16 @@ export const Default: Story = { }, } +export const ReadOnly: Story = { + render: () => ( + + Start time + + + + ), +} + export const RTL: Story = { parameters: { docs: { diff --git a/packages/dify-ui/src/date-time/time-picker/index.tsx b/packages/dify-ui/src/date-time/time-picker/index.tsx index 3d8fc5a5e3a..9154a59687e 100644 --- a/packages/dify-ui/src/date-time/time-picker/index.tsx +++ b/packages/dify-ui/src/date-time/time-picker/index.tsx @@ -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={ - - - + - + close() + }} + > + {labels.apply} + + + ) } > { await expect.element(hdd).toHaveAttribute('aria-checked', 'false') }) + it('should keep read-only radios unchanged on hover and click', async () => { + const screen = await render( + + SSD + HDD + , + ) + + 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(
diff --git a/packages/dify-ui/src/radio-group/index.stories.tsx b/packages/dify-ui/src/radio-group/index.stories.tsx index f8db69f4510..c897fe792af 100644 --- a/packages/dify-ui/src/radio-group/index.stories.tsx +++ b/packages/dify-ui/src/radio-group/index.stories.tsx @@ -249,6 +249,31 @@ export const StateMatrix: Story = { + +
+ } + > + Read-only radio states + + + + Read-only unchecked + + + + + + Read-only checked + + +
+
({ className, ...props }: RadioProps) { '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', diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index 3922896dbce..07ea2989b24 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -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') }) }) diff --git a/packages/dify-ui/src/select/index.tsx b/packages/dify-ui/src/select/index.tsx index 920bd0cdf39..d1cb4c2ac1c 100644 --- a/packages/dify-ui/src/select/index.tsx +++ b/packages/dify-ui/src/select/index.tsx @@ -106,7 +106,7 @@ function SelectTrigger({ className, children, size, ...props }: SelectTriggerPro {...props} > {children} - + diff --git a/packages/dify-ui/src/switch/__tests__/index.spec.tsx b/packages/dify-ui/src/switch/__tests__/index.spec.tsx index e7c1ac98a2b..034bb9a47f1 100644 --- a/packages/dify-ui/src/switch/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/switch/__tests__/index.spec.tsx @@ -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() + 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() diff --git a/packages/dify-ui/src/switch/index.stories.tsx b/packages/dify-ui/src/switch/index.stories.tsx index f7b190ff18f..81da85c70ae 100644 --- a/packages/dify-ui/src/switch/index.stories.tsx +++ b/packages/dify-ui/src/switch/index.stories.tsx @@ -125,6 +125,7 @@ function AllStatesDemo() {
+ @@ -150,6 +151,22 @@ function AllStatesDemo() { /> +
Size DefaultRead-only Disabled Loading Skeleton +
+ + +
+
({ 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() }) diff --git a/web/app/components/tools/parameters/__tests__/tool-date-picker.spec.tsx b/web/app/components/tools/parameters/__tests__/tool-date-picker.spec.tsx index 895374bd18f..895733a811e 100644 --- a/web/app/components/tools/parameters/__tests__/tool-date-picker.spec.tsx +++ b/web/app/components/tools/parameters/__tests__/tool-date-picker.spec.tsx @@ -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( { ) expect(screen.getByRole('button', { name: 'Select date May 1, 2024' })).toHaveAttribute( - 'aria-disabled', - 'true', + 'data-readonly', ) expect(screen.queryByRole('button', { name: 'Clear' })).not.toBeInTheDocument() }) diff --git a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx index 75650ae9a32..79b8a5d2e61 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx +++ b/web/app/components/workflow/nodes/trigger-schedule/__tests__/panel.spec.tsx @@ -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()) })