Merge pull request #952 from iflytek/feature/shadcn-ui-opensource

feat(frontend): refine shadcn UI foundations
This commit is contained in:
XiaoSeS 2026-10-15 08:53:40 +08:00 • committed by GitHub
commit 27f044b0f5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 545 additions and 67 deletions

View file

@ -0,0 +1,32 @@
## Approach
Keep component source in `web/src/shared/ui` and use explicit shadcn aliases for UI, composition,
utilities and hooks. Compare compatible upstream source before adapting individual components;
existing APIs, variants, native attributes, refs and event behavior remain the compatibility baseline.
CSS custom properties own semantic colors, fonts and density. Tailwind consumes these properties.
Regular density remains the default; `data-density="compact"` changes control height and surface
spacing without shrinking default control text. Explicit caller classes and styles remain effective.
Table headings and cells share horizontal padding. Portal content must retain usable independent sizing.
Preserve self-hosted Inter and JetBrains Mono and platform-appropriate fallback fonts. Provide semantic
status colors in both themes, visible focus and invalid styling, and reduced-motion treatment.
Keep the existing Tabs API and panel mounting behavior. Navigate enabled triggers in their rendered
order with arrow keys and Home/End; respect horizontal direction. A valid enabled selection is the
tab stop, otherwise the first enabled trigger is. Recomputing a tab stop must not change selection
or invoke `onValueChange`. An entirely disabled list has no actionable tab stop. Trigger IDs/aria-controls and panel IDs/aria-labelledby
are reserved for the generated association; other supported native attributes and refs remain available.
## Validation
Use component behavior tests and an isolated browser fixture for keyboard, density and validation
contracts. Verify light/dark and regular/compact combinations plus representative page layouts.
Keep test fixtures out of production entry points. Actual backend smoke results are reported separately
from UI fixtures; successful frontend tests alone do not establish backend integration health.
## Risks
Shared font and spacing changes affect many callers. Check existing variants and explicit class/style
overrides, table alignment, nested portals and responsive layouts before accepting the change.
No dependency migration or global component replacement is required.

View file

@ -0,0 +1,26 @@
## Why
Shared controls need a consistent foundation for future page work. The current shadcn configuration
does not explicitly resolve the UI directory, CSS and Tailwind disagree on the sans-serif font,
and shared controls do not expose a consistent density or validation treatment. Tabs also need
complete basic keyboard navigation while preserving optional controlled and uncontrolled values.
## What Changes
- Resolve shadcn aliases to the existing shared directories without replacing component APIs.
- Align self-hosted fonts and semantic light/dark tokens, and add regular/compact container density.
- Apply shared density, focus and invalid states to existing controls, cards and tables.
- Complete Tabs keyboard navigation and retain an entry point when selection is absent or invalid.
- Add behavior-focused regression coverage and verify representative existing pages.
## Capabilities
### New Capabilities
- `shared-ui-foundation`: shared theme, density, component compatibility and keyboard behavior.
## Impact
Changes are limited to frontend configuration, theme, shared UI components and regression tests.
React 19, Tailwind 3, existing Radix primitives and current business behavior are retained.
This change does not redesign business pages or add a global shortcut system.

View file

@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: Component generation uses existing shared directories
The project SHALL configure shadcn aliases to generate UI primitives into `src/shared/ui` and
resolve imports using the existing TypeScript and Vite aliases.
#### Scenario: Preview a new component
- **WHEN** a representative component is previewed with the compatible CLI
- **THEN** its UI file resolves into `src/shared/ui`, without a nested `ui/ui` directory
- **AND** dependencies and source changes can be reviewed before applying them
### Requirement: Shared theme and density are consistent
The frontend SHALL use consistent self-hosted font families and semantic light/dark colors.
Shared controls SHALL support regular and compact container density without reducing default text size.
#### Scenario: Switch density
- **WHEN** a container switches from regular to compact
- **THEN** default controls change from 36px to 32px and card/table spacing follows the shared density
- **AND** table heading and data text remain horizontally aligned
- **AND** caller-provided class and style overrides remain effective
#### Scenario: Validation and reduced motion
- **WHEN** a form control is invalid or receives keyboard focus
- **THEN** it has visible invalid or focus treatment appropriate to the selected theme
- **WHEN** reduced motion is requested
- **THEN** nonessential shared control motion is suppressed
### Requirement: Existing component contracts remain compatible
Shared component changes SHALL retain existing variants, native attributes, refs, events, controlled
and uncontrolled values, form semantics, portal behavior and panel mounting behavior.
#### Scenario: Reuse an existing button variant
- **WHEN** a page renders an existing variant such as `soft`
- **THEN** the variant remains available and existing caller attributes are honored
### Requirement: Tabs are keyboard accessible
Tabs SHALL support arrow-key navigation, Home/End and disabled-item skipping using the current
horizontal direction. At least one enabled trigger SHALL provide a keyboard entry when available.
#### Scenario: Navigate enabled tabs
- **WHEN** the user presses a navigation key on a tab
- **THEN** focus and selection follow the intended enabled trigger in rendered order
- **AND** disabled triggers are skipped
#### Scenario: Missing or invalid selection
- **WHEN** selection is absent, invalid, removed or disabled and an enabled trigger exists
- **THEN** the first enabled trigger remains reachable with Tab
- **AND** establishing that entry does not change selection or invoke the selection callback
#### Scenario: All triggers are disabled
- **WHEN** every trigger is disabled
- **THEN** no trigger is exposed as an actionable keyboard entry
### Requirement: Regression evidence is repeatable
The change SHALL include repeatable component regression tests and pass relevant frontend checks.
Browser fixtures SHALL be separate from production entry points.
#### Scenario: Validate component behavior independently
- **WHEN** the component fixture is tested without a backend
- **THEN** keyboard and density assertions run against actual shared components
- **AND** the result is described as component verification rather than backend acceptance

View file

@ -0,0 +1,14 @@
## Implementation
- [x] 1. Inspect existing components, callers, tokens and compatible shadcn configuration.
- [x] 2. Correct aliases and verify CLI project information and a representative dry-run.
- [x] 3. Align fonts, semantic colors, density and shared validation/focus treatment.
- [x] 4. Complete Tabs navigation and missing-selection keyboard entry behavior.
- [x] 5. Add repeatable behavior and browser regression tests.
## Verification
- [x] 6. Pass relevant frontend typecheck, lint, unit tests and production build.
- [x] 7. Verify light/dark, regular/compact and desktop/mobile component behavior.
- [x] 8. Check representative existing pages, overrides and production entry isolation.
- [x] 9. Record actual verification results and any unverified integration layers.

View file

@ -10,7 +10,10 @@
"cssVariables": true
},
"aliases": {
"components": "@/shared/ui",
"utils": "@/shared/lib/utils"
"components": "@/shared/components",
"ui": "@/shared/ui",
"utils": "@/shared/lib/utils",
"lib": "@/shared/lib",
"hooks": "@/shared/hooks"
}
}

View file

@ -0,0 +1,2 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Shared UI regression fixture</title><link rel="stylesheet" href="/fonts/fonts.css"></head><body><div id="root"></div><div id="skillhub-portals"></div><script type="module" src="./ui-foundation.tsx"></script></body></html>

View file

@ -0,0 +1,44 @@
import { useState } from 'react'
import { createRoot } from 'react-dom/client'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/shared/ui/tabs'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/shared/ui/table'
import { Button } from '@/shared/ui/button'
import { Input } from '@/shared/ui/input'
import { Textarea } from '@/shared/ui/textarea'
import { Card, CardHeader } from '@/shared/ui/card'
import '@/index.css'
const params = new URLSearchParams(location.search)
const mode = params.get('mode')
document.documentElement.classList.toggle('dark', params.get('theme') === 'dark')
document.documentElement.dir = params.get('dir') === 'rtl' ? 'rtl' : 'ltr'
function Fixture() {
const [value, setValue] = useState(mode === 'missing' || mode === 'all-disabled' ? 'missing' : mode === 'empty' || mode === 'none' ? '' : mode === 'selected' ? 'second' : 'first')
const [changes, setChanges] = useState(0)
const [disabled, setDisabled] = useState(mode === 'disabled')
const [removed, setRemoved] = useState(false)
const allDisabled = mode === 'all-disabled'
return <main data-density={params.get('density') ?? 'regular'}>
<button id="before">Before tabs</button>
<Tabs {...(mode === 'none' ? {} : { value })} onValueChange={next => { setValue(next); setChanges(count => count + 1) }}>
<TabsList><TabsTrigger value="first" disabled={allDisabled}>First</TabsTrigger>
{!removed && <TabsTrigger value="second" disabled={allDisabled || disabled}>Second</TabsTrigger>}
<TabsTrigger value="third" disabled={allDisabled}>Third</TabsTrigger></TabsList>
<TabsContent value="first">first content</TabsContent><TabsContent value="second">second content</TabsContent><TabsContent value="third">third content</TabsContent>
</Tabs>
<button id="after">After tabs</button>
<button onClick={() => setDisabled(true)}>Disable second</button><button onClick={() => setRemoved(true)}>Remove second</button>
<output data-testid="changes">{changes}</output>
<Table><TableHeader><TableRow><TableHead>Skill</TableHead></TableRow></TableHeader><TableBody><TableRow><TableCell>Example</TableCell></TableRow></TableBody></Table>
<label htmlFor="normal">Name</label><Input id="normal" />
<label htmlFor="invalid">Version</label><Input id="invalid" aria-invalid="true" aria-describedby="error" /><p id="error">Enter a valid version.</p>
<Textarea aria-label="Description" aria-invalid="true" />
<Button data-testid="default-control">Save</Button><Button variant="soft">Soft</Button>
<Button data-testid="class-override" className="h-12">Large caller</Button>
<Input aria-label="Style override" style={{ height: 52 }} />
<Card className="shadow-none" data-testid="card"><CardHeader>Card</CardHeader></Card>
</main>
}
createRoot(document.getElementById('root')!).render(<Fixture />)

View file

@ -0,0 +1,91 @@
import { expect, test } from '@playwright/test'
const fixture = '/e2e/fixtures/ui-foundation.html'
for (const mode of ['none', 'empty', 'missing']) {
test(`tabs provide entry without selecting when value is ${mode}`, async ({ page }) => {
await page.goto(`${fixture}?mode=${mode}`)
await expect(page.getByRole('tab', { selected: true })).toHaveCount(0)
await expect(page.getByTestId('changes')).toHaveText('0')
await expect(page.locator('[role="tab"][tabindex="0"]')).toHaveCount(1)
await page.locator('#before').focus()
await page.keyboard.press('Tab')
await expect(page.getByRole('tab', { name: 'First' })).toBeFocused()
await expect(page.getByTestId('changes')).toHaveText('0')
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('tab', { name: 'Second' })).toBeFocused()
await expect(page.getByRole('tabpanel')).toHaveText('second content')
await expect(page.getByTestId('changes')).toHaveText('1')
})
}
for (const action of ['Disable second', 'Remove second']) {
test(`tabs recover keyboard entry after ${action}`, async ({ page }) => {
await page.goto(`${fixture}?mode=selected`)
await expect(page.getByRole('tab', { name: 'Second' })).toHaveAttribute('tabindex', '0')
await page.getByRole('button', { name: action, exact: true }).click()
await expect(page.getByTestId('changes')).toHaveText('0')
await expect(page.locator('[role="tab"][tabindex="0"]')).toHaveCount(1)
await page.locator('#before').focus()
await page.keyboard.press('Tab')
await expect(page.getByRole('tab', { name: 'First' })).toBeFocused()
await expect(page.getByTestId('changes')).toHaveText('0')
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('tab', { name: 'Third' })).toBeFocused()
})
}
for (const dir of ['ltr', 'rtl']) {
test(`tabs wrap and skip disabled triggers in ${dir}`, async ({ page }) => {
await page.goto(`${fixture}?mode=disabled&dir=${dir}`)
await page.locator('#before').focus()
await page.keyboard.press('Tab')
await page.keyboard.press(dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight')
await expect(page.getByRole('tab', { name: 'Third' })).toBeFocused()
await page.keyboard.press(dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight')
await expect(page.getByRole('tab', { name: 'First' })).toBeFocused()
await page.keyboard.press('End')
await expect(page.getByRole('tab', { name: 'Third' })).toBeFocused()
await page.keyboard.press('Home')
await expect(page.getByRole('tab', { name: 'First' })).toBeFocused()
await page.keyboard.press(dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft')
await expect(page.getByRole('tab', { name: 'Third' })).toBeFocused()
})
}
test('all-disabled tabs let Tab leave the list', async ({ page }) => {
await page.goto(`${fixture}?mode=all-disabled`)
await expect(page.locator('[role="tab"][tabindex="0"]')).toHaveCount(0)
await page.locator('#before').focus()
await page.keyboard.press('Tab')
await expect(page.locator('#after')).toBeFocused()
await expect(page.getByTestId('changes')).toHaveText('0')
})
for (const density of ['regular', 'compact']) {
for (const theme of ['light', 'dark']) {
test(`controls retain density, overrides and visible states: ${theme}/${density}`, async ({ page }) => {
await page.goto(`${fixture}?density=${density}&theme=${theme}`)
const padding = density === 'compact' ? '8px' : '12px'
for (const property of ['padding-left', 'padding-right']) {
await expect(page.getByRole('columnheader')).toHaveCSS(property, padding)
await expect(page.getByRole('cell')).toHaveCSS(property, padding)
}
await expect(page.getByTestId('default-control')).toHaveCSS('height', density === 'compact' ? '32px' : '36px')
await expect(page.getByLabel('Name', { exact: true })).toHaveCSS('height', density === 'compact' ? '32px' : '36px')
await expect(page.getByTestId('class-override')).toHaveCSS('height', '48px')
await expect(page.getByLabel('Style override')).toHaveCSS('height', '52px')
await expect(page.getByTestId('card')).toHaveCSS('box-shadow', /(?:none|rgba\(0, 0, 0, 0\))/)
const normal = page.getByLabel('Name', { exact: true })
const invalid = page.getByLabel('Version')
expect(await invalid.evaluate(el => getComputedStyle(el).borderColor)).not.toBe(await normal.evaluate(el => getComputedStyle(el).borderColor))
await expect(invalid).toHaveAccessibleDescription('Enter a valid version.')
await normal.focus()
await expect(normal).toHaveCSS('box-shadow', /.+/)
expect(await normal.evaluate(el => getComputedStyle(el).boxShadow)).not.toBe('none')
await page.emulateMedia({ reducedMotion: 'reduce' })
const duration = await page.getByTestId('default-control').evaluate(el => getComputedStyle(el).transitionDuration)
expect(duration.split(',').every(value => parseFloat(value) <= 0.001)).toBe(true)
})
}
}

View file

@ -4,6 +4,8 @@
@layer base {
:root {
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', 'Noto Sans SC', sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
/* ═══════════════════════════════════════════
SkillHub — 白 + 近黑(ref: skillhub-clone, TabTin, skillhub.cn)
纯白底,冷灰卡片 #f2f3f5,近黑按钮 #202020
@ -44,8 +46,12 @@
--surface-glass: 0 0% 100%;
--glow-primary: 0 0% 13%;
--glow-accent: 0 0% 7%;
--success: 160 50% 40%;
--warning: 38 85% 50%;
--success: 160 60% 28%;
--success-foreground: 0 0% 100%;
--info: 217 75% 45%;
--info-foreground: 0 0% 100%;
--warning: 38 90% 50%;
--warning-foreground: 26 60% 12%;
/* ── 品牌渐变 — 无(纯黑) ── */
--brand-start: #202020;
@ -66,6 +72,21 @@
--shadow-btn-primary: 0 1px 2px 0 rgb(0 0 0 / 0.06);
}
:root,
[data-density="regular"] {
--control-height: 2.25rem;
--control-padding-x: 0.75rem;
--table-cell-padding: 0.75rem;
--card-padding: 1.25rem;
}
[data-density="compact"] {
--control-height: 2rem;
--control-padding-x: 0.625rem;
--table-cell-padding: 0.5rem;
--card-padding: 1rem;
}
.dark {
/* ── 基础层 — 暗色保持近黑风格,但提高卡片对比度以改善 hover 可见性 ── */
--background: 0 0% 6%; /* #0f0f0f */
@ -100,8 +121,12 @@
--surface-glass: 0 0% 10%;
--glow-primary: 0 0% 80%;
--glow-accent: 0 0% 60%;
--success: 160 45% 42%;
--warning: 38 80% 48%;
--success: 160 50% 48%;
--success-foreground: 160 70% 8%;
--info: 213 90% 70%;
--info-foreground: 220 60% 12%;
--warning: 38 90% 60%;
--warning-foreground: 26 60% 12%;
/* ── 文字语义 — 提高可读性 ── */
--text-secondary: 0 0% 75%; /* 从 70% 提升 */
@ -136,14 +161,14 @@
body {
@apply bg-background text-foreground antialiased;
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Text', 'PingFang SC', 'HarmonyOS Sans SC', 'Noto Sans SC', system-ui, sans-serif;
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'Plus Jakarta Sans', -apple-system, 'system-ui', 'SF Pro Display', 'PingFang SC', 'HarmonyOS Sans SC', system-ui, sans-serif;
font-family: var(--font-sans);
font-weight: 500;
letter-spacing: -0.02em;
}
@ -160,7 +185,7 @@
}
code, pre, kbd {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-family: var(--font-mono);
}
/* Hide browser-native password reveal button (conflicts with custom toggle) */

View file

@ -5,7 +5,7 @@ describe('buttonVariants', () => {
it('applies default variant and size classes', () => {
const classes = buttonVariants()
expect(classes).toContain('bg-primary')
expect(classes).toContain('h-9')
expect(classes).toContain('h-[var(--control-height)]')
expect(classes).toContain('px-4')
})
@ -55,8 +55,8 @@ describe('buttonVariants', () => {
it('applies icon size classes', () => {
const classes = buttonVariants({ size: 'icon' })
expect(classes).toContain('h-9')
expect(classes).toContain('w-9')
expect(classes).toContain('h-[var(--control-height)]')
expect(classes).toContain('w-[var(--control-height)]')
})
it('always includes base focus-visible and disabled styles', () => {

View file

@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
const buttonVariants = cva(
// 基础:紧致圆角、精准属性过渡、按下回弹、聚焦双层光晕
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,box-shadow,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] select-none',
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[transform,background-color,box-shadow,border-color] duration-150 ease-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 active:scale-[0.97] motion-reduce:transform-none select-none',
{
variants: {
variant: {
@ -25,10 +25,10 @@ const buttonVariants = cva(
'text-primary underline-offset-4 hover:underline active:scale-100',
},
size: {
default: 'h-9 px-4 py-2',
default: 'h-[var(--control-height)] px-4 py-2',
sm: 'h-8 rounded-sm px-3 text-xs',
lg: 'h-10 rounded-lg px-8',
icon: 'h-9 w-9',
icon: 'h-[var(--control-height)] w-[var(--control-height)]',
},
},
defaultVariants: {

View file

@ -6,10 +6,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
<div
ref={ref}
className={cn(
'rounded-md border border-border/60 bg-card text-card-foreground transition-[border-color,box-shadow,transform] duration-150 ease-out hover:border-[hsl(214_50%_88%)] hover:shadow-[var(--shadow-card-hover)]',
'rounded-md border border-border/60 bg-card text-card-foreground transition-[border-color,box-shadow,transform] duration-150 ease-out shadow-[var(--shadow-card)] hover:border-ring/20 hover:shadow-[var(--shadow-card-hover)]',
className
)}
style={{ boxShadow: 'var(--shadow-card)', ...style }}
style={style}
{...props}
/>
)
@ -18,12 +18,12 @@ Card.displayName = 'Card'
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-[var(--card-padding)]', className)} {...props} />
)
)
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn('text-lg font-semibold font-heading leading-none tracking-tight', className)} {...props} />
)
@ -38,13 +38,13 @@ const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttribu
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
({ className, ...props }, ref) => <div ref={ref} className={cn('p-[var(--card-padding)] pt-0', className)} {...props} />
)
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
<div ref={ref} className={cn('flex items-center p-[var(--card-padding)] pt-0', className)} {...props} />
)
)
CardFooter.displayName = 'CardFooter'

View file

@ -4,7 +4,7 @@ import { cn } from '@/shared/lib/utils'
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export const INPUT_BASE_CLASS_NAME =
'flex h-9 w-full rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/70 hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-background focus-visible:ring-4 focus-visible:ring-ring/15 disabled:cursor-not-allowed disabled:opacity-50'
'flex h-[var(--control-height)] w-full rounded-md border border-border/60 bg-background/40 px-[var(--control-padding-x)] py-1.5 text-sm text-foreground ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/70 hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-background focus-visible:ring-4 focus-visible:ring-ring/15 aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50'
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, style, ...props }, ref) => {

View file

@ -10,7 +10,7 @@ import {
describe('shared select contract', () => {
it('keeps the trigger aligned with the existing input styling language', () => {
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-9')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('h-[var(--control-height)]')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('rounded-md')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('border-border/60')
expect(SELECT_TRIGGER_CLASS_NAME).toContain('bg-background/40')

View file

@ -5,10 +5,10 @@ import { getPortalContainer } from '@/shared/lib/portal-container'
import { cn } from '@/shared/lib/utils'
export const SELECT_TRIGGER_CLASS_NAME = cn(
'flex h-9 w-full items-center justify-between gap-2 rounded-md border border-border/60 bg-background/40 px-3 py-1.5 text-sm text-foreground',
'flex h-[var(--control-height)] w-full items-center justify-between gap-2 rounded-md border border-border/60 bg-background/40 px-[var(--control-padding-x)] py-1.5 text-sm text-foreground',
'ring-offset-background transition-[border-color,box-shadow,background-color] duration-150 ease-out',
'hover:border-border focus-visible:outline-none focus-visible:border-ring focus-visible:bg-card focus-visible:ring-4 focus-visible:ring-ring/15',
'disabled:cursor-not-allowed disabled:opacity-50',
'aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50',
'data-[placeholder]:text-muted-foreground [&>span]:line-clamp-1'
)

View file

@ -38,7 +38,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
<th
ref={ref}
className={cn(
'h-12 px-4 text-left align-middle font-medium font-heading text-sm text-muted-foreground [&:has([role=checkbox])]:pr-0',
'h-[var(--control-height)] px-[var(--table-cell-padding)] py-[var(--table-cell-padding)] text-left align-middle font-medium font-heading text-sm text-muted-foreground [&:has([role=checkbox])]:pr-0',
className
)}
{...props}
@ -49,7 +49,7 @@ TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td ref={ref} className={cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
<td ref={ref} className={cn('p-[var(--table-cell-padding)] align-middle [&:has([role=checkbox])]:pr-0', className)} {...props} />
)
)
TableCell.displayName = 'TableCell'

View file

@ -0,0 +1,123 @@
// @vitest-environment jsdom
import { createRef, useState } from 'react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs'
afterEach(cleanup)
function Example({ value, onValueChange = vi.fn() }: { value?: string; onValueChange?: (value: string) => void }) {
return (
<Tabs value={value} onValueChange={onValueChange}>
<TabsList aria-label="Options">
<TabsTrigger value="a">Alpha</TabsTrigger>
<TabsTrigger value="b" disabled>Beta</TabsTrigger>
<TabsTrigger value="c">Gamma</TabsTrigger>
</TabsList>
<TabsContent value="a">Alpha panel</TabsContent>
<TabsContent value="c">Gamma panel</TabsContent>
</Tabs>
)
}
describe('Tabs keyboard contract', () => {
it.each([undefined, 'missing', 'b'])('restores keyboard entry without choosing for value %s', (value) => {
const onValueChange = vi.fn()
render(<Example value={value} onValueChange={onValueChange} />)
const tabs = screen.getAllByRole('tab')
expect(tabs.map(tab => tab.tabIndex)).toEqual([0, -1, -1])
expect(onValueChange).not.toHaveBeenCalled()
expect(screen.queryByRole('tabpanel')).toBeNull()
})
it('navigates, wraps and skips disabled tabs; links the active panel', async () => {
render(<Example />)
const alpha = screen.getByRole('tab', { name: 'Alpha' })
const gamma = screen.getByRole('tab', { name: 'Gamma' })
fireEvent.keyDown(alpha, { key: 'ArrowRight' })
expect(document.activeElement).toBe(gamma)
const panel = screen.getByRole('tabpanel')
expect(panel.id).toBe(gamma.getAttribute('aria-controls'))
expect(panel.getAttribute('aria-labelledby')).toBe(gamma.id)
await waitFor(() => expect(gamma.tabIndex).toBe(0))
fireEvent.keyDown(gamma, { key: 'ArrowRight' })
expect(document.activeElement).toBe(alpha)
fireEvent.keyDown(alpha, { key: 'End' })
expect(document.activeElement).toBe(gamma)
fireEvent.keyDown(gamma, { key: 'Home' })
expect(document.activeElement).toBe(alpha)
})
it('honors RTL direction and controlled selection', () => {
const onValueChange = vi.fn()
render(
<Tabs value="a" onValueChange={onValueChange}>
<TabsList style={{ direction: 'rtl' }}>
<TabsTrigger value="a">Alpha</TabsTrigger>
<TabsTrigger value="b">Beta</TabsTrigger>
<TabsTrigger value="c">Gamma</TabsTrigger>
</TabsList>
<TabsContent value="a">Alpha panel</TabsContent>
</Tabs>
)
fireEvent.keyDown(screen.getByRole('tab', { name: 'Alpha' }), { key: 'ArrowRight' })
expect(document.activeElement).toBe(screen.getByRole('tab', { name: 'Gamma' }))
expect(onValueChange).toHaveBeenCalledWith('c')
expect(screen.getByRole('tab', { name: 'Alpha' }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('tabpanel').textContent).toBe('Alpha panel')
})
it('repairs entry after a descendant independently removes or disables selection', async () => {
const onValueChange = vi.fn()
function Triggers() {
const [state, setState] = useState(0)
return <>
<button onClick={() => setState(state + 1)}>Change</button>
{state < 2 && <TabsTrigger value="a" disabled={state === 1}>Alpha</TabsTrigger>}
<TabsTrigger value="c" disabled={state === 3}>Gamma</TabsTrigger>
</>
}
render(<Tabs value="a" onValueChange={onValueChange}><TabsList><Triggers /></TabsList></Tabs>)
fireEvent.click(screen.getByText('Change'))
await waitFor(() => expect(screen.getByRole('tab', { name: 'Gamma' }).tabIndex).toBe(0))
fireEvent.click(screen.getByText('Change'))
await waitFor(() => expect(screen.getByRole('tab', { name: 'Gamma' }).tabIndex).toBe(0))
fireEvent.click(screen.getByText('Change'))
await waitFor(() => expect(screen.getByRole('tab', { name: 'Gamma' }).tabIndex).toBe(-1))
expect(onValueChange).not.toHaveBeenCalled()
})
it('reserves linked IDs in types and at runtime for untyped callers', () => {
// @ts-expect-error Linked IDs are managed by Tabs rather than the caller.
const trigger = <TabsTrigger value="a" id="custom-tab" aria-controls="missing-panel">Alpha</TabsTrigger>
// @ts-expect-error Linked IDs are managed by Tabs rather than the caller.
const content = <TabsContent value="a" id="custom-panel" aria-labelledby="missing-tab">Panel</TabsContent>
render(<Tabs defaultValue="a"><TabsList>{trigger}</TabsList>{content}</Tabs>)
const tab = screen.getByRole('tab')
const panel = screen.getByRole('tabpanel')
expect(tab.id).not.toBe('custom-tab')
expect(panel.id).not.toBe('custom-panel')
expect(document.getElementById(tab.getAttribute('aria-controls')!)).toBe(panel)
expect(document.getElementById(panel.getAttribute('aria-labelledby')!)).toBe(tab)
})
it('forwards refs/native attributes and allows event cancellation', () => {
const listRef = createRef<HTMLDivElement>()
const triggerRef = createRef<HTMLButtonElement>()
const onValueChange = vi.fn()
render(
<Tabs defaultValue="a" onValueChange={onValueChange}>
<TabsList ref={listRef} aria-label="Choices" style={{ padding: 3 }}>
<TabsTrigger ref={triggerRef} value="a" title="First" onKeyDown={event => event.preventDefault()}>Alpha</TabsTrigger>
<TabsTrigger value="c" onClick={event => event.preventDefault()}>Gamma</TabsTrigger>
</TabsList>
</Tabs>
)
expect(listRef.current).toBe(screen.getByRole('tablist'))
expect(triggerRef.current?.title).toBe('First')
expect(listRef.current?.style.padding).toBe('3px')
fireEvent.keyDown(triggerRef.current!, { key: 'End' })
fireEvent.click(screen.getByRole('tab', { name: 'Gamma' }))
expect(onValueChange).not.toHaveBeenCalled()
})
})

View file

@ -1,7 +1,7 @@
import * as React from 'react'
import { cn } from '@/shared/lib/utils'
interface TabsProps {
interface TabsProps extends Omit<React.ComponentPropsWithRef<'div'>, 'defaultValue' | 'onChange'> {
defaultValue?: string
value?: string
children: React.ReactNode
@ -12,12 +12,14 @@ interface TabsProps {
interface TabsContextValue {
value: string
setValue: (value: string) => void
baseId: string
}
const TabsContext = React.createContext<TabsContextValue | undefined>(undefined)
export function Tabs({ defaultValue, value: controlledValue, children, className, onValueChange }: TabsProps) {
export function Tabs({ defaultValue, value: controlledValue, children, className, onValueChange, ...props }: TabsProps) {
const [internalValue, setInternalValue] = React.useState(defaultValue ?? '')
const baseId = React.useId()
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : internalValue
@ -29,40 +31,61 @@ export function Tabs({ defaultValue, value: controlledValue, children, className
}, [isControlled, onValueChange])
return (
<TabsContext.Provider value={{ value, setValue: handleValueChange }}>
<div className={className}>{children}</div>
<TabsContext.Provider value={{ value, setValue: handleValueChange, baseId }}>
<div className={className} {...props}>{children}</div>
</TabsContext.Provider>
)
}
interface TabsListProps {
children: React.ReactNode
className?: string
function getTriggers(list: HTMLElement) {
return Array.from(list.querySelectorAll<HTMLButtonElement>('button[role="tab"]'))
.filter(tab => tab.closest('[role="tablist"]') === list)
}
export function TabsList({ children, className }: TabsListProps) {
export function TabsList({ children, className, ref, ...props }: React.ComponentPropsWithRef<'div'>) {
const listRef = React.useRef<HTMLDivElement>(null)
React.useImperativeHandle(ref, () => listRef.current!, [])
React.useLayoutEffect(() => {
const list = listRef.current
if (!list) return
const updateTabStop = () => {
const tabs = getTriggers(list)
const entry = tabs.find(tab => !tab.disabled && tab.getAttribute('aria-selected') === 'true')
?? tabs.find(tab => !tab.disabled)
// Restoring keyboard entry must not select a value or invoke business callbacks.
tabs.forEach(tab => { tab.tabIndex = tab === entry ? 0 : -1 })
}
updateTabStop()
// Descendants may change independently of TabsList (conditional or disabled triggers).
const observer = new MutationObserver(updateTabStop)
observer.observe(list, { subtree: true, childList: true, attributes: true, attributeFilter: ['disabled', 'aria-selected'] })
return () => observer.disconnect()
}, [])
return (
<div
ref={listRef}
role="tablist"
className={cn(
'inline-flex items-center gap-6 border-b text-sm',
className
)}
style={{ borderColor: 'hsl(var(--border))' }}
className={cn('inline-flex items-center gap-6 border-b border-border text-sm', className)}
{...props}
>
{children}
</div>
)
}
interface TabsTriggerProps {
interface TabsTriggerProps extends React.ComponentPropsWithRef<'button'> {
// IDs and their relationship are owned by Tabs, including for untyped callers.
id?: never
'aria-controls'?: never
value: string
children: React.ReactNode
className?: string
disabled?: boolean
}
export function TabsTrigger({ value, children, className, disabled = false }: TabsTriggerProps) {
export function TabsTrigger({ value, children, className, disabled = false, onClick, onKeyDown, ...props }: TabsTriggerProps) {
const context = React.useContext(TabsContext)
if (!context) throw new Error('TabsTrigger must be used within Tabs')
@ -74,33 +97,69 @@ export function TabsTrigger({ value, children, className, disabled = false }: Ta
role="tab"
aria-selected={isActive}
aria-disabled={disabled}
tabIndex={isActive && !disabled ? 0 : -1}
disabled={disabled}
onClick={() => context.setValue(value)}
onClick={(event) => {
onClick?.(event)
if (!event.defaultPrevented) context.setValue(value)
}}
onKeyDown={(event) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) return
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return
const list = event.currentTarget.closest<HTMLElement>('[role="tablist"]')
if (!list) return
const tabs = getTriggers(list).filter(tab => !tab.disabled)
const index = tabs.indexOf(event.currentTarget)
if (index < 0) return
const direction = getComputedStyle(list).direction === 'rtl' ? -1 : 1
const step = event.key === 'ArrowRight' ? direction : -direction
const nextIndex = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + step + tabs.length) % tabs.length
event.preventDefault()
tabs[nextIndex].focus()
tabs[nextIndex].click()
}}
data-state={isActive ? 'active' : 'inactive'}
className={cn(
'inline-flex -mb-px items-center justify-center whitespace-nowrap py-3 text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
'inline-flex -mb-px items-center justify-center whitespace-nowrap py-3 text-sm font-medium transition-[color,border-color,background-color] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
isActive
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground hover:text-foreground/80',
: 'border-b-2 border-transparent text-muted-foreground hover:text-foreground/80',
className
)}
{...props}
id={`${context.baseId}-tab-${value}`}
aria-controls={`${context.baseId}-panel-${value}`}
>
{children}
</button>
)
}
interface TabsContentProps {
interface TabsContentProps extends React.ComponentPropsWithRef<'div'> {
id?: never
'aria-labelledby'?: never
value: string
children: React.ReactNode
className?: string
}
export function TabsContent({ value, children, className }: TabsContentProps) {
export function TabsContent({ value, children, className, ...props }: TabsContentProps) {
const context = React.useContext(TabsContext)
if (!context) throw new Error('TabsContent must be used within Tabs')
if (context.value !== value) return null
return <div role="tabpanel" className={cn('animate-fade-in', className)}>{children}</div>
return (
<div
role="tabpanel"
tabIndex={0}
className={cn('animate-fade-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', className)}
{...props}
id={`${context.baseId}-panel-${value}`}
aria-labelledby={`${context.baseId}-tab-${value}`}
>
{children}
</div>
)
}

View file

@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
'flex min-h-[100px] w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-3 text-sm text-foreground ring-offset-background transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
'flex min-h-[100px] w-full rounded-lg border border-border/60 bg-secondary/50 px-4 py-3 text-sm text-foreground ring-offset-background transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:border-primary/50 aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/20 disabled:cursor-not-allowed disabled:opacity-50 resize-y',
className
)}
ref={ref}

View file

@ -6,24 +6,10 @@ const config: Config = {
theme: {
extend: {
fontFamily: {
display: ['Inter', 'system-ui', 'sans-serif'],
heading: ['Inter', 'system-ui', 'sans-serif'],
sans: ['Inter', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
mono: [
'JetBrains Mono',
'SF Mono',
'Monaco',
'Inconsolata',
'Fira Mono',
'Droid Sans Mono',
'Source Code Pro',
'ui-monospace',
'SFMono-Regular',
'Menlo',
'Consolas',
'Liberation Mono',
'monospace'
],
display: ['var(--font-sans)'],
heading: ['var(--font-sans)'],
sans: ['var(--font-sans)'],
mono: ['var(--font-mono)'],
},
borderRadius: {
lg: 'var(--radius)',
@ -63,6 +49,18 @@ const config: Config = {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
},
info: {
DEFAULT: 'hsl(var(--info))',
foreground: 'hsl(var(--info-foreground))',
},
success: {
DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))',
},
border: 'hsl(var(--border))',
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',