chore(dify-ui): align authoring rules and dependency optimization (#43233)

This commit is contained in:
yyh
2026-09-29 11:58:49 +00:00
committed by GitHub
parent 3f0032f20b
commit 0ed392fb62
38 changed files with 359 additions and 316 deletions

View File

@@ -1193,15 +1193,29 @@ export const lintConfig = {
{
files: ['packages/dify-ui/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'],
rules: {
'func-names': ['error', 'as-needed'],
'id-denylist': ['error', 'e'],
'unicorn/import-style': [
'error',
{
extendDefaultStyles: false,
styles: {
react: { namespace: true },
'react-dom': { namespace: true },
'react-dom/client': { namespace: true },
'react-dom/server': { namespace: true },
},
},
],
'react/function-component-definition': [
'error',
{
namedComponents: ['function-declaration', 'function-expression'],
unnamedComponents: ['arrow-function', 'function-expression'],
},
],
'react/jsx-fragments': ['error', 'element'],
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
'react/exhaustive-deps': [
'error',
{

View File

@@ -13,6 +13,15 @@ Use React namespace imports throughout this package, including stories and tests
import also covers React types; do not add separate named type imports. JSX alone does not
require an explicit React import.
Use namespace imports for `react-dom`, `react-dom/client`, and `react-dom/server` as well.
Write grouping fragments as `<React.Fragment>` and remove unnecessary fragment wrappers.
## Component writing style
Define named components with function declarations or function expressions. Arrow functions
remain appropriate for event handlers, render props, and Storybook render callbacks. Name function
expressions when JavaScript cannot infer their name. Use `event` or `error` instead of `e`.
## Subpaths and names
Every public primitive needs a matching `package.json#exports` subpath. Import relatively between

View File

@@ -104,7 +104,7 @@ export const NonDestructive: Story = {
),
}
const ControlledDemo = () => {
function ControlledDemo() {
const [open, setOpen] = React.useState(false)
const [count, setCount] = React.useState(0)
@@ -149,7 +149,7 @@ export const Controlled: Story = {
render: () => <ControlledDemo />,
}
const LoadingConfirmDemo = () => {
function LoadingConfirmDemo() {
const [pending, setPending] = React.useState(false)
const [open, setOpen] = React.useState(false)
const confirmLabelId = React.useId()

View File

@@ -293,27 +293,29 @@ async function searchSuggestions(
}
}
const SuggestionItem = ({ item, dense }: { item: Suggestion; dense?: boolean }) => (
<AutocompleteItem value={item}>
{item.icon && (
<span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />
)}
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
function SuggestionItem({ item, dense }: { item: Suggestion; dense?: boolean }) {
return (
<AutocompleteItem value={item}>
{item.icon && (
<span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
}
// Only virtualized items receive an explicit index; ordinary lists must let Base UI register items by DOM order for keyboard navigation.
const VirtualizedSuggestionItem = ({
function VirtualizedSuggestionItem({
item,
index,
dense,
@@ -321,70 +323,76 @@ const VirtualizedSuggestionItem = ({
item: Suggestion
index: number
dense?: boolean
}) => (
<AutocompleteItem value={item} index={index}>
{item.icon && (
<span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />
)}
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
}) {
return (
<AutocompleteItem value={item} index={index}>
{item.icon && (
<span className={cn(item.icon, 'size-4 shrink-0 text-text-tertiary')} aria-hidden="true" />
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
<div className="flex min-w-0 grow flex-col">
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{!dense && item.description && (
<span className="truncate system-xs-regular text-text-tertiary">{item.description}</span>
)}
</div>
{item.meta && (
<span className="shrink-0 rounded-md bg-components-badge-bg-dimm px-1.5 py-0.5 system-2xs-medium text-text-tertiary">
{item.meta}
</span>
)}
</AutocompleteItem>
)
}
const TagSuggestionItem = ({ item }: { item: Suggestion }) => (
<AutocompleteItem value={item}>
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{item.description && (
<span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">
{item.description}
</span>
)}
</AutocompleteItem>
)
function TagSuggestionItem({ item }: { item: Suggestion }) {
return (
<AutocompleteItem value={item}>
<AutocompleteItemText className="px-0">{item.label}</AutocompleteItemText>
{item.description && (
<span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">
{item.description}
</span>
)}
</AutocompleteItem>
)
}
const BasicTagAutocomplete = ({ size = 'medium' }: { size?: 'small' | 'medium' | 'large' }) => (
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup size={size}>
<span
className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<AutocompleteInput
size={size}
placeholder="Search tags or type a new one…"
aria-label="Search tags or type a new one"
/>
<AutocompleteClear size={size} />
<AutocompleteTrigger size={size} />
</AutocompleteInputGroup>
<AutocompletePortal>
<AutocompletePositioner>
<AutocompletePopup>
<AutocompleteList<Suggestion>>
{(item) => <TagSuggestionItem key={item.value} item={item} />}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompletePortal>
</Autocomplete>
)
function BasicTagAutocomplete({ size = 'medium' }: { size?: 'small' | 'medium' | 'large' }) {
return (
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup size={size}>
<span
className="ml-2 i-ri-search-line size-4 shrink-0 text-text-tertiary"
aria-hidden="true"
/>
<AutocompleteInput
size={size}
placeholder="Search tags or type a new one…"
aria-label="Search tags or type a new one"
/>
<AutocompleteClear size={size} />
<AutocompleteTrigger size={size} />
</AutocompleteInputGroup>
<AutocompletePortal>
<AutocompletePositioner>
<AutocompletePopup>
<AutocompleteList<Suggestion>>
{(item) => <TagSuggestionItem key={item.value} item={item} />}
</AutocompleteList>
<AutocompleteEmpty>No tag suggestion. Keep the typed value.</AutocompleteEmpty>
</AutocompletePopup>
</AutocompletePositioner>
</AutocompletePortal>
</Autocomplete>
)
}
const GroupedSuggestionList = () => {
function GroupedSuggestionList() {
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
return (
@@ -402,7 +410,7 @@ const GroupedSuggestionList = () => {
)
}
const CommandPaletteList = () => {
function CommandPaletteList() {
const groups = useAutocompleteFilteredItems<SuggestionGroup>()
return (
@@ -450,7 +458,7 @@ const LimitedStatus = ({ total }: { total: number }) => {
: `${items.length} suggestions available.`
}
const AsyncSearchDemo = () => {
function AsyncSearchDemo() {
const [searchValue, setSearchValue] = React.useState('')
const [searchResults, setSearchResults] = React.useState<Suggestion[]>([])
const [error, setError] = React.useState<string | null>(null)
@@ -532,11 +540,11 @@ const AsyncSearchDemo = () => {
)
}
const VirtualizedSuggestionList = ({
function VirtualizedSuggestionList({
virtualizerRef,
}: {
virtualizerRef: React.RefObject<StoryVirtualizer | null>
}) => {
}) {
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
const virtualizer = useVirtualizer({
@@ -586,7 +594,7 @@ const VirtualizedSuggestionList = ({
)
}
const VirtualizedStatus = () => {
function VirtualizedStatus() {
const filteredItems = useAutocompleteFilteredItems<Suggestion>()
return (
@@ -596,7 +604,7 @@ const VirtualizedStatus = () => {
)
}
const FuzzyHighlight = ({ text, query }: { text: string; query: string }) => {
function FuzzyHighlight({ text, query }: { text: string; query: string }) {
const parts = React.useMemo(() => {
const trimmed = query.trim()
@@ -622,7 +630,7 @@ const FuzzyHighlight = ({ text, query }: { text: string; query: string }) => {
)
}
const FuzzyMatchingDemo = () => {
function FuzzyMatchingDemo() {
const [value, setValue] = React.useState('retr')
const { contains } = useAutocompleteFilter({ sensitivity: 'base' })
@@ -863,7 +871,7 @@ export const CommandPalette: Story = {
),
}
const VirtualizedLongSuggestionsDemo = () => {
function VirtualizedLongSuggestionsDemo() {
const virtualizerRef = React.useRef<StoryVirtualizer | null>(null)
return (

View File

@@ -74,7 +74,7 @@ function AvatarImage({ className, ...props }: AvatarImageProps) {
)
}
const Avatar = ({ name, avatar, size = 'md', className, onLoadingStatusChange }: AvatarProps) => {
function Avatar({ name, avatar, size = 'md', className, onLoadingStatusChange }: AvatarProps) {
return (
<AvatarRoot size={size} className={className}>
{avatar && (

View File

@@ -50,7 +50,7 @@ function TriggerIcon() {
function RecoveryKeys(props: Pick<CollapsiblePanelProps, 'keepMounted' | 'hiddenUntilFound'>) {
return (
<>
<React.Fragment>
<CollapsibleTrigger className={cn(triggerClassName, 'justify-between gap-2')}>
Recovery keys
<TriggerIcon />
@@ -62,7 +62,7 @@ function RecoveryKeys(props: Pick<CollapsiblePanelProps, 'keepMounted' | 'hidden
<div>horse-battery-staple</div>
</div>
</CollapsiblePanel>
</>
</React.Fragment>
)
}

View File

@@ -353,23 +353,25 @@ const renderVirtualizedOptionItem = (option: Option, index: number, itemCount: n
</ComboboxItem>
)
const PopupSearchInput = ({ label, placeholder }: { label: string; placeholder: string }) => (
<div className="p-1 pb-0">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
aria-label={label}
placeholder={`${placeholder}…`}
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
</ComboboxInputGroup>
</div>
)
function PopupSearchInput({ label, placeholder }: { label: string; placeholder: string }) {
return (
<div className="p-1 pb-0">
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span
aria-hidden
className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
/>
<ComboboxInput
aria-label={label}
placeholder={`${placeholder}…`}
className="block h-4.5 grow px-1 py-0 system-sm-regular text-components-input-text-filled"
/>
</ComboboxInputGroup>
</div>
)
}
const GroupedToolList = () => {
function GroupedToolList() {
const groups = useComboboxFilteredItems<OptionGroup>()
return (
@@ -385,11 +387,11 @@ const GroupedToolList = () => {
)
}
const VirtualizedModelList = ({
function VirtualizedModelList({
virtualizerRef,
}: {
virtualizerRef: React.RefObject<StoryVirtualizer | null>
}) => {
}) {
const scrollRef = React.useRef<HTMLDivElement | null>(null)
const filteredItems = useComboboxFilteredItems<Option>()
const virtualizer = useVirtualizer({
@@ -451,7 +453,7 @@ const VirtualizedModelList = ({
)
}
const FilteredModelStatus = () => {
function FilteredModelStatus() {
const filteredItems = useComboboxFilteredItems<Option>()
return (
@@ -461,7 +463,7 @@ const FilteredModelStatus = () => {
)
}
const VirtualizedLongListDemo = () => {
function VirtualizedLongListDemo() {
const [value, setValue] = React.useState<Option | null>(modelCatalogOptions[137]!)
const virtualizerRef = React.useRef<StoryVirtualizer | null>(null)
@@ -495,7 +497,7 @@ const VirtualizedLongListDemo = () => {
)
}
const AsyncDirectoryDemo = () => {
function AsyncDirectoryDemo() {
const [searchResults, setSearchResults] = React.useState<Option[]>([])
const [selectedValue, setSelectedValue] = React.useState<Option | null>(null)
const [searchValue, setSearchValue] = React.useState('')
@@ -599,7 +601,7 @@ const AsyncDirectoryDemo = () => {
)
}
const InlinePopoverDemo = () => {
function InlinePopoverDemo() {
const [open, setOpen] = React.useState(false)
const [value, setValue] = React.useState<Option | null>(null)
const [inputValue, setInputValue] = React.useState('')
@@ -829,7 +831,7 @@ export const Grouped: Story = {
),
}
const MultipleChipsDemo = () => {
function MultipleChipsDemo() {
const [value, setValue] = React.useState<Option[]>(defaultReviewers)
return (
@@ -1004,7 +1006,7 @@ export const ReadOnly: Story = {
},
}
const ControlledDemo = () => {
function ControlledDemo() {
const [value, setValue] = React.useState<string | null>(defaultTag.value)
return (

View File

@@ -19,14 +19,16 @@ import {
ContextMenuTrigger,
} from '.'
const TriggerArea = ({ label = 'Right-click inside this area' }: { label?: string }) => (
<ContextMenuTrigger
aria-label="context menu trigger area"
className="flex h-44 w-80 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary select-none"
>
{label}
</ContextMenuTrigger>
)
function TriggerArea({ label = 'Right-click inside this area' }: { label?: string }) {
return (
<ContextMenuTrigger
aria-label="context menu trigger area"
className="flex h-44 w-80 items-center justify-center rounded-xl border border-divider-subtle bg-background-default-subtle px-6 text-center text-sm text-text-tertiary select-none"
>
{label}
</ContextMenuTrigger>
)
}
const meta = {
title: 'Base/UI/ContextMenu',
@@ -102,7 +104,7 @@ export const WithGroupLabel: Story = {
type Density = 'compact' | 'comfortable' | 'spacious'
const WithRadioItemsDemo = () => {
function WithRadioItemsDemo() {
const [density, setDensity] = React.useState<Density>('comfortable')
return (
@@ -132,7 +134,7 @@ export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
function WithCheckboxItemsDemo() {
const [showToolbar, setShowToolbar] = React.useState(true)
const [showSidebar, setShowSidebar] = React.useState(false)
const [showStatusBar, setShowStatusBar] = React.useState(true)

View File

@@ -1,4 +1,3 @@
import type * as React from 'react'
import {
DatePicker,
DatePickerContent,
@@ -17,7 +16,8 @@ import {
TimePickerLabel,
TimePickerTrigger,
} from '@langgenius/dify-ui/time-picker'
import { createPortal } from 'react-dom'
import * as React from 'react'
import * as ReactDOM from 'react-dom'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
@@ -51,7 +51,7 @@ it.each(pickers)(
const help = vi.fn()
const screen = await render(
wrap(
<>
<React.Fragment>
<Label>
<span>Departure</span>
<button type="button" onClick={help}>
@@ -60,7 +60,7 @@ it.each(pickers)(
</Label>
<Trigger />
<Content />
</>,
</React.Fragment>,
),
)
const trigger = screen.getByRole('button', { name: 'Departure Help' })
@@ -113,7 +113,7 @@ it('does not forward clicks from portalled label content to the trigger', async
<DatePicker>
<DatePickerLabel>
Departure
{createPortal(<input aria-label="Help search" />, document.body)}
{ReactDOM.createPortal(<input aria-label="Help search" />, document.body)}
</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
@@ -127,14 +127,14 @@ it('does not forward clicks from portalled label content to the trigger', async
it('allows a consumer to cancel label focus forwarding', async () => {
const screen = await render(
<>
<React.Fragment>
<button type="button">Previous control</button>
<DatePicker>
<DatePickerLabel onClick={(event) => event.preventDefault()}>Departure</DatePickerLabel>
<DatePickerTrigger />
<DatePickerContent />
</DatePicker>
</>,
</React.Fragment>,
)
const previous = screen.getByRole('button', { name: 'Previous control' })
await previous.click()
@@ -148,10 +148,10 @@ it.each(pickers)(
async ({ wrap, Trigger, Content }) => {
const screen = await render(
wrap(
<>
<React.Fragment>
<Trigger aria-label="Departure" />
<Content aria-label="Choose departure" />
</>,
</React.Fragment>,
),
)
const trigger = screen.getByRole('button', { name: 'Departure' })
@@ -164,7 +164,7 @@ it.each(pickers)(
it('combines external label, value and descriptions without losing validation feedback', async () => {
const screen = await render(
<>
<React.Fragment>
<span id="departure-label">Departure</span>
<p id="departure-help">Choose a working day.</p>
<DatePicker
@@ -176,7 +176,7 @@ it('combines external label, value and descriptions without losing validation fe
<DatePickerTrigger aria-labelledby="departure-label" aria-describedby="departure-help" />
<DatePickerContent aria-labelledby="departure-label" />
</DatePicker>
</>,
</React.Fragment>,
)
const trigger = screen.getByRole('button', { name: 'Departure Jan 15, 2025' })
await expect

View File

@@ -14,11 +14,11 @@ import {
function Parts() {
return (
<>
<React.Fragment>
<DatePickerTrigger />
<DatePickerClear aria-label="Clear date" />
<DatePickerContent />
</>
</React.Fragment>
)
}
@@ -27,7 +27,7 @@ describe('DatePicker keyboard and form contract', () => {
function Example() {
const [visible, setVisible] = React.useState(true)
return (
<>
<React.Fragment>
<button type="button" onClick={() => setVisible(!visible)}>
Toggle label
</button>
@@ -36,7 +36,7 @@ describe('DatePicker keyboard and form contract', () => {
<DatePickerTrigger id="custom-departure" aria-label={visible ? undefined : 'Date'} />
<DatePickerContent aria-label={visible ? undefined : 'Choose date'} />
</DatePicker>
</>
</React.Fragment>
)
}
const screen = await render(<Example />)

View File

@@ -162,7 +162,7 @@ function CalendarPanel({
}
footer={
choosingMonth ? (
<>
<React.Fragment>
<Button
size="small"
variant="secondary"
@@ -183,7 +183,7 @@ function CalendarPanel({
>
{labels.apply}
</Button>
</>
</React.Fragment>
) : (
footer
)

View File

@@ -265,7 +265,7 @@ function Session() {
isDateUnavailable={isDateUnavailable}
labels={text}
footer={
<>
<React.Fragment>
<Button
size="small"
variant="secondary-accent"
@@ -285,14 +285,14 @@ function Session() {
</Button>
{apply}
</div>
</>
</React.Fragment>
}
/>
) : (
<PickerPanel
header={<PickerPanelHeader>{text.pickTime}</PickerPanelHeader>}
footer={
<>
<React.Fragment>
<Button
size="small"
variant="secondary-accent"
@@ -311,7 +311,7 @@ function Session() {
</Button>
{apply}
</div>
</>
</React.Fragment>
}
>
<TimePanel

View File

@@ -283,13 +283,13 @@ function PickerTrigger({
}
>
{children ?? (
<>
<React.Fragment>
<PickerValue />
<span
className="i-ri-calendar-line size-4 shrink-0 text-text-tertiary forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
aria-hidden="true"
/>
</>
</React.Fragment>
)}
</PopoverTrigger>
)

View File

@@ -296,9 +296,9 @@ it('announces the complete custom displayed value including its timezone', async
<TimePickerTrigger>
<TimePickerValue>
{(displayValue) => (
<>
<React.Fragment>
<span>{displayValue}</span> <span>UTC+08:00</span>
</>
</React.Fragment>
)}
</TimePickerValue>
</TimePickerTrigger>

View File

@@ -174,9 +174,9 @@ export const Schedule: Story = {
<TimePickerTrigger>
<TimePickerValue>
{(value) => (
<>
<React.Fragment>
{value} <span className="text-text-tertiary">UTC+0</span>
</>
</React.Fragment>
)}
</TimePickerValue>
</TimePickerTrigger>

View File

@@ -136,13 +136,13 @@ function TimePickerTrigger({ children, ...props }: TimePickerTriggerProps) {
return (
<PickerTrigger {...props}>
{children ?? (
<>
<React.Fragment>
<PickerValue />
<span
aria-hidden="true"
className="i-ri-time-line size-4 shrink-0 text-text-tertiary forced-colors:text-[ButtonText] forced-colors:forced-color-adjust-none"
/>
</>
</React.Fragment>
)}
</PickerTrigger>
)
@@ -196,7 +196,7 @@ function Session() {
)
}
footer={
<>
<React.Fragment>
<Button
size="small"
variant="ghost-accent"
@@ -224,7 +224,7 @@ function Session() {
>
{text.apply}
</Button>
</>
</React.Fragment>
}
>
<TimePanel

View File

@@ -75,14 +75,14 @@ describe('Dialog wrapper', () => {
it('should connect a detached trigger to the dialog', async () => {
const handle = createDialogHandle()
const screen = await render(
<>
<React.Fragment>
<DialogTrigger handle={handle}>Open dialog</DialogTrigger>
<Dialog handle={handle}>
<DialogContent>
<DialogTitle>Detached dialog</DialogTitle>
</DialogContent>
</Dialog>
</>,
</React.Fragment>,
)
await screen.getByRole('button', { name: 'Open dialog' }).click()

View File

@@ -160,7 +160,7 @@ export const WithoutCloseButton: Story = {
),
}
const ControlledDemo = () => {
function ControlledDemo() {
const [open, setOpen] = React.useState(false)
return (
@@ -274,7 +274,7 @@ type ApiExtensionFormValues = {
apiKey: string
}
const FormDialogDemo = () => {
function FormDialogDemo() {
const [open, setOpen] = React.useState(false)
const nameInputRef = React.useRef<HTMLInputElement>(null)
@@ -369,7 +369,7 @@ export const FormDialog: Story = {
},
}
const OutsideScrollingContentDemo = () => {
function OutsideScrollingContentDemo() {
const [open, setOpen] = React.useState(false)
const popupRef = React.useRef<HTMLDivElement>(null)
@@ -465,7 +465,7 @@ export const OutsidePopupElements: Story = {
),
}
const InsideScrollingContentDemo = () => {
function InsideScrollingContentDemo() {
const [open, setOpen] = React.useState(false)
return (

View File

@@ -19,18 +19,20 @@ import {
DropdownMenuTrigger,
} from '.'
const TriggerButton = ({ label = 'Open Menu' }: { label?: string }) => (
<DropdownMenuTrigger
render={
<button
type="button"
className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
/>
}
>
{label}
</DropdownMenuTrigger>
)
function TriggerButton({ label = 'Open Menu' }: { label?: string }) {
return (
<DropdownMenuTrigger
render={
<button
type="button"
className="rounded-lg border border-divider-subtle bg-components-button-secondary-bg px-3 py-1.5 text-sm text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
/>
}
>
{label}
</DropdownMenuTrigger>
)
}
const meta = {
title: 'Base/UI/DropdownMenu',
@@ -140,7 +142,7 @@ export const WithSubmenu: Story = {
type Density = 'compact' | 'comfortable' | 'spacious'
const WithRadioItemsDemo = () => {
function WithRadioItemsDemo() {
const [density, setDensity] = React.useState<Density>('comfortable')
return (
@@ -170,7 +172,7 @@ export const WithRadioItems: Story = {
render: () => <WithRadioItemsDemo />,
}
const WithCheckboxItemsDemo = () => {
function WithCheckboxItemsDemo() {
const [showToolbar, setShowToolbar] = React.useState(true)
const [showSidebar, setShowSidebar] = React.useState(false)
const [showStatusBar, setShowStatusBar] = React.useState(true)
@@ -265,7 +267,7 @@ export const WithLinkItems: Story = {
type SortOrder = 'newest' | 'oldest' | 'name'
const ComplexDemo = () => {
function ComplexDemo() {
const [sortOrder, setSortOrder] = React.useState<SortOrder>('newest')
const [showArchived, setShowArchived] = React.useState(false)

View File

@@ -1,5 +1,5 @@
import * as React from 'react'
import { createPortal } from 'react-dom'
import * as ReactDOM from 'react-dom'
import { render } from 'vitest-browser-react'
import { Infotip, InfotipContent, InfotipTrigger } from '..'
@@ -11,7 +11,7 @@ describe('Infotip composition', () => {
const screen = await render(
<button type="button" onClick={parentClick}>
Parent action
{createPortal(
{ReactDOM.createPortal(
<Infotip>
<InfotipTrigger aria-label="Details" onClick={triggerClick} ref={triggerRef} />
<InfotipContent aria-label="Details">Explanation</InfotipContent>

View File

@@ -1,5 +1,5 @@
import type * as React from 'react'
import { Infotip, InfotipContent, InfotipTrigger } from '@langgenius/dify-ui/infotip'
import * as React from 'react'
export function infotipTypeContracts(
triggerRef: React.Ref<HTMLButtonElement>,
@@ -8,7 +8,7 @@ export function infotipTypeContracts(
const composition = (
<Infotip<{ title: string }>>
{({ payload }) => (
<>
<React.Fragment>
<InfotipTrigger
aria-label="Details"
ref={triggerRef}
@@ -28,7 +28,7 @@ export function infotipTypeContracts(
>
{payload?.title}
</InfotipContent>
</>
</React.Fragment>
)}
</Infotip>
)

View File

@@ -1,3 +1,4 @@
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Button } from '../../button'
@@ -157,7 +158,7 @@ describe('Invalid focus colors', () => {
document.documentElement.dataset.theme = theme
try {
const screen = await render(
<>
<React.Fragment>
<Button>Before</Button>
<Field invalid>
<FieldLabel>Invalid value</FieldLabel>
@@ -166,7 +167,7 @@ describe('Invalid focus colors', () => {
<InputGroupAddon>suffix</InputGroupAddon>
</InputGroup>
</Field>
</>,
</React.Fragment>,
)
const input = screen.getByRole('textbox', { name: 'Invalid value' })
const surface = screen.getByTestId('invalid-surface').element()

View File

@@ -43,7 +43,7 @@ const meta = {
export default meta
type Story = StoryObj<typeof meta>
const HotkeyKbdGroup = ({
function HotkeyKbdGroup({
hotkey,
color = 'gray',
platform = 'mac',
@@ -51,16 +51,18 @@ const HotkeyKbdGroup = ({
hotkey: DisplayHotkey
color?: 'gray' | 'white'
platform?: FormatDisplayOptions['platform']
}) => (
<KbdGroup>
{formatForDisplay(hotkey, { platform, parts: true }).map((key, index) => (
// oxlint-disable-next-line react/no-array-index-key -- Repeated display keys are static, ordered tokens with no component state.
<Kbd key={`${key}-${index}`} color={color}>
{key}
</Kbd>
))}
</KbdGroup>
)
}) {
return (
<KbdGroup>
{formatForDisplay(hotkey, { platform, parts: true }).map((key, index) => (
// oxlint-disable-next-line react/no-array-index-key -- Repeated display keys are static, ordered tokens with no component state.
<Kbd key={`${key}-${index}`} color={color}>
{key}
</Kbd>
))}
</KbdGroup>
)
}
export const Default: Story = {
render: () => <HotkeyKbdGroup hotkey="Mod+K" />,

View File

@@ -268,7 +268,7 @@ describe('Invalid focus colors', () => {
document.documentElement.dataset.theme = theme
try {
const screen = await render(
<>
<React.Fragment>
<Button>Before</Button>
<Field invalid>
<FieldLabel>Invalid value</FieldLabel>
@@ -278,7 +278,7 @@ describe('Invalid focus colors', () => {
</NumberFieldGroup>
</NumberField>
</Field>
</>,
</React.Fragment>,
)
const input = screen.getByRole('textbox', { name: 'Invalid value' })
const surface = screen.getByTestId('invalid-surface').element()

View File

@@ -208,7 +208,7 @@ const PLACEMENTS: PopoverPlacement[] = [
'left-end',
]
const PlacementsDemo = () => {
function PlacementsDemo() {
const [placement, setPlacement] = React.useState<PopoverPlacement>('bottom')
return (
@@ -254,7 +254,7 @@ export const Placements: Story = {
render: () => <PlacementsDemo />,
}
const ControlledDemo = () => {
function ControlledDemo() {
const [open, setOpen] = React.useState(false)
return (

View File

@@ -126,7 +126,7 @@ const PLACEMENTS: PreviewCardPlacement[] = [
'left-end',
]
const PlacementsDemo = () => {
function PlacementsDemo() {
const [placement, setPlacement] = React.useState<PreviewCardPlacement>('bottom')
return (
@@ -172,27 +172,29 @@ export const Placements: Story = {
render: () => <PlacementsDemo />,
}
const CustomDelayDemo = () => (
<PreviewCard>
<PreviewCardTrigger
delay={100}
closeDelay={100}
href="#preview-card-delay"
className={triggerButtonClassName}
>
Snappy trigger
</PreviewCardTrigger>
<PreviewCardContent className="w-64 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
<div className="text-xs text-text-secondary">
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for
denser UIs.
function CustomDelayDemo() {
return (
<PreviewCard>
<PreviewCardTrigger
delay={100}
closeDelay={100}
href="#preview-card-delay"
className={triggerButtonClassName}
>
Snappy trigger
</PreviewCardTrigger>
<PreviewCardContent className="w-64 p-3">
<div className="flex flex-col gap-1">
<div className="text-sm font-semibold text-text-primary">Fast hover</div>
<div className="text-xs text-text-secondary">
Base UI defaults (600ms / 300ms) are tuned for link previews. Override per trigger for
denser UIs.
</div>
</div>
</div>
</PreviewCardContent>
</PreviewCard>
)
</PreviewCardContent>
</PreviewCard>
)
}
export const CustomDelays: Story = {
render: () => <CustomDelayDemo />,

View File

@@ -1,4 +1,4 @@
import type * as React from 'react'
import * as React from 'react'
import { render } from 'vitest-browser-react'
import {
ScrollArea,
@@ -105,7 +105,7 @@ describe('scroll area', () => {
it('should keep accessible region semantics on the viewport', async () => {
const screen = await render(
<>
<React.Fragment>
<p id="installed-apps-label">Installed apps</p>
<ScrollArea className="h-40 w-40" data-testid="scroll-area-root">
<ScrollAreaViewport
@@ -121,7 +121,7 @@ describe('scroll area', () => {
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
</>,
</React.Fragment>,
)
const viewport = screen.getByRole('region', { name: 'Installed apps' })

View File

@@ -1,11 +1,11 @@
import type * as React from 'react'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { SegmentedControl, SegmentedControlDivider, SegmentedControlItem } from '../index'
function SegmentedControlTypeExamples() {
return (
<>
<React.Fragment>
<SegmentedControl<number> value={10} onValueChange={() => {}} aria-label="Page size">
<SegmentedControlItem<number> value={10}>10</SegmentedControlItem>
<SegmentedControlItem<number> value={20}>20</SegmentedControlItem>
@@ -14,7 +14,7 @@ function SegmentedControlTypeExamples() {
<SegmentedControl aria-label="Missing value">
<SegmentedControlItem value="one">One</SegmentedControlItem>
</SegmentedControl>
</>
</React.Fragment>
)
}

View File

@@ -30,14 +30,18 @@ type SegmentedControlProps = {
noPadding?: boolean
}
const Icon = () => <i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" />
function Icon() {
return <i className="i-ri-information-line size-4 shrink-0" aria-hidden="true" />
}
const Item = () => (
<React.Fragment>
<Icon />
<span className="px-0.5">Item</span>
</React.Fragment>
)
function Item() {
return (
<React.Fragment>
<Icon />
<span className="px-0.5">Item</span>
</React.Fragment>
)
}
function SegmentedControlExample({
defaultValue,

View File

@@ -298,7 +298,7 @@ export const ReadOnly: Story = {
),
}
const ControlledDemo = () => {
function ControlledDemo() {
const [value, setValue] = React.useState<string | null>('balanced')
return (
@@ -336,7 +336,7 @@ export const Controlled: Story = {
render: () => <ControlledDemo />,
}
const MultipleControlledDemo = () => {
function MultipleControlledDemo() {
const [value, setValue] = React.useState<DeploymentRegion[]>(['us-east', 'eu-west'])
return (

View File

@@ -1,13 +1,14 @@
import * as React from 'react'
import { render } from 'vitest-browser-react'
import { StatusDot, StatusDotSkeleton } from '../index'
describe('StatusDot', () => {
it('hides the visual indicator and its loading placeholder from assistive technology', async () => {
const screen = await render(
<>
<React.Fragment>
<StatusDot data-testid="dot" />
<StatusDotSkeleton data-testid="skeleton" />
</>,
</React.Fragment>,
)
await expect.element(screen.getByTestId('dot')).toHaveAttribute('aria-hidden', 'true')
@@ -16,14 +17,14 @@ describe('StatusDot', () => {
it('does not allow consumers to override the decorative contract', async () => {
const screen = await render(
<>
<React.Fragment>
{/* @ts-expect-error StatusDot is always decorative */}
<StatusDot aria-hidden={false} data-testid="dot" />
{/* @ts-expect-error Status semantics belong to the surrounding component */}
<StatusDot aria-label="Active" data-testid="labelled-dot" />
{/* @ts-expect-error StatusDotSkeleton is always decorative */}
<StatusDotSkeleton aria-hidden={false} data-testid="skeleton" />
</>,
</React.Fragment>,
)
await expect.element(screen.getByTestId('dot')).toHaveAttribute('aria-hidden', 'true')

View File

@@ -1,5 +1,6 @@
import type { SwitchProps } from '@langgenius/dify-ui/switch'
import { Switch } from '@langgenius/dify-ui/switch'
import * as React from 'react'
function SwitchTypeExamples() {
const onCheckedChange: NonNullable<SwitchProps['onCheckedChange']> = (checked, details) => {
@@ -7,12 +8,12 @@ function SwitchTypeExamples() {
}
return (
<>
<React.Fragment>
<Switch defaultChecked onCheckedChange={onCheckedChange} />
<Switch checked={false} onCheckedChange={(checked) => void checked} />
{/* @ts-expect-error the composite owns its thumb and loading indicator */}
<Switch>Custom thumb</Switch>
</>
</React.Fragment>
)
}

View File

@@ -50,7 +50,7 @@ type SwitchDemoProps = Partial<
checked?: boolean
}
const SwitchDemo = (args: SwitchDemoProps) => {
function SwitchDemo(args: SwitchDemoProps) {
const [enabled, setEnabled] = React.useState(args.checked ?? false)
return (
@@ -113,7 +113,7 @@ export const DisabledOn: Story = {
},
}
const AllStatesDemo = () => {
function AllStatesDemo() {
const sizes = ['xs', 'sm', 'md', 'lg'] as const
return (
@@ -202,7 +202,7 @@ export const AllStates: Story = {
},
}
const SizeComparisonDemo = () => {
function SizeComparisonDemo() {
const [states, setStates] = React.useState({
xs: false,
sm: false,
@@ -260,7 +260,7 @@ export const SizeComparison: Story = {
render: () => <SizeComparisonDemo />,
}
const LoadingDemo = () => {
function LoadingDemo() {
const [loading, setLoading] = React.useState(true)
return (
@@ -362,7 +362,7 @@ function useMockUpdateAutoRetrySettingMutation({
}
}
const MutationLoadingDemo = () => {
function MutationLoadingDemo() {
const autoRetrySetting = useMockAutoRetrySettingQuery()
const updateAutoRetrySetting = useMockUpdateAutoRetrySettingMutation({
onSuccess: autoRetrySetting.setData,
@@ -406,26 +406,28 @@ export const MutationLoadingGuard: Story = {
},
}
const SkeletonDemo = () => (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" aria-hidden="true" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
function SkeletonDemo() {
return (
<div className="flex flex-col items-center space-y-4">
<div className="flex items-center gap-3">
<SwitchSkeleton size="xs" aria-hidden="true" />
<span className="text-sm text-gray-700">Extra Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" aria-hidden="true" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" aria-hidden="true" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" aria-hidden="true" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="sm" aria-hidden="true" />
<span className="text-sm text-gray-700">Small skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="md" aria-hidden="true" />
<span className="text-sm text-gray-700">Regular skeleton</span>
</div>
<div className="flex items-center gap-3">
<SwitchSkeleton size="lg" aria-hidden="true" />
<span className="text-sm text-gray-700">Large skeleton</span>
</div>
</div>
)
)
}
export const Skeleton: Story = {
render: () => <SkeletonDemo />,

View File

@@ -110,7 +110,7 @@ export const States: Story = {
),
}
const FormDemo = () => {
function FormDemo() {
const [savedDescription, setSavedDescription] = React.useState<string | null>(null)
return (
@@ -153,7 +153,7 @@ export const WithField: Story = {
render: () => <FormDemo />,
}
const ControlledDemo = () => {
function ControlledDemo() {
const [value, setValue] = React.useState(
'Summarize customer feedback into actionable product themes.',
)
@@ -180,7 +180,7 @@ export const Controlled: Story = {
),
}
const CharacterCounterDemo = () => {
function CharacterCounterDemo() {
const maxLength = 120
const [value, setValue] = React.useState(
'Summarize customer feedback into actionable product themes.',

View File

@@ -1,4 +1,5 @@
import type { ToastManager, ToastViewportProps } from '../index'
import * as React from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import {
@@ -88,7 +89,7 @@ describe('@langgenius/dify-ui/toast', () => {
it('should not intercept pointer events below a collapsed top-anchored stack', async () => {
const screen = await render(
<>
<React.Fragment>
<style>{'[role="dialog"] { transition: none !important; }'}</style>
<button
type="button"
@@ -103,7 +104,7 @@ describe('@langgenius/dify-ui/toast', () => {
Underlying action
</button>
<ExampleToastHost manager={manager} timeout={0} />
</>,
</React.Fragment>,
)
toast('Older notification')
@@ -134,7 +135,7 @@ describe('@langgenius/dify-ui/toast', () => {
try {
const screen = await render(
<>
<React.Fragment>
<style>
{`
[role="dialog"]:not([data-ending-style]) {
@@ -170,7 +171,7 @@ describe('@langgenius/dify-ui/toast', () => {
}}
/>
<ExampleToastHost manager={manager} timeout={0} offset={{ top: 120 }} />
</>,
</React.Fragment>,
)
toast('Directional notification')
@@ -209,7 +210,7 @@ describe('@langgenius/dify-ui/toast', () => {
try {
const screen = await render(
<>
<React.Fragment>
<style>
{`
[role="dialog"][data-ending-style] {
@@ -230,7 +231,7 @@ describe('@langgenius/dify-ui/toast', () => {
}}
/>
<ExampleToastHost manager={manager} timeout={0} />
</>,
</React.Fragment>,
)
toast('Background notification')
@@ -269,10 +270,10 @@ describe('@langgenius/dify-ui/toast', () => {
const localManager = createToastManager()
const localToast = createToast(localManager)
const screen = await render(
<>
<React.Fragment>
<ExampleToastHost manager={manager} />
<ExampleToastHost manager={localManager} />
</>,
</React.Fragment>,
)
localToast.error('Local error')
@@ -361,7 +362,7 @@ describe('@langgenius/dify-ui/toast', () => {
try {
const screen = await render(
<>
<React.Fragment>
<style>
{`
[role="dialog"][data-ending-style] {
@@ -391,7 +392,7 @@ describe('@langgenius/dify-ui/toast', () => {
Underlying action
</button>
<ExampleToastHost manager={manager} />
</>,
</React.Fragment>,
)
toast('Dismiss me', {

View File

@@ -53,7 +53,7 @@ const buttonClassName =
const cardClassName =
'flex min-h-[220px] flex-col gap-4 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6 shadow-sm shadow-shadow-shadow-3'
const ExampleCard = ({
function ExampleCard({
eyebrow,
title,
description,
@@ -63,7 +63,7 @@ const ExampleCard = ({
title: string
description: string
children: React.ReactNode
}) => {
}) {
return (
<section className={cardClassName}>
<div className="space-y-2">
@@ -76,7 +76,7 @@ const ExampleCard = ({
)
}
const VariantExamples = () => {
function VariantExamples() {
const createVariantToast = (type: 'success' | 'error' | 'warning' | 'info') => {
const copy = {
success: {
@@ -132,7 +132,7 @@ const VariantExamples = () => {
)
}
const StackExamples = () => {
function StackExamples() {
const createStack = () => {
;[
{
@@ -194,7 +194,7 @@ const StackExamples = () => {
)
}
const PromiseExamples = () => {
function PromiseExamples() {
const [pendingExample, setPendingExample] = React.useState<'success' | 'error' | null>(null)
const exportDsl = async (outcome: 'success' | 'error') => {
@@ -255,7 +255,7 @@ const PromiseExamples = () => {
)
}
const ActionExamples = () => {
function ActionExamples() {
const createActionToast = () => {
let archivedToastId = ''
archivedToastId = toast.warning('Project archived', {
@@ -301,7 +301,7 @@ const ActionExamples = () => {
)
}
const DeduplicateExamples = () => {
function DeduplicateExamples() {
const saveCountRef = React.useRef(0)
const saveDraft = () => {
@@ -328,7 +328,7 @@ const DeduplicateExamples = () => {
)
}
const UpdateExamples = () => {
function UpdateExamples() {
const createUpdatableToast = () => {
const toastId = toast.info('Import started', {
description: 'Preparing assets and metadata for processing.',
@@ -365,7 +365,7 @@ const UpdateExamples = () => {
)
}
const ToastDocsDemo = () => {
function ToastDocsDemo() {
return (
<React.Fragment>
<ExampleToastHost manager={manager} />

View File

@@ -106,7 +106,7 @@ const PLACEMENTS: TooltipPlacement[] = [
'left-end',
]
const PlacementsDemo = () => {
function PlacementsDemo() {
const [placement, setPlacement] = React.useState<TooltipPlacement>('top')
return (
@@ -158,24 +158,26 @@ const DELAY_PRESETS: Array<{ label: string; delay: number }> = [
{ label: 'Default', delay: 600 },
]
const DelayDemo = () => (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={
<DifyIconButton aria-label={`${label} (${delay}ms)`} size="lg" variant="secondary">
<span aria-hidden className="i-ri-timer-line size-4" />
</DifyIconButton>
}
/>
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
function DelayDemo() {
return (
<div className="flex items-center gap-3">
{DELAY_PRESETS.map(({ label, delay }) => (
<TooltipProvider key={delay} delay={delay}>
<Tooltip>
<TooltipTrigger
render={
<DifyIconButton aria-label={`${label} (${delay}ms)`} size="lg" variant="secondary">
<span aria-hidden className="i-ri-timer-line size-4" />
</DifyIconButton>
}
/>
<TooltipContent>{`${label} (${delay}ms)`}</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
)
}
export const WithDelay: Story = {
parameters: {

View File

@@ -15,16 +15,6 @@ export default defineConfig({
resolve: {
tsconfigPaths: true,
},
optimizeDeps: {
include: [
'vite-plus/test/browser',
'@daypicker/react/locale/en-US',
'@daypicker/react/locale/ar-TN',
'@daypicker/react/locale/fa-IR',
'@base-ui/utils/useStableCallback',
'@base-ui/utils/useTimeout',
],
},
test: {
browser: {
expect: { toMatchScreenshot: { screenshotDirectory: './.vitest-browser/screenshots' } },