mirror of
https://github.com/langgenius/dify.git
synced 2026-11-10 13:26:58 +08:00
chore(lint): enforce logical Tailwind direction classes and migrate dify-ui (#43869)
This commit is contained in:
27 files changed
+4699
-54
No files matched your search
+4495
-1
File diff suppressed because it is too large.
Load diff
@@ -53,6 +53,14 @@ stylesheet:
|
||||
@source not '../../../packages/dify-ui/src/**/*.stories.{ts,tsx}';
|
||||
```
|
||||
|
||||
## Writing direction
|
||||
|
||||
Use logical inline-direction utilities for spacing, offsets, borders, corners, and alignment.
|
||||
Caller overrides must use the same family: `cn` does not merge `pl-*` with `ps-*`.
|
||||
|
||||
Keep block-axis and sizing utilities unchanged. Preserve physical directions for swipe geometry
|
||||
and transform-based centering; explain deliberate physical exceptions with a scoped lint directive.
|
||||
|
||||
## Border radius
|
||||
|
||||
Check the actual radius value in Figma before choosing a `rounded-*` class; matching token names
|
||||
|
||||
@@ -350,7 +350,7 @@ function TagSuggestionItem({ item }: { item: Suggestion }) {
|
||||
<AutocompleteItem value={item}>
|
||||
<AutocompleteItemText>{item.label}</AutocompleteItemText>
|
||||
{item.description && (
|
||||
<span className="ml-auto max-w-36 truncate system-xs-regular text-text-tertiary">
|
||||
<span className="ms-auto max-w-36 truncate system-xs-regular text-text-tertiary">
|
||||
{item.description}
|
||||
</span>
|
||||
)}
|
||||
@@ -697,7 +697,7 @@ function VirtualizedSuggestionList({
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
className="absolute inset-s-0 top-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
|
||||
@@ -229,7 +229,7 @@ export const Collaborators: Story = {
|
||||
aria-label="View 4 collaborators"
|
||||
variant="secondary"
|
||||
size={null}
|
||||
className="rounded-2xl bg-components-panel-bg-blur py-1 pr-1.5 pl-1 inset-ring-components-panel-border hover:bg-components-panel-bg hover:inset-ring-components-panel-border data-popup-open:bg-components-panel-bg"
|
||||
className="rounded-2xl bg-components-panel-bg-blur py-1 ps-1 pe-1.5 inset-ring-components-panel-border hover:bg-components-panel-bg hover:inset-ring-components-panel-border data-popup-open:bg-components-panel-bg"
|
||||
>
|
||||
<span aria-hidden="true" className="inline-flex items-center gap-px">
|
||||
<AvatarGroup size="sm">
|
||||
|
||||
@@ -43,7 +43,7 @@ function DocumentSelectionDemo() {
|
||||
<Checkbox parent />
|
||||
Current page documents
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 pl-6">
|
||||
<div className="flex flex-col gap-2 ps-6">
|
||||
{[
|
||||
{ id: 'doc-1', name: 'onboarding-guide.pdf' },
|
||||
{ id: 'doc-2', name: 'pricing-faq.md' },
|
||||
|
||||
@@ -43,7 +43,7 @@ function TriggerIcon() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open/collapsible:rotate-90 motion-reduce:transition-none"
|
||||
className="i-ri-arrow-right-s-line size-4 shrink-0 text-text-tertiary transition-transform duration-100 ease-out group-data-panel-open/collapsible:rotate-90 motion-reduce:transition-none [&:dir(rtl)]:-scale-x-100 [&:dir(rtl)]:group-data-panel-open/collapsible:-rotate-90"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from '.'
|
||||
import { Button } from '../button'
|
||||
import { cn } from '../cn'
|
||||
import { useDirection } from '../direction-provider'
|
||||
import { Field, FieldDescription, FieldLabel } from '../field'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '../popover'
|
||||
import {
|
||||
@@ -422,7 +423,7 @@ function VirtualizedModelList({
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="absolute top-0 left-0 w-full"
|
||||
className="absolute inset-s-0 top-0 w-full"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
@@ -700,7 +701,7 @@ export const TriggerWithPopupInput: Story = {
|
||||
<div className={fieldWidth}>
|
||||
<Combobox items={dataSourceOptions} defaultValue={defaultPopupDataSource}>
|
||||
<ComboboxLabel>Data source</ComboboxLabel>
|
||||
<ComboboxTrigger className="group/trigger flex h-8 w-full min-w-0 items-center gap-0.5 rounded-lg bg-components-input-bg-normal py-2 pr-2 pl-3 text-start system-sm-regular text-components-input-text-filled transition-colors hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder data-popup-open:bg-state-base-hover-alt motion-reduce:transition-none">
|
||||
<ComboboxTrigger className="group/trigger flex h-8 w-full min-w-0 items-center gap-0.5 rounded-lg bg-components-input-bg-normal py-2 ps-3 pe-2 text-start system-sm-regular text-components-input-text-filled transition-colors hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-placeholder:text-components-input-text-placeholder data-popup-open:bg-state-base-hover-alt motion-reduce:transition-none">
|
||||
<span className="min-w-0 grow truncate">
|
||||
<ComboboxValue placeholder="Choose source" />
|
||||
</span>
|
||||
@@ -818,6 +819,7 @@ export const Grouped: Story = {
|
||||
|
||||
function MultipleChipsDemo() {
|
||||
const [value, setValue] = React.useState<Option[]>(defaultReviewers)
|
||||
const direction = useDirection()
|
||||
|
||||
return (
|
||||
<Field name="reviewers" className={fieldWidth}>
|
||||
@@ -862,7 +864,7 @@ function MultipleChipsDemo() {
|
||||
Selected reviewers wrap inside the input instead of scrolling horizontally.
|
||||
{value.length > 0 && (
|
||||
<span className="sr-only">
|
||||
{` ${value.length} selected. From the start of the input, press Left Arrow to focus the selected items`}
|
||||
{` ${value.length} selected. From the start of the input, press ${direction === 'rtl' ? 'Right' : 'Left'} Arrow to focus the selected items`}
|
||||
</span>
|
||||
)}
|
||||
</FieldDescription>
|
||||
@@ -882,17 +884,19 @@ export const MultipleChips: Story = {
|
||||
)
|
||||
|
||||
const input = canvas.getByRole('combobox', { name: 'Reviewers' })
|
||||
const isRtl = getComputedStyle(input).direction === 'rtl'
|
||||
const previousArrow = isRtl ? 'Right' : 'Left'
|
||||
await expect(input).toHaveAccessibleDescription(
|
||||
'Selected reviewers wrap inside the input instead of scrolling horizontally. 2 selected. From the start of the input, press Left Arrow to focus the selected items',
|
||||
`Selected reviewers wrap inside the input instead of scrolling horizontally. 2 selected. From the start of the input, press ${previousArrow} Arrow to focus the selected items`,
|
||||
)
|
||||
|
||||
input.focus()
|
||||
await userEvent.keyboard('{ArrowLeft}{Delete}')
|
||||
await userEvent.keyboard(`{Arrow${previousArrow}}{Delete}`)
|
||||
|
||||
await expect(canvas.queryByText('Liam Brooks')).not.toBeInTheDocument()
|
||||
await expect(canvas.getByText('Maya Chen')).toBeVisible()
|
||||
await expect(input).toHaveAccessibleDescription(
|
||||
'Selected reviewers wrap inside the input instead of scrolling horizontally. 1 selected. From the start of the input, press Left Arrow to focus the selected items',
|
||||
`Selected reviewers wrap inside the input instead of scrolling horizontally. 1 selected. From the start of the input, press ${previousArrow} Arrow to focus the selected items`,
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -837,6 +837,7 @@ function SwipeToOpenDemo() {
|
||||
className="relative h-90 w-140 overflow-hidden rounded-2xl border-[0.5px] border-divider-subtle bg-background-body"
|
||||
>
|
||||
<Drawer swipeDirection="right" modal={false}>
|
||||
{/* oxlint-disable-next-line better-tailwindcss/enforce-logical-properties -- the swipe area sits on the physical right edge that `swipeDirection="right"` opens from */}
|
||||
<DrawerSwipeArea className="absolute inset-y-0 right-0 z-10 flex w-10 touch-none items-center justify-center bg-state-accent-hover text-text-accent">
|
||||
<span className="rotate-90 text-xs font-medium">Swipe</span>
|
||||
</DrawerSwipeArea>
|
||||
@@ -857,6 +858,7 @@ function SwipeToOpenDemo() {
|
||||
<DrawerBackdrop className="absolute" />
|
||||
<DrawerViewport className="absolute">
|
||||
<DrawerPopup className="absolute touch-auto data-ending-style:duration-[calc(var(--drawer-swipe-strength)*400ms)] data-[swipe-direction=right]:-right-12 data-[swipe-direction=right]:h-full data-[swipe-direction=right]:w-102 data-[swipe-direction=right]:max-w-[calc(100%+3rem)] data-[swipe-direction=right]:transform-[translateX(var(--drawer-swipe-movement-x,0px))] data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(100%-3rem+2px))] data-starting-style:data-[swipe-direction=right]:transform-[translateX(calc(100%-3rem+2px))]">
|
||||
{/* oxlint-disable-next-line better-tailwindcss/enforce-logical-properties -- reserves the physical right strip that the popup leaves off-screen with `-right-12` */}
|
||||
<DrawerContent className="flex min-h-0 flex-1 flex-col p-0 pr-12 pb-0">
|
||||
<div className="flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4">
|
||||
<div className="min-w-0">
|
||||
@@ -923,7 +925,7 @@ function ActionSheetDemo() {
|
||||
<li key={label}>
|
||||
<DrawerClose
|
||||
type="button"
|
||||
className="flex w-full flex-col items-start gap-0.5 bg-transparent px-4 py-3 text-left outline-hidden hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
className="flex w-full flex-col items-start gap-0.5 bg-transparent px-4 py-3 text-start outline-hidden hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span className="text-sm font-medium text-text-secondary">{label}</span>
|
||||
<span className="text-xs text-text-tertiary">{description}</span>
|
||||
|
||||
@@ -597,7 +597,7 @@ export const Filterable: Story = {
|
||||
story: { autoplay: false },
|
||||
description: {
|
||||
story:
|
||||
'Filtering is a Base UI preview feature. Opening with a click or the keyboard focuses the input; opening on hover does not, so the on-screen keyboard stays hidden. Arrow keys move the highlight while the input keeps focus, Enter runs the highlighted action, Tab closes the menu, and Shift+Tab returns focus to the trigger. Groups without matches hide, and the empty state announces when nothing matches. Place the separator between groups inside the later group so it hides with the groups before it. A submenu trigger matches like an item; a submenu stays unfiltered unless its `DropdownMenuSub` is wrapped in its own `DropdownMenuFilterProvider` with its own input, as "Share with" is. The right arrow opens a submenu and focuses its input when it has one, the left arrow closes it from an empty input and returns focus to the parent input, and each level keeps its own query. Pass `autoHighlight` to highlight the first match while typing.',
|
||||
'Filtering is a Base UI preview feature. Opening with a click or the keyboard focuses the input; opening on hover does not, so the on-screen keyboard stays hidden. Arrow keys move the highlight while the input keeps focus, Enter runs the highlighted action, Tab closes the menu, and Shift+Tab returns focus to the trigger. Groups without matches hide, and the empty state announces when nothing matches. Place the separator between groups inside the later group so it hides with the groups before it. A submenu trigger matches like an item; a submenu stays unfiltered unless its `DropdownMenuSub` is wrapped in its own `DropdownMenuFilterProvider` with its own input, as "Share with" is. In LTR, the right arrow opens a submenu and focuses its input when it has one, the left arrow closes it from an empty input and returns focus to the parent input, and each level keeps its own query. RTL reverses these horizontal keys. Pass `autoHighlight` to highlight the first match while typing.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -609,6 +609,9 @@ export const Filterable: Story = {
|
||||
|
||||
await userEvent.click(trigger)
|
||||
const input = await body.findByRole('searchbox', { name: 'Filter actions' })
|
||||
const isRtl = getComputedStyle(input).direction === 'rtl'
|
||||
const openSubmenuKey = isRtl ? '{ArrowLeft}' : '{ArrowRight}'
|
||||
const closeSubmenuKey = isRtl ? '{ArrowRight}' : '{ArrowLeft}'
|
||||
await waitFor(async () => {
|
||||
await expect(input).toHaveFocus()
|
||||
})
|
||||
@@ -644,12 +647,12 @@ export const Filterable: Story = {
|
||||
'data-highlighted',
|
||||
)
|
||||
})
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await userEvent.keyboard(openSubmenuKey)
|
||||
const personalSpace = await body.findByRole('menuitem', { name: 'Personal space' })
|
||||
await waitFor(async () => {
|
||||
await expect(personalSpace).toHaveFocus()
|
||||
})
|
||||
await userEvent.keyboard('{ArrowLeft}')
|
||||
await userEvent.keyboard(closeSubmenuKey)
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('menuitem', { name: 'Team space' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -663,13 +666,13 @@ export const Filterable: Story = {
|
||||
'data-highlighted',
|
||||
)
|
||||
})
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await userEvent.keyboard(openSubmenuKey)
|
||||
const submenuInput = await body.findByRole('searchbox', { name: 'Filter teams' })
|
||||
await waitFor(async () => {
|
||||
await expect(submenuInput).toHaveFocus()
|
||||
})
|
||||
// The left arrow closes the submenu only from an empty input; with text it moves the caret.
|
||||
await userEvent.keyboard('{ArrowLeft}')
|
||||
// The closing arrow key dismisses the submenu only from an empty input; with text it moves the caret.
|
||||
await userEvent.keyboard(closeSubmenuKey)
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('searchbox', { name: 'Filter teams' })).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -677,7 +680,7 @@ export const Filterable: Story = {
|
||||
await expect(input).toHaveFocus()
|
||||
})
|
||||
await expect(input).toHaveValue('share')
|
||||
await userEvent.keyboard('{ArrowRight}')
|
||||
await userEvent.keyboard(openSubmenuKey)
|
||||
const reopenedSubmenuInput = await body.findByRole('searchbox', { name: 'Filter teams' })
|
||||
await waitFor(async () => {
|
||||
await expect(reopenedSubmenuInput).toHaveFocus()
|
||||
|
||||
@@ -161,7 +161,7 @@ export const InTooltip: Story = {
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<IconButton aria-label="Collapse sidebar" size="lg" variant="secondary">
|
||||
<span aria-hidden className="i-ri-sidebar-fold-line size-4" />
|
||||
<span aria-hidden className="i-ri-sidebar-fold-line size-4 [&:dir(rtl)]:-scale-x-100" />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -146,7 +146,7 @@ function OptionCardsDemo() {
|
||||
value={option.value}
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
|
||||
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-start outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export const Anatomy: Story = {
|
||||
role="region"
|
||||
className="border-[0.5px] border-divider-subtle bg-components-panel-bg"
|
||||
>
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 pr-5 pl-3 system-sm-regular leading-6 text-text-secondary">
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 ps-3 pe-5 system-sm-regular leading-6 text-text-secondary">
|
||||
{articleParagraphs.map((paragraph) => (
|
||||
<p key={paragraph}>{paragraph}</p>
|
||||
))}
|
||||
@@ -142,7 +142,7 @@ export const Vertical: Story = {
|
||||
role="region"
|
||||
className="border-[0.5px] border-divider-subtle bg-components-panel-bg"
|
||||
>
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 system-sm-regular leading-6 text-text-secondary">
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pe-6 system-sm-regular leading-6 text-text-secondary">
|
||||
<div className="space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">Article</div>
|
||||
<div className="system-md-semibold text-text-primary">Scrollable text region</div>
|
||||
@@ -213,7 +213,7 @@ export const ScrollFade: Story = {
|
||||
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%-min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
)}
|
||||
>
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 system-sm-regular leading-6 text-text-secondary">
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pe-6 system-sm-regular leading-6 text-text-secondary">
|
||||
{Array.from({ length: 5 }, (_, groupIndex) =>
|
||||
articleParagraphs.map((paragraph) => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>{paragraph}</p>
|
||||
@@ -277,7 +277,7 @@ export const BothAxes: Story = {
|
||||
role="region"
|
||||
className="border-[0.5px] border-divider-subtle bg-components-panel-bg"
|
||||
>
|
||||
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
|
||||
<ScrollAreaContent className="ps-3 pe-6 pt-3 pb-6">
|
||||
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
|
||||
{gridCells.map((cell) => (
|
||||
<div
|
||||
|
||||
@@ -66,7 +66,7 @@ function SegmentedControlExample({
|
||||
</SegmentedControlItem>
|
||||
{index === 1 && (
|
||||
<span
|
||||
className="pointer-events-none absolute top-0 -right-px flex h-full items-center"
|
||||
className="pointer-events-none absolute -inset-e-px top-0 flex h-full items-center"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SegmentedControlDivider />
|
||||
|
||||
@@ -81,10 +81,9 @@ const selectTriggerVariants = cva(
|
||||
variants: {
|
||||
size: {
|
||||
// The text sits 4px further in than the icon: 8px around the control, 4px more for text.
|
||||
// Physical padding, so callers' `px-*`/`pl-*`/`pr-*` overrides keep replacing it.
|
||||
small: 'h-6 gap-px rounded-md py-1 pr-1 pl-2 system-xs-regular',
|
||||
medium: 'h-8 gap-0.5 rounded-lg py-2 pr-2 pl-3 system-sm-regular',
|
||||
large: 'h-9 gap-0.5 rounded-[10px] py-2 pr-3 pl-4 system-md-regular',
|
||||
small: 'h-6 gap-px rounded-md py-1 ps-2 pe-1 system-xs-regular',
|
||||
medium: 'h-8 gap-0.5 rounded-lg py-2 ps-3 pe-2 system-sm-regular',
|
||||
large: 'h-9 gap-0.5 rounded-[10px] py-2 ps-4 pe-3 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -122,7 +122,7 @@ function AllStatesDemo() {
|
||||
<div style={{ width: '600px' }} className="space-y-6">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500">
|
||||
<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">Disabled</th>
|
||||
|
||||
@@ -188,7 +188,7 @@ function CharacterCounterDemo() {
|
||||
rows={4}
|
||||
className="resize-y pb-8"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-xs-medium text-text-quaternary">
|
||||
<div className="pointer-events-none absolute inset-e-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 system-xs-medium text-text-quaternary">
|
||||
<span>{value.length}</span>/<span className="text-text-tertiary">{maxLength}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -151,7 +151,7 @@ function ToastCard({ toast: toastItem, children }: ToastCardProps) {
|
||||
swipeDirection={['up', 'right']}
|
||||
className={cn(
|
||||
focusRingClassName,
|
||||
'group/toast pointer-events-auto absolute top-0 right-0 w-full origin-top cursor-default rounded-xl select-none',
|
||||
'group/toast pointer-events-auto absolute inset-s-0 top-0 w-full origin-top cursor-default rounded-xl select-none',
|
||||
'[--toast-current-height:var(--toast-frontmost-height,var(--toast-height))] [--toast-expanded-offset-y:calc(var(--toast-offset-y)+var(--toast-swipe-movement-y)+(var(--toast-index)*var(--toast-gap)))] [--toast-gap:8px] [--toast-peek:5px] [--toast-scale:calc(1-(var(--toast-index)*0.0225))] [--toast-shrink:calc(1-var(--toast-scale))]',
|
||||
'z-[calc(100-var(--toast-index))] h-(--toast-current-height)',
|
||||
'[transition:transform_500ms_cubic-bezier(0.22,1,0.36,1),opacity_500ms,height_150ms] motion-reduce:transition-none',
|
||||
@@ -161,7 +161,7 @@ function ToastCard({ toast: toastItem, children }: ToastCardProps) {
|
||||
'data-ending-style:data-[swipe-direction=up]:transform-[translateY(calc(var(--toast-swipe-movement-y)-150%))]',
|
||||
'data-ending-style:data-[swipe-direction=right]:transform-[translateX(calc(var(--toast-swipe-movement-x)+150%))_translateY(var(--toast-expanded-offset-y))]',
|
||||
'data-limited:opacity-0 data-starting-style:transform-[translateY(-150%)] data-starting-style:opacity-0',
|
||||
"after:pointer-events-auto after:absolute after:bottom-full after:left-0 after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']",
|
||||
"after:pointer-events-auto after:absolute after:inset-s-0 after:bottom-full after:h-[calc(var(--toast-gap)+1px)] after:w-full after:content-['']",
|
||||
)}
|
||||
>
|
||||
<div className="relative h-full overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
@@ -240,7 +240,11 @@ function Toaster({
|
||||
<BaseToast.Provider toastManager={toastManager} timeout={timeout} limit={limit}>
|
||||
<BaseToast.Portal>
|
||||
<BaseToast.Viewport
|
||||
className="pointer-events-none fixed top-4 right-4 z-50 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8"
|
||||
className={
|
||||
// Once the offset API is removed, migrate placement, horizontal swipe, and exit animations together to logical inline-end.
|
||||
// oxlint-disable-next-line better-tailwindcss/enforce-logical-properties -- Dify's offset.right API currently requires physical right positioning, paired with rightward swipe dismissal.
|
||||
'pointer-events-none fixed top-4 right-4 z-50 w-90 max-w-[calc(100vw-2rem)] overflow-visible sm:right-8'
|
||||
}
|
||||
style={offset}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -89,7 +89,18 @@ Oxlint and Vite+ type-check scope is defined by `packages/tooling/lint.config.ts
|
||||
|
||||
The primary rule baseline lives in `packages/tooling/lint.config.ts` and is connected through the root `vite.config.ts` `lint` block. Oxlint-native rules are preferred, and compatible ESLint rules can run through Oxlint's `jsPlugins` support. The rules are explicit snapshots of the ESLint configurations that were active at migration time. Do not import an upstream preset wholesale: enable a new rule intentionally and review its existing violations first.
|
||||
|
||||
Tailwind canonical class cleanup is optional because loading the JavaScript plugin adds noticeable lint startup time. The default `vp run -w check` command does not load it. Run `vp run -w lint:tailwind` to inspect `web/` and `packages/dify-ui/`, or `vp run -w lint:tailwind:fix` to apply safe replacements. Both commands run the complete lint configuration with the additional `better-tailwindcss/enforce-canonical-classes` rule, using `web/app/styles/globals.css` and a 16px root font size.
|
||||
The Tailwind plugin always loads. `better-tailwindcss/enforce-logical-properties` enforces
|
||||
logical inline-direction classes in Web and Dify UI; block-axis, sizing, explicit direction,
|
||||
physical swipe, and centering exceptions remain physical. Dify UI is migrated without a baseline;
|
||||
existing Web findings use the per-file bulk suppressions described below.
|
||||
|
||||
Migrate with scoped `vp lint --fix <paths>` and `vp fmt <paths>`. Autofix also rewrites suppressed
|
||||
classes, so review caller overrides and the suppression diff. CSS modules, inline styles, and
|
||||
dynamic classes need separate review.
|
||||
|
||||
Canonical-class cleanup remains optional: `vp run -w lint:tailwind` and
|
||||
`vp run -w lint:tailwind:fix` additionally enable `better-tailwindcss/enforce-canonical-classes`
|
||||
at warning level, using `web/app/styles/globals.css` and a 16px root font size.
|
||||
|
||||
The non-code baseline and its repository-wide file scope live in `packages/tooling/eslint.ts`. ESLint checks JSON, JSONC, JSON5, YAML, and Markdown only. The configuration globally ignores JavaScript, JSX, TypeScript, TSX, and declaration files; a comment-only inventory records the removed code checks as a migration tradeoff. It does not import or depend on the Antfu ESLint config.
|
||||
|
||||
|
||||
@@ -383,6 +383,32 @@ const tailwindCanonicalClassesOverride = {
|
||||
},
|
||||
} satisfies NonNullable<OxlintConfig['overrides']>[number]
|
||||
|
||||
// Web's existing violations use the per-file suppression baseline; Dify UI is fully migrated.
|
||||
const tailwindLogicalPropertiesOverride = {
|
||||
files: [
|
||||
'web/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}',
|
||||
'packages/dify-ui/**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'better-tailwindcss/enforce-logical-properties': [
|
||||
'error',
|
||||
{
|
||||
ignore: [
|
||||
// Block-axis and sizing classes do not depend on writing direction;
|
||||
// keep their physical names (`pt`, `h-4`) instead of `pbs`, `block-4`.
|
||||
'(?:^|[:!])-?(?:pt|pb|mt|mb|scroll-pt|scroll-pb|scroll-mt|scroll-mb|top|bottom|border-t|border-b|w|h|size|min-w|min-h|max-w|max-h)(?:-|$)',
|
||||
// Direction variants and Base UI swipe directions name the physical side on purpose.
|
||||
'(?:^|:)(?:rtl|ltr):',
|
||||
'swipe-direction=(?:left|right)\\]:',
|
||||
// Half-width offsets paired with physical horizontal translations must stay
|
||||
// physical to remain centered under `dir="rtl"`.
|
||||
'(?:^|[:!])-?(?:left|right)-1/2$',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} satisfies NonNullable<OxlintConfig['overrides']>[number]
|
||||
|
||||
// Oxlint's default handler lists omit pointer and touch activation.
|
||||
const jsxInteractionHandlers = [
|
||||
'onClick',
|
||||
@@ -537,7 +563,7 @@ export const lintConfig = {
|
||||
'@shadcn/lint',
|
||||
'@tanstack/eslint-plugin-query',
|
||||
'eslint-plugin-antfu',
|
||||
...(enableTailwindCanonicalClasses ? ['eslint-plugin-better-tailwindcss'] : []),
|
||||
'eslint-plugin-better-tailwindcss',
|
||||
{
|
||||
name: 'dify',
|
||||
specifier: './plugins/eslint/index.ts',
|
||||
@@ -570,15 +596,11 @@ export const lintConfig = {
|
||||
typeCheck: true,
|
||||
},
|
||||
settings: {
|
||||
...(enableTailwindCanonicalClasses
|
||||
? {
|
||||
'better-tailwindcss': {
|
||||
cwd: path.resolve(rootDir, 'web'),
|
||||
entryPoint: 'app/styles/globals.css',
|
||||
rootFontSize: 16,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
'better-tailwindcss': {
|
||||
cwd: path.resolve(rootDir, 'web'),
|
||||
entryPoint: 'app/styles/globals.css',
|
||||
rootFontSize: 16,
|
||||
},
|
||||
'react-x': {
|
||||
additionalStateHooks: '/^use\\w*State(?:s)?|useAtom$/u',
|
||||
},
|
||||
@@ -883,6 +905,7 @@ export const lintConfig = {
|
||||
},
|
||||
overrides: [
|
||||
...(enableTailwindCanonicalClasses ? [tailwindCanonicalClassesOverride] : []),
|
||||
tailwindLogicalPropertiesOverride,
|
||||
{
|
||||
files: ['**/*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}'],
|
||||
rules: {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { expect, it } from 'vite-plus/test'
|
||||
import { lintConfig } from './lint.config.ts'
|
||||
|
||||
const rule = 'better-tailwindcss/enforce-logical-properties'
|
||||
|
||||
function fixture() {
|
||||
const require = createRequire(import.meta.url)
|
||||
const viteRequire = createRequire(require.resolve('vite-plus/package.json'))
|
||||
const oxlintBin = join(dirname(dirname(viteRequire.resolve('oxlint'))), 'bin', 'oxlint')
|
||||
const directory = mkdtempSync(join(tmpdir(), 'dify-logical-properties-'))
|
||||
writeFileSync(
|
||||
join(directory, '.oxlintrc.json'),
|
||||
JSON.stringify({
|
||||
categories: { correctness: 'off' },
|
||||
jsPlugins: lintConfig.jsPlugins.filter(
|
||||
(plugin) =>
|
||||
typeof plugin === 'string' && plugin.includes('eslint-plugin-better-tailwindcss'),
|
||||
),
|
||||
settings: { 'better-tailwindcss': lintConfig.settings['better-tailwindcss'] },
|
||||
options: { reportUnusedDisableDirectives: 'error' },
|
||||
overrides: lintConfig.overrides.filter((override) => rule in override.rules),
|
||||
}),
|
||||
)
|
||||
return {
|
||||
close: () => rmSync(directory, { recursive: true, force: true }),
|
||||
lint(source: string, file = 'web/example.tsx', flags: string[] = []) {
|
||||
const target = join(directory, file)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
writeFileSync(target, source)
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[oxlintBin, '--config', '.oxlintrc.json', '--format', 'json', ...flags, file],
|
||||
{ cwd: directory, encoding: 'utf8' },
|
||||
)
|
||||
expect(result.error).toBeUndefined()
|
||||
expect([0, 1], result.stderr + result.stdout).toContain(result.status)
|
||||
return { status: result.status, source: readFileSync(target, 'utf8'), output: result.stdout }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('enforces logical classes in both Web and Dify UI without the canonical-class flag', () => {
|
||||
const test = fixture()
|
||||
try {
|
||||
for (const file of ['web/example.tsx', 'packages/dify-ui/src/example.tsx']) {
|
||||
const result = test.lint('<div className="pl-2 text-right rounded-tl-md" />', file)
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.output).toContain('enforce-logical-properties')
|
||||
const fixed = test.lint('<div className="pl-2 text-right rounded-tl-md" />', file, ['--fix'])
|
||||
expect(fixed.status).toBe(0)
|
||||
expect(fixed.source).toContain('ps-2 text-end rounded-ss-md')
|
||||
}
|
||||
} finally {
|
||||
test.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves physical geometry and described exceptions while rejecting ordinary offsets', () => {
|
||||
const test = fixture()
|
||||
try {
|
||||
expect(
|
||||
test.lint(
|
||||
'<div className="pt-2 mb-4 top-0 h-8 w-8 border-b left-1/2 -translate-x-1/2 rtl:pl-2 ltr:pr-2 data-[swipe-direction=right]:right-0 data-[swipe-direction=left]:rounded-r-2xl" />',
|
||||
).status,
|
||||
).toBe(0)
|
||||
expect(
|
||||
test.lint(
|
||||
'// oxlint-disable-next-line better-tailwindcss/enforce-logical-properties -- The stack exits toward the physical right.\nconst toast = <div className="right-4" />',
|
||||
).status,
|
||||
).toBe(0)
|
||||
expect(test.lint('<div className="right-4" />').status).toBe(1)
|
||||
} finally {
|
||||
test.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('baselines Web debt, rejects additional violations, and fixes baselined classes', () => {
|
||||
const test = fixture()
|
||||
try {
|
||||
const source = '<div className="pl-2" />'
|
||||
expect(test.lint(source, undefined, ['--suppress-all']).status).toBe(0)
|
||||
expect(test.lint(source).status).toBe(0)
|
||||
expect(test.lint('<div className="pl-2 pr-4" />').status).toBe(1)
|
||||
expect(test.lint(source, 'packages/dify-ui/src/example.tsx').status).toBe(1)
|
||||
const fixed = test.lint(source, undefined, ['--fix'])
|
||||
expect(fixed.status).toBe(0)
|
||||
expect(fixed.source).toContain('ps-2')
|
||||
expect(test.lint(source).status).toBe(1)
|
||||
} finally {
|
||||
test.close()
|
||||
}
|
||||
})
|
||||
@@ -83,7 +83,7 @@ export function CredentialField({
|
||||
id={inputId}
|
||||
aria-invalid={Boolean(error)}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
className="pl-2"
|
||||
className="ps-2"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<StatusDot />
|
||||
|
||||
@@ -42,7 +42,7 @@ const InputTypeSelectField = ({
|
||||
disabled={disabled}
|
||||
onValueChange={handleInputTypeChange}
|
||||
>
|
||||
<SelectTrigger id={field.name} className="gap-x-0.5 pl-2">
|
||||
<SelectTrigger id={field.name} className="gap-x-0.5 ps-2">
|
||||
<Trigger option={selected} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-92 bg-components-panel-bg-blur shadow-shadow-shadow-5">
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
@apply mr-1 h-3 w-3 stroke-current stroke-2;
|
||||
}
|
||||
.select {
|
||||
@apply !h-7 rounded-md !bg-gray-50 !py-0 !pl-2 !text-xs !shadow-none hover:!bg-gray-100;
|
||||
@apply !h-7 rounded-md !bg-gray-50 !py-0 !ps-2 !text-xs !shadow-none hover:!bg-gray-100;
|
||||
}
|
||||
.selectWrapper {
|
||||
@apply !h-7 w-full;
|
||||
|
||||
+1
-1
@@ -454,7 +454,7 @@ const ConditionItem = ({
|
||||
value={selectedSelectOption?.value ?? null}
|
||||
onValueChange={(value) => value && handleUpdateConditionValue(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-t-none border-0 pl-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
<SelectTrigger className="h-8 rounded-t-none border-0 ps-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
{selectedSelectOption?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ const TypeSelector: FC<TypeSelectorProps> = ({ items, currentValue, onSelect })
|
||||
if (selected) onSelect(selected)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-auto rounded-[5px] bg-transparent p-0.5 pl-1 hover:bg-state-base-hover data-popup-open:bg-state-base-hover">
|
||||
<SelectTrigger className="h-auto w-auto rounded-[5px] bg-transparent p-0.5 ps-1 hover:bg-state-base-hover data-popup-open:bg-state-base-hover">
|
||||
<SelectValue className="system-xs-medium text-text-tertiary" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
|
||||
@@ -363,7 +363,7 @@ const ConditionItem = ({
|
||||
value={selectedSelectOption?.value ?? null}
|
||||
onValueChange={(value) => value && handleUpdateConditionValue(value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 rounded-t-none border-0 pl-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
<SelectTrigger className="h-8 rounded-t-none border-0 ps-2 text-xs hover:bg-components-input-bg-normal focus-visible:bg-components-input-bg-normal">
|
||||
{selectedSelectOption?.name ?? t(($) => $['placeholder.select'], { ns: 'common' })}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -153,7 +153,7 @@ const renderSelectCell = (
|
||||
<SelectTrigger
|
||||
size="small"
|
||||
className={cn(
|
||||
'h-6 w-full min-w-0 rounded-none bg-transparent py-0 pr-6 pl-0 text-text-secondary',
|
||||
'h-6 w-full min-w-0 rounded-none bg-transparent py-0 ps-0 pe-6 text-text-secondary',
|
||||
'hover:bg-transparent focus-visible:bg-transparent',
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user