From f9615b30abe4b4a86aa3f473d7b36bf33bbfee3c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 19 Mar 2026 13:04:04 +0800 Subject: [PATCH] feat(web): add base ui scroll area primitive (#33727) --- .../ui/scroll-area/__tests__/index.spec.tsx | 250 ++++++++ .../base/ui/scroll-area/index.stories.tsx | 563 ++++++++++++++++++ .../components/base/ui/scroll-area/index.tsx | 89 +++ web/eslint.config.mjs | 133 +---- web/eslint.constants.mjs | 125 ++++ 5 files changed, 1035 insertions(+), 125 deletions(-) create mode 100644 web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx create mode 100644 web/app/components/base/ui/scroll-area/index.stories.tsx create mode 100644 web/app/components/base/ui/scroll-area/index.tsx diff --git a/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx new file mode 100644 index 0000000000..170a4771d4 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/__tests__/index.spec.tsx @@ -0,0 +1,250 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaCorner, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '../index' + +const renderScrollArea = (options: { + rootClassName?: string + viewportClassName?: string + verticalScrollbarClassName?: string + horizontalScrollbarClassName?: string + verticalThumbClassName?: string + horizontalThumbClassName?: string +} = {}) => { + return render( + + + +
Scrollable content
+
+
+ + + + + + +
, + ) +} + +describe('scroll-area wrapper', () => { + describe('Rendering', () => { + it('should render the compound exports together', async () => { + renderScrollArea() + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-root')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-viewport')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-content')).toHaveTextContent('Scrollable content') + expect(screen.getByTestId('scroll-area-vertical-scrollbar')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-vertical-thumb')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-horizontal-scrollbar')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-horizontal-thumb')).toBeInTheDocument() + }) + }) + }) + + describe('Scrollbar', () => { + it('should apply the default vertical scrollbar classes and orientation data attribute', async () => { + renderScrollArea() + + await waitFor(() => { + const scrollbar = screen.getByTestId('scroll-area-vertical-scrollbar') + const thumb = screen.getByTestId('scroll-area-vertical-thumb') + + expect(scrollbar).toHaveAttribute('data-orientation', 'vertical') + expect(scrollbar).toHaveClass( + 'flex', + 'touch-none', + 'select-none', + 'opacity-0', + 'transition-opacity', + 'motion-reduce:transition-none', + 'pointer-events-none', + 'data-[hovering]:pointer-events-auto', + 'data-[hovering]:opacity-100', + 'data-[scrolling]:pointer-events-auto', + 'data-[scrolling]:opacity-100', + 'hover:pointer-events-auto', + 'hover:opacity-100', + 'data-[orientation=vertical]:absolute', + 'data-[orientation=vertical]:inset-y-0', + 'data-[orientation=vertical]:right-0', + 'data-[orientation=vertical]:w-3', + 'data-[orientation=vertical]:justify-center', + ) + expect(thumb).toHaveAttribute('data-orientation', 'vertical') + expect(thumb).toHaveClass( + 'shrink-0', + 'rounded-[4px]', + 'bg-state-base-handle', + 'transition-[background-color]', + 'hover:bg-state-base-handle-hover', + 'motion-reduce:transition-none', + 'data-[orientation=vertical]:w-1', + ) + }) + }) + + it('should apply horizontal scrollbar and thumb classes when orientation is horizontal', async () => { + renderScrollArea() + + await waitFor(() => { + const scrollbar = screen.getByTestId('scroll-area-horizontal-scrollbar') + const thumb = screen.getByTestId('scroll-area-horizontal-thumb') + + expect(scrollbar).toHaveAttribute('data-orientation', 'horizontal') + expect(scrollbar).toHaveClass( + 'flex', + 'touch-none', + 'select-none', + 'opacity-0', + 'transition-opacity', + 'motion-reduce:transition-none', + 'pointer-events-none', + 'data-[hovering]:pointer-events-auto', + 'data-[hovering]:opacity-100', + 'data-[scrolling]:pointer-events-auto', + 'data-[scrolling]:opacity-100', + 'hover:pointer-events-auto', + 'hover:opacity-100', + 'data-[orientation=horizontal]:absolute', + 'data-[orientation=horizontal]:inset-x-0', + 'data-[orientation=horizontal]:bottom-0', + 'data-[orientation=horizontal]:h-3', + 'data-[orientation=horizontal]:items-center', + ) + expect(thumb).toHaveAttribute('data-orientation', 'horizontal') + expect(thumb).toHaveClass( + 'shrink-0', + 'rounded-[4px]', + 'bg-state-base-handle', + 'transition-[background-color]', + 'hover:bg-state-base-handle-hover', + 'motion-reduce:transition-none', + 'data-[orientation=horizontal]:h-1', + ) + }) + }) + }) + + describe('Props', () => { + it('should forward className to the viewport', async () => { + renderScrollArea({ + viewportClassName: 'custom-viewport-class', + }) + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-viewport')).toHaveClass( + 'size-full', + 'min-h-0', + 'min-w-0', + 'outline-none', + 'focus-visible:ring-1', + 'focus-visible:ring-inset', + 'focus-visible:ring-components-input-border-hover', + 'custom-viewport-class', + ) + }) + }) + }) + + describe('Corner', () => { + it('should render the corner export when both axes overflow', async () => { + const originalDescriptors = { + clientHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientHeight'), + clientWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'clientWidth'), + scrollHeight: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollHeight'), + scrollWidth: Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, 'scrollWidth'), + } + + Object.defineProperties(HTMLDivElement.prototype, { + clientHeight: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0 + }, + }, + clientWidth: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 80 : 0 + }, + }, + scrollHeight: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0 + }, + }, + scrollWidth: { + configurable: true, + get() { + return this.getAttribute('data-testid') === 'scroll-area-viewport' ? 160 : 0 + }, + }, + }) + + try { + render( + + + +
Scrollable content
+
+
+ + + + + + + +
, + ) + + await waitFor(() => { + expect(screen.getByTestId('scroll-area-corner')).toBeInTheDocument() + expect(screen.getByTestId('scroll-area-corner')).toHaveClass('bg-transparent') + }) + } + finally { + if (originalDescriptors.clientHeight) { + Object.defineProperty(HTMLDivElement.prototype, 'clientHeight', originalDescriptors.clientHeight) + } + if (originalDescriptors.clientWidth) { + Object.defineProperty(HTMLDivElement.prototype, 'clientWidth', originalDescriptors.clientWidth) + } + if (originalDescriptors.scrollHeight) { + Object.defineProperty(HTMLDivElement.prototype, 'scrollHeight', originalDescriptors.scrollHeight) + } + if (originalDescriptors.scrollWidth) { + Object.defineProperty(HTMLDivElement.prototype, 'scrollWidth', originalDescriptors.scrollWidth) + } + } + }) + }) +}) diff --git a/web/app/components/base/ui/scroll-area/index.stories.tsx b/web/app/components/base/ui/scroll-area/index.stories.tsx new file mode 100644 index 0000000000..17be6a352d --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.stories.tsx @@ -0,0 +1,563 @@ +import type { Meta, StoryObj } from '@storybook/nextjs-vite' +import type { ReactNode } from 'react' +import AppIcon from '@/app/components/base/app-icon' +import { cn } from '@/utils/classnames' +import { + ScrollArea, + ScrollAreaContent, + ScrollAreaCorner, + ScrollAreaScrollbar, + ScrollAreaThumb, + ScrollAreaViewport, +} from '.' + +const meta = { + title: 'Base/Layout/ScrollArea', + component: ScrollArea, + parameters: { + layout: 'padded', + docs: { + description: { + component: 'Compound scroll container built on Base UI ScrollArea. These stories focus on panel-style compositions that already exist throughout Dify: dense sidebars, sticky list headers, multi-pane workbenches, horizontal rails, and overlay surfaces.', + }, + }, + }, + tags: ['autodocs'], +} satisfies Meta + +export default meta +type Story = StoryObj + +const panelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5' +const blurPanelClassName = 'overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl shadow-shadow-shadow-7 backdrop-blur-[6px]' +const labelClassName = 'text-text-tertiary system-xs-medium-uppercase tracking-[0.14em]' +const titleClassName = 'text-text-primary system-sm-semibold' +const bodyClassName = 'text-text-secondary system-sm-regular' +const insetScrollAreaClassName = 'h-full p-1' +const insetViewportClassName = 'rounded-[20px] bg-components-panel-bg' +const insetScrollbarClassName = 'data-[orientation=vertical]:top-1 data-[orientation=vertical]:bottom-1 data-[orientation=vertical]:right-1 data-[orientation=horizontal]:bottom-1 data-[orientation=horizontal]:left-1 data-[orientation=horizontal]:right-1' +const storyButtonClassName = 'flex w-full items-center justify-between gap-3 rounded-xl border border-divider-subtle bg-components-panel-bg-alt px-3 py-2.5 text-left text-text-secondary transition-colors hover:bg-state-base-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' +const sidebarScrollAreaClassName = 'h-full pr-2' +const sidebarViewportClassName = 'overscroll-contain pr-2' +const sidebarContentClassName = 'space-y-0.5 pr-2' +const sidebarScrollbarClassName = 'data-[orientation=vertical]:right-0.5' +const appNavButtonClassName = 'group flex h-8 w-full items-center justify-between gap-3 rounded-lg px-2 text-left transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover motion-reduce:transition-none' +const appNavMetaClassName = 'shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 text-text-quaternary system-2xs-medium-uppercase tracking-[0.08em]' + +const releaseRows = [ + { title: 'Agent refactor', meta: 'Updated 2 hours ago', status: 'Ready' }, + { title: 'Retriever tuning', meta: 'Updated yesterday', status: 'Review' }, + { title: 'Workflow replay', meta: 'Updated 3 days ago', status: 'Draft' }, + { title: 'Sandbox policy', meta: 'Updated this week', status: 'Ready' }, + { title: 'SSE diagnostics', meta: 'Updated last week', status: 'Blocked' }, + { title: 'Model routing', meta: 'Updated 9 days ago', status: 'Review' }, + { title: 'Chunk overlap', meta: 'Updated 11 days ago', status: 'Draft' }, + { title: 'Vector warmup', meta: 'Updated 2 weeks ago', status: 'Ready' }, +] as const + +const queueRows = [ + { id: 'PLG-142', title: 'Plugin catalog sync', note: 'Waiting for moderation result' }, + { id: 'OPS-088', title: 'Billing alert fallback', note: 'Last retry finished 12 minutes ago' }, + { id: 'RAG-511', title: 'Embedding migration', note: '16 datasets still pending' }, + { id: 'AGT-204', title: 'Multi-agent tracing', note: 'QA is verifying edge cases' }, + { id: 'UI-390', title: 'Prompt editor polish', note: 'Needs token density pass' }, + { id: 'WEB-072', title: 'Marketplace empty state', note: 'Waiting for design review' }, +] as const + +const horizontalCards = [ + { title: 'Claude Opus', detail: 'Reasoning-heavy preset' }, + { title: 'GPT-5.4', detail: 'Balanced orchestration lane' }, + { title: 'Gemini 2.5', detail: 'Multimodal fallback' }, + { title: 'Qwen Max', detail: 'Regional deployment' }, + { title: 'DeepSeek R1', detail: 'High-throughput analysis' }, + { title: 'Llama 4', detail: 'Cost-sensitive routing' }, +] as const + +const activityRows = Array.from({ length: 14 }, (_, index) => ({ + title: `Workspace activity ${index + 1}`, + body: 'A short line of copy to mimic dense operational feeds in settings and debug panels.', +})) + +const webAppsRows = [ + { id: 'invoice-copilot', name: 'Invoice Copilot', meta: 'Pinned', icon: '🧾', iconBackground: '#FFEAD5', selected: true, pinned: true }, + { id: 'rag-ops', name: 'RAG Ops Console', meta: 'Ops', icon: 'πŸ›°οΈ', iconBackground: '#E0F2FE', selected: false, pinned: true }, + { id: 'knowledge-studio', name: 'Knowledge Studio', meta: 'Docs', icon: 'πŸ“š', iconBackground: '#FEF3C7', selected: false, pinned: true }, + { id: 'workflow-studio', name: 'Workflow Studio', meta: 'Build', icon: '🧩', iconBackground: '#E0E7FF', selected: false, pinned: true }, + { id: 'growth-briefs', name: 'Growth Briefs', meta: 'Brief', icon: 'πŸ“£', iconBackground: '#FCE7F3', selected: false, pinned: true }, + { id: 'agent-playground', name: 'Agent Playground', meta: 'Lab', icon: 'πŸ§ͺ', iconBackground: '#DCFCE7', selected: false, pinned: false }, + { id: 'sales-briefing', name: 'Sales Briefing', meta: 'Team', icon: 'πŸ“ˆ', iconBackground: '#FCE7F3', selected: false, pinned: false }, + { id: 'support-triage', name: 'Support Triage', meta: 'Queue', icon: '🎧', iconBackground: '#EDE9FE', selected: false, pinned: false }, + { id: 'legal-review', name: 'Legal Review', meta: 'Beta', icon: 'βš–οΈ', iconBackground: '#FDE68A', selected: false, pinned: false }, + { id: 'release-watcher', name: 'Release Watcher', meta: 'Feed', icon: 'πŸš€', iconBackground: '#DBEAFE', selected: false, pinned: false }, + { id: 'research-hub', name: 'Research Hub', meta: 'Notes', icon: 'πŸ”Ž', iconBackground: '#E0F2FE', selected: false, pinned: false }, + { id: 'field-enablement', name: 'Field Enablement', meta: 'Team', icon: '🧭', iconBackground: '#DCFCE7', selected: false, pinned: false }, + { id: 'brand-monitor', name: 'Brand Monitor', meta: 'Watch', icon: 'πŸͺ„', iconBackground: '#F3E8FF', selected: false, pinned: false }, + { id: 'finance-ops', name: 'Finance Ops Desk', meta: 'Ops', icon: 'πŸ’³', iconBackground: '#FEF3C7', selected: false, pinned: false }, + { id: 'security-radar', name: 'Security Radar', meta: 'Risk', icon: 'πŸ›‘οΈ', iconBackground: '#FEE2E2', selected: false, pinned: false }, + { id: 'partner-portal', name: 'Partner Portal', meta: 'Ext', icon: '🀝', iconBackground: '#DBEAFE', selected: false, pinned: false }, + { id: 'qa-replays', name: 'QA Replays', meta: 'Debug', icon: '🎞️', iconBackground: '#EDE9FE', selected: false, pinned: false }, + { id: 'roadmap-notes', name: 'Roadmap Notes', meta: 'Plan', icon: 'πŸ—ΊοΈ', iconBackground: '#FFEAD5', selected: false, pinned: false }, +] as const + +const StoryCard = ({ + eyebrow, + title, + description, + className, + children, +}: { + eyebrow: string + title: string + description: string + className?: string + children: ReactNode +}) => ( +
+
+
{eyebrow}
+

{title}

+

{description}

+
+ {children} +
+) + +const VerticalPanelPane = () => ( +
+ + + +
+
Release board
+
Weekly checkpoints
+

A simple vertical panel with the default scrollbar skin and no business-specific overrides.

+
+ {releaseRows.map(item => ( +
+
+
+

{item.title}

+

{item.meta}

+
+ + {item.status} + +
+
+ ))} +
+
+ + + +
+
+) + +const StickyListPane = () => ( +
+ + + +
+
Sticky header
+
+
+
Operational queue
+

The scrollbar is still the shared base/ui primitive, while the pane adds sticky structure and a viewport mask.

+
+ + 24 items + +
+
+
+ {queueRows.map(item => ( +
+
+
+
{item.title}
+
{item.note}
+
+ {item.id} +
+
+ ))} +
+
+
+ + + +
+
+) + +const WorkbenchPane = ({ + title, + eyebrow, + children, + className, +}: { + title: string + eyebrow: string + children: ReactNode + className?: string +}) => ( +
+ + + +
+
{eyebrow}
+
{title}
+
+ {children} +
+
+ + + +
+
+) + +const HorizontalRailPane = () => ( +
+ + + +
+
Horizontal rail
+
Model lanes
+

This pane keeps the default track behavior and only changes the surface layout around it.

+
+
+ {horizontalCards.map(card => ( +
+
+ + + +
{card.title}
+
{card.detail}
+
+
Drag cards into orchestration groups.
+
+ ))} +
+
+
+ + + +
+
+) + +const OverlayPane = () => ( +
+
+ + + +
+
Overlay palette
+
Quick actions
+
+ {activityRows.map(item => ( +
+
+ + + +
+
{item.title}
+
{item.body}
+
+
+
+ ))} +
+
+ + + +
+
+
+) + +const CornerPane = () => ( +
+ + + +
+
+
Corner surface
+
Bi-directional inspector canvas
+

Both axes overflow here so the corner becomes visible as a deliberate seam between the two tracks.

+
+ + Always visible + +
+
+ {Array.from({ length: 12 }, (_, index) => ( +
+
+ Cell + {' '} + {index + 1} +
+

+ Wide-and-tall content to force both scrollbars and show the corner treatment clearly. +

+
+ ))} +
+
+
+ + + + + + + +
+
+) + +const ExploreSidebarWebAppsPane = () => { + const pinnedAppsCount = webAppsRows.filter(item => item.pinned).length + + return ( +
+
+
+
+
+ +
+
+ Explore +
+
+
+ +
+
+

+ Web Apps +

+ + {webAppsRows.length} + +
+ +
+ + + + {webAppsRows.map((item, index) => ( +
+ + {index === pinnedAppsCount - 1 && index !== webAppsRows.length - 1 && ( +
+ )} +
+ ))} + + + + + + +
+
+
+
+ ) +} + +export const VerticalPanels: Story = { + render: () => ( + +
+ + +
+
+ ), +} + +export const ThreePaneWorkbench: Story = { + render: () => ( + +
+ +
+ {releaseRows.map(item => ( + + ))} +
+
+ +
+ {Array.from({ length: 7 }, (_, index) => ( +
+
+
+ Section + {' '} + {index + 1} +
+ + Active + +
+

+ This pane is intentionally long so the default vertical scrollbar sits over a larger editorial surface. +

+
+ ))} +
+
+ +
+ {queueRows.map(item => ( +
+
{item.id}
+
{item.title}
+
{item.note}
+
+ ))} +
+
+
+
+ ), +} + +export const HorizontalAndOverlay: Story = { + render: () => ( +
+ + + + + + +
+ ), +} + +export const CornerSurface: Story = { + render: () => ( + +
+ +
+
+ ), +} + +export const ExploreSidebarWebApps: Story = { + render: () => ( + +
+ +
+
+ ), +} + +export const PrimitiveComposition: Story = { + render: () => ( + +
+ + + + {Array.from({ length: 8 }, (_, index) => ( +
+ Primitive row + {' '} + {index + 1} +
+ ))} +
+
+ + + + + + + +
+
+
+ ), +} diff --git a/web/app/components/base/ui/scroll-area/index.tsx b/web/app/components/base/ui/scroll-area/index.tsx new file mode 100644 index 0000000000..73197b7ee5 --- /dev/null +++ b/web/app/components/base/ui/scroll-area/index.tsx @@ -0,0 +1,89 @@ +'use client' + +import { ScrollArea as BaseScrollArea } from '@base-ui/react/scroll-area' +import * as React from 'react' +import { cn } from '@/utils/classnames' + +export const ScrollArea = BaseScrollArea.Root +export type ScrollAreaRootProps = React.ComponentPropsWithRef + +export const ScrollAreaContent = BaseScrollArea.Content +export type ScrollAreaContentProps = React.ComponentPropsWithRef + +export const scrollAreaScrollbarClassName = cn( + 'flex touch-none select-none opacity-0 transition-opacity motion-reduce:transition-none', + 'pointer-events-none data-[hovering]:pointer-events-auto data-[hovering]:opacity-100', + 'data-[scrolling]:pointer-events-auto data-[scrolling]:opacity-100', + 'hover:pointer-events-auto hover:opacity-100', + 'data-[orientation=vertical]:absolute data-[orientation=vertical]:inset-y-0 data-[orientation=vertical]:right-0 data-[orientation=vertical]:w-3 data-[orientation=vertical]:justify-center', + 'data-[orientation=horizontal]:absolute data-[orientation=horizontal]:inset-x-0 data-[orientation=horizontal]:bottom-0 data-[orientation=horizontal]:h-3 data-[orientation=horizontal]:items-center', +) + +export const scrollAreaThumbClassName = cn( + 'shrink-0 rounded-[4px] bg-state-base-handle transition-[background-color] hover:bg-state-base-handle-hover motion-reduce:transition-none', + 'data-[orientation=vertical]:w-1', + 'data-[orientation=horizontal]:h-1', +) + +export const scrollAreaViewportClassName = cn( + 'size-full min-h-0 min-w-0 outline-none', + 'focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-components-input-border-hover', +) + +export const scrollAreaCornerClassName = 'bg-transparent' + +export type ScrollAreaViewportProps = React.ComponentPropsWithRef + +export function ScrollAreaViewport({ + className, + ...props +}: ScrollAreaViewportProps) { + return ( + + ) +} + +export type ScrollAreaScrollbarProps = React.ComponentPropsWithRef + +export function ScrollAreaScrollbar({ + className, + ...props +}: ScrollAreaScrollbarProps) { + return ( + + ) +} + +export type ScrollAreaThumbProps = React.ComponentPropsWithRef + +export function ScrollAreaThumb({ + className, + ...props +}: ScrollAreaThumbProps) { + return ( + + ) +} + +export type ScrollAreaCornerProps = React.ComponentPropsWithRef + +export function ScrollAreaCorner({ + className, + ...props +}: ScrollAreaCornerProps) { + return ( + + ) +} diff --git a/web/eslint.config.mjs b/web/eslint.config.mjs index 0c7a2554e3..ee26de85e9 100644 --- a/web/eslint.config.mjs +++ b/web/eslint.config.mjs @@ -5,7 +5,13 @@ import tailwindcss from 'eslint-plugin-better-tailwindcss' import hyoban from 'eslint-plugin-hyoban' import sonar from 'eslint-plugin-sonarjs' import storybook from 'eslint-plugin-storybook' -import { OVERLAY_MIGRATION_LEGACY_BASE_FILES } from './eslint.constants.mjs' +import { + HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS, + NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS, + NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS, + OVERLAY_MIGRATION_LEGACY_BASE_FILES, + OVERLAY_RESTRICTED_IMPORT_PATTERNS, +} from './eslint.constants.mjs' import dify from './plugins/eslint/index.js' // Enable Tailwind CSS IntelliSense mode for ESLint runs @@ -14,99 +20,6 @@ process.env.TAILWIND_MODE ??= 'ESLINT' const disableRuleAutoFix = !(isInEditorEnv() || isInGitHooksOrLintStaged()) -const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [ - { - name: 'next', - message: 'Import Next APIs from the corresponding @/next module instead of next.', - }, -] - -const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [ - { - group: ['next/image'], - message: 'Do not import next/image. Use native img tags instead.', - }, - { - group: ['next/font', 'next/font/*'], - message: 'Do not import next/font. Use the project font styles instead.', - }, - { - group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'], - message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.', - }, -] - -const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ - { - group: [ - '**/portal-to-follow-elem', - '**/portal-to-follow-elem/index', - ], - message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.', - }, - { - group: [ - '**/base/tooltip', - '**/base/tooltip/index', - ], - message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.', - }, - { - group: [ - '**/base/modal', - '**/base/modal/index', - '**/base/modal/modal', - ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/select', - '**/base/select/index', - '**/base/select/custom', - '**/base/select/pure', - ], - message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', - }, - { - group: [ - '**/base/confirm', - '**/base/confirm/index', - ], - message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/popover', - '**/base/popover/index', - ], - message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.', - }, - { - group: [ - '**/base/dropdown', - '**/base/dropdown/index', - ], - message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.', - }, - { - group: [ - '**/base/dialog', - '**/base/dialog/index', - ], - message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', - }, - { - group: [ - '**/base/toast', - '**/base/toast/index', - '**/base/toast/context', - '**/base/toast/context/index', - ], - message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.', - }, -] - export default antfu( { react: { @@ -192,37 +105,7 @@ export default antfu( { files: ['**/*.tsx'], rules: { - 'hyoban/prefer-tailwind-icons': ['warn', { - prefix: 'i-', - propMappings: { - size: 'size', - width: 'w', - height: 'h', - }, - libraries: [ - { - prefix: 'i-custom-', - source: '^@/app/components/base/icons/src/(?(?:public|vender)(?:/.*)?)$', - name: '^(?.*)$', - }, - { - source: '^@remixicon/react$', - name: '^(?Ri)(?.+)$', - }, - { - source: '^@(?heroicons)/react/24/outline$', - name: '^(?.*)Icon$', - }, - { - source: '^@(?heroicons)/react/24/(?solid)$', - name: '^(?.*)Icon$', - }, - { - source: '^@(?heroicons)/react/(?\\d+/(?:solid|outline))$', - name: '^(?.*)Icon$', - }, - ], - }], + 'hyoban/prefer-tailwind-icons': ['warn', HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS], }, }, { diff --git a/web/eslint.constants.mjs b/web/eslint.constants.mjs index 2ec571de84..ce19b99c9b 100644 --- a/web/eslint.constants.mjs +++ b/web/eslint.constants.mjs @@ -1,3 +1,96 @@ +export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATHS = [ + { + name: 'next', + message: 'Import Next APIs from the corresponding @/next module instead of next.', + }, +] + +export const NEXT_PLATFORM_RESTRICTED_IMPORT_PATTERNS = [ + { + group: ['next/image'], + message: 'Do not import next/image. Use native img tags instead.', + }, + { + group: ['next/font', 'next/font/*'], + message: 'Do not import next/font. Use the project font styles instead.', + }, + { + group: ['next/*', '!next/font', '!next/font/*', '!next/image', '!next/image/*'], + message: 'Import Next APIs from the corresponding @/next/* module instead of next/*.', + }, +] + +export const OVERLAY_RESTRICTED_IMPORT_PATTERNS = [ + { + group: [ + '**/portal-to-follow-elem', + '**/portal-to-follow-elem/index', + ], + message: 'Deprecated: use semantic overlay primitives from @/app/components/base/ui/ instead. See issue #32767.', + }, + { + group: [ + '**/base/tooltip', + '**/base/tooltip/index', + ], + message: 'Deprecated: use @/app/components/base/ui/tooltip instead. See issue #32767.', + }, + { + group: [ + '**/base/modal', + '**/base/modal/index', + '**/base/modal/modal', + ], + message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/select', + '**/base/select/index', + '**/base/select/custom', + '**/base/select/pure', + ], + message: 'Deprecated: use @/app/components/base/ui/select instead. See issue #32767.', + }, + { + group: [ + '**/base/confirm', + '**/base/confirm/index', + ], + message: 'Deprecated: use @/app/components/base/ui/alert-dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/popover', + '**/base/popover/index', + ], + message: 'Deprecated: use @/app/components/base/ui/popover instead. See issue #32767.', + }, + { + group: [ + '**/base/dropdown', + '**/base/dropdown/index', + ], + message: 'Deprecated: use @/app/components/base/ui/dropdown-menu instead. See issue #32767.', + }, + { + group: [ + '**/base/dialog', + '**/base/dialog/index', + ], + message: 'Deprecated: use @/app/components/base/ui/dialog instead. See issue #32767.', + }, + { + group: [ + '**/base/toast', + '**/base/toast/index', + '**/base/toast/context', + '**/base/toast/context/index', + ], + message: 'Deprecated: use @/app/components/base/ui/toast instead. See issue #32811.', + }, +] + export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/chat/chat-with-history/header/mobile-operation-dropdown.tsx', 'app/components/base/chat/chat-with-history/header/operation.tsx', @@ -27,3 +120,35 @@ export const OVERLAY_MIGRATION_LEGACY_BASE_FILES = [ 'app/components/base/theme-selector.tsx', 'app/components/base/tooltip/index.tsx', ] + +export const HYOBAN_PREFER_TAILWIND_ICONS_OPTIONS = { + prefix: 'i-', + propMappings: { + size: 'size', + width: 'w', + height: 'h', + }, + libraries: [ + { + prefix: 'i-custom-', + source: '^@/app/components/base/icons/src/(?(?:public|vender)(?:/.*)?)$', + name: '^(?.*)$', + }, + { + source: '^@remixicon/react$', + name: '^(?Ri)(?.+)$', + }, + { + source: '^@(?heroicons)/react/24/outline$', + name: '^(?.*)Icon$', + }, + { + source: '^@(?heroicons)/react/24/(?solid)$', + name: '^(?.*)Icon$', + }, + { + source: '^@(?heroicons)/react/(?\\d+/(?:solid|outline))$', + name: '^(?.*)Icon$', + }, + ], +}