mirror of
https://github.com/iflytek/skillhub.git
synced 2026-10-15 04:18:20 +00:00
Merge pull request #952 from iflytek/feature/shadcn-ui-opensource
feat(frontend): refine shadcn UI foundations
This commit is contained in:
commit
27f044b0f5
20 changed files with 545 additions and 67 deletions
32
openspec/changes/refine-shadcn-ui-foundation/design.md
Normal file
32
openspec/changes/refine-shadcn-ui-foundation/design.md
Normal 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.
|
||||
26
openspec/changes/refine-shadcn-ui-foundation/proposal.md
Normal file
26
openspec/changes/refine-shadcn-ui-foundation/proposal.md
Normal 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.
|
||||
|
|
@ -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
|
||||
14
openspec/changes/refine-shadcn-ui-foundation/tasks.md
Normal file
14
openspec/changes/refine-shadcn-ui-foundation/tasks.md
Normal 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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
2
web/e2e/fixtures/ui-foundation.html
Normal file
2
web/e2e/fixtures/ui-foundation.html
Normal 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>
|
||||
44
web/e2e/fixtures/ui-foundation.tsx
Normal file
44
web/e2e/fixtures/ui-foundation.tsx
Normal 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 />)
|
||||
91
web/e2e/ui-foundation.spec.ts
Normal file
91
web/e2e/ui-foundation.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
123
web/src/shared/ui/tabs.behavior.test.tsx
Normal file
123
web/src/shared/ui/tabs.behavior.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue