feat(ui): convert Dialog/Slider/Table/Tabs to Tailwind utilities (visual-verified) (#779)
Continue the component CSS -> Tailwind v4 utility migration for UI group 3. Added Slider, Table, and Tabs to the visual-regression harness (specs.jsx + manifest.ts) and committed machine-local baselines, then converted each component, proving the result pixel-identical with `bun run test:visual`. - Slider: fully converted; Slider.css deleted (no keyframes / complex selectors). Token + arbitrary-value utilities preserve exact pixels; thumb hover/active/focus-visible and the multi-easing transition are kept faithful via arbitrary-property utilities. Visual-verified across all 3 themes. - Tabs: fully converted; Tabs.css deleted. pill/underline variants, size, active and hover:not(active) states mapped to conditional utility sets. The `ui-tabs* / is-active / ui-tabs__icon` class names are retained as inert hooks so Settings.css's unlayered overrides (`.ui-tabs.settings-tabs-ui …`) keep winning over the layered utilities — Settings tab rail unchanged. Visual-verified across all 3 themes. - Dialog: partial conversion. Header / title / body / footer box-model + typography and per-size max-width converted to utilities; the glass gradient surface, backdrop-filter, fixed centering, and open/close @keyframes remain in Dialog.css (cannot be reduced to utilities). NOT visually verified: Radix Portal + position:fixed render the dialog outside the harness's #visual-root, so it can't be snapshotted in isolation; verified instead by 1:1 token equivalence + build + unit tests. - Table: LEFT AS CSS (STOP rule). Its classes are a shared CSS contract, not a private leaf — ModelsTable.jsx renders `ui-table-header`/`ui-table-header__cell` directly without the component, and DubSegmentTable.css, EngineCompatibilityMatrix.css, Settings.css, and index.css all hook those global classes. Removing Table.css would break them, so converting yields no safe net benefit. Added to the harness with a baseline for a future pass. Verified: oxlint (0 errors), oxfmt --check (clean), vite build, vitest (641 passed), bun install --frozen-lockfile (no changes), test:visual (24 passed). Co-authored-by: mergetest <test@local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -14,6 +14,9 @@ export const COMPONENTS = [
|
||||
'Panel',
|
||||
'SettingRow',
|
||||
'SettingsToggle',
|
||||
'Slider',
|
||||
'Table',
|
||||
'Tabs',
|
||||
] as const;
|
||||
|
||||
export const THEMES = ['default', 'midnight', 'catppuccin'] as const;
|
||||
|
||||
@@ -11,13 +11,16 @@
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
|
||||
import React from 'react';
|
||||
import { Download, Sparkles, Trash2 } from 'lucide-react';
|
||||
import { Download, Mic, Sparkles, Trash2 } from 'lucide-react';
|
||||
|
||||
import Badge from '../../ui/Badge.jsx';
|
||||
import Button from '../../ui/Button.jsx';
|
||||
import Panel from '../../ui/Panel.jsx';
|
||||
import Progress from '../../ui/Progress.jsx';
|
||||
import Segmented from '../../ui/Segmented.jsx';
|
||||
import Slider from '../../ui/Slider.jsx';
|
||||
import Table from '../../ui/Table.jsx';
|
||||
import Tabs from '../../ui/Tabs.jsx';
|
||||
import SettingRow from '../../components/settings/primitives/SettingRow.jsx';
|
||||
import SettingsToggle from '../../components/settings/primitives/SettingsToggle.jsx';
|
||||
// SettingRow / SettingsToggle styling lives in the primitives stylesheet,
|
||||
@@ -36,6 +39,18 @@ function Spec({ label, children }) {
|
||||
|
||||
const BADGE_TONES = ['neutral', 'brand', 'success', 'warn', 'danger', 'info', 'violet'];
|
||||
|
||||
const TAB_ITEMS = [
|
||||
{ id: 'clone', label: 'Clone', icon: Mic },
|
||||
{ id: 'design', label: 'Design', icon: Sparkles },
|
||||
{ id: 'dub', label: 'Dub' },
|
||||
];
|
||||
|
||||
const TABLE_COLS = [
|
||||
{ key: 'name', label: 'Voice', flex: 2 },
|
||||
{ key: 'lang', label: 'Lang', width: 80 },
|
||||
{ key: 'dur', label: 'Length', width: 70, align: 'right' },
|
||||
];
|
||||
|
||||
export const SPECS = {
|
||||
Badge: {
|
||||
render: () => (
|
||||
@@ -216,4 +231,48 @@ export const SPECS = {
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
Slider: {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, width: '100%' }}>
|
||||
<Spec label="md + label">
|
||||
<Slider value={42} onChange={() => {}} label="Stability" />
|
||||
</Spec>
|
||||
<Spec label="sm">
|
||||
<Slider value={70} onChange={() => {}} size="sm" />
|
||||
</Spec>
|
||||
<Spec label="no value bubble">
|
||||
<Slider value={30} onChange={() => {}} showValue={false} />
|
||||
</Spec>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
Table: {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', width: '100%', height: 160 }}>
|
||||
<Table>
|
||||
<Table.Toolbar search="voice" onSearch={() => {}} meta="42/42 · 3 sel" />
|
||||
<Table.Header columns={TABLE_COLS} />
|
||||
<div style={{ flex: 1 }} />
|
||||
</Table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
Tabs: {
|
||||
render: () => (
|
||||
<>
|
||||
<Spec label="pill (md)">
|
||||
<Tabs items={TAB_ITEMS} value="clone" onChange={() => {}} />
|
||||
</Spec>
|
||||
<Spec label="pill (sm)">
|
||||
<Tabs items={TAB_ITEMS} value="design" onChange={() => {}} size="sm" />
|
||||
</Spec>
|
||||
<Spec label="underline">
|
||||
<Tabs items={TAB_ITEMS} value="dub" onChange={() => {}} variant="underline" />
|
||||
</Spec>
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,42 +44,8 @@
|
||||
animation: ui-dialog-pop var(--dur-base) var(--ease-spring);
|
||||
}
|
||||
|
||||
/* sizes */
|
||||
.ui-dialog--sm { max-width: 380px; }
|
||||
.ui-dialog--md { max-width: 560px; }
|
||||
.ui-dialog--lg { max-width: 780px; }
|
||||
.ui-dialog--xl { max-width: 1080px; }
|
||||
|
||||
.ui-dialog__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-5) var(--space-6);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
gap: var(--space-4);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ui-dialog__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-bold);
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.ui-dialog__body {
|
||||
padding: var(--space-6);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ui-dialog__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4) var(--space-6);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* sizes — the per-size max-width now lives in Dialog.jsx (DIALOG_MAX_W) as
|
||||
`max-w-[…]` utilities. The header / title / body / footer box-model +
|
||||
typography were likewise converted to utilities in Dialog.jsx. Only the
|
||||
glass surface + fixed centering + open/close keyframes remain here, since
|
||||
those can't be reduced to utilities. */
|
||||
|
||||
@@ -4,6 +4,16 @@ import { X } from 'lucide-react';
|
||||
import Button from './Button';
|
||||
import './Dialog.css';
|
||||
|
||||
// Per-size max-width. The rest of the dialog surface (glass gradient,
|
||||
// backdrop-filter, fixed centering, open/close keyframes) stays in Dialog.css
|
||||
// because those rules can't be expressed as — or safely reduced to — utilities.
|
||||
const DIALOG_MAX_W = {
|
||||
sm: 'max-w-[380px]',
|
||||
md: 'max-w-[560px]',
|
||||
lg: 'max-w-[780px]',
|
||||
xl: 'max-w-[1080px]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Dialog — accessible modal backed by @radix-ui/react-dialog.
|
||||
*
|
||||
@@ -43,14 +53,18 @@ export default function Dialog({
|
||||
<RadixDialog.Portal>
|
||||
<RadixDialog.Overlay className="ui-dialog-backdrop" />
|
||||
<RadixDialog.Content
|
||||
className={`ui-dialog ui-dialog--${size}`}
|
||||
className={`ui-dialog ${DIALOG_MAX_W[size] || DIALOG_MAX_W.md}`}
|
||||
onEscapeKeyDown={handleEscapeKeyDown}
|
||||
onPointerDownOutside={handlePointerDownOutside}
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{(title || dismissable) && (
|
||||
<header className="ui-dialog__header">
|
||||
{title && <RadixDialog.Title className="ui-dialog__title">{title}</RadixDialog.Title>}
|
||||
<header className="flex shrink-0 items-center justify-between gap-[var(--space-4)] border-b border-border px-[var(--space-6)] py-[var(--space-5)]">
|
||||
{title && (
|
||||
<RadixDialog.Title className="m-0 font-serif text-[length:var(--text-lg)] font-bold tracking-[-0.01em] text-fg">
|
||||
{title}
|
||||
</RadixDialog.Title>
|
||||
)}
|
||||
{dismissable && (
|
||||
<RadixDialog.Close asChild>
|
||||
<Button variant="icon" iconSize="sm" aria-label="Close">
|
||||
@@ -61,8 +75,12 @@ export default function Dialog({
|
||||
</header>
|
||||
)}
|
||||
{!title && <RadixDialog.Title className="sr-only">Dialog</RadixDialog.Title>}
|
||||
<div className="ui-dialog__body">{children}</div>
|
||||
{footer && <footer className="ui-dialog__footer">{footer}</footer>}
|
||||
<div className="min-h-0 overflow-y-auto p-[var(--space-6)]">{children}</div>
|
||||
{footer && (
|
||||
<footer className="flex shrink-0 items-center justify-end gap-[var(--space-3)] border-t border-border px-[var(--space-6)] py-[var(--space-4)]">
|
||||
{footer}
|
||||
</footer>
|
||||
)}
|
||||
</RadixDialog.Content>
|
||||
</RadixDialog.Portal>
|
||||
</RadixDialog.Root>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
/* ── Slider primitive (Radix UI) ──────────────────────────────── */
|
||||
|
||||
.ui-slider { width: 100%; display: flex; flex-direction: column; gap: var(--space-1); }
|
||||
.ui-slider__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-fg-muted);
|
||||
font-weight: var(--weight-semibold);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.ui-slider__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
/* Radix Slider Root */
|
||||
.ui-slider__root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
.ui-slider__track {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Filled range */
|
||||
.ui-slider__range {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--color-brand);
|
||||
}
|
||||
|
||||
/* Thumb */
|
||||
.ui-slider__thumb {
|
||||
display: block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-fg);
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--color-bg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--dur-fast) var(--ease-spring), background var(--dur-fast);
|
||||
outline: none;
|
||||
}
|
||||
.ui-slider__thumb:hover { transform: scale(1.25); }
|
||||
.ui-slider__thumb:active { transform: scale(1.1); background: var(--color-brand); }
|
||||
.ui-slider__thumb:focus-visible { box-shadow: var(--focus-ring); }
|
||||
|
||||
.ui-slider__value {
|
||||
font-size: var(--text-xs);
|
||||
background: var(--color-bg-elev-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-fg);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 2em;
|
||||
text-align: center;
|
||||
font-family: var(--font-mono);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* size sm: shorter */
|
||||
.ui-slider--size-sm .ui-slider__track { height: 2px; }
|
||||
.ui-slider--size-sm .ui-slider__value { font-size: var(--text-2xs); padding: 0 4px; }
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { forwardRef, useId } from 'react';
|
||||
import * as RadixSlider from '@radix-ui/react-slider';
|
||||
import './Slider.css';
|
||||
|
||||
/**
|
||||
* Slider — styled horizontal range input.
|
||||
@@ -32,19 +31,23 @@ const Slider = forwardRef(function Slider(
|
||||
ref,
|
||||
) {
|
||||
const id = useId();
|
||||
const isSm = size === 'sm';
|
||||
|
||||
return (
|
||||
<div className={`ui-slider ui-slider--size-${size} ${className}`}>
|
||||
<div className={`flex w-full flex-col gap-[var(--space-1)] ${className}`}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="ui-slider__label">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-[length:var(--text-xs)] font-semibold tracking-[0.02em] text-fg-muted"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="ui-slider__row">
|
||||
<div className="flex items-center gap-[var(--space-3)]">
|
||||
<RadixSlider.Root
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="ui-slider__root"
|
||||
className="relative flex h-5 flex-1 cursor-pointer touch-none select-none items-center"
|
||||
value={[Number(value)]}
|
||||
onValueChange={([v]) => onChange?.(v)}
|
||||
min={min}
|
||||
@@ -52,13 +55,22 @@ const Slider = forwardRef(function Slider(
|
||||
step={step}
|
||||
{...rest}
|
||||
>
|
||||
<RadixSlider.Track className="ui-slider__track">
|
||||
<RadixSlider.Range className="ui-slider__range" />
|
||||
<RadixSlider.Track
|
||||
className={`relative grow rounded-[2px] bg-[rgba(255,255,255,0.08)] ${isSm ? 'h-[2px]' : 'h-[3px]'}`}
|
||||
>
|
||||
<RadixSlider.Range className="absolute h-full rounded-[2px] bg-brand" />
|
||||
</RadixSlider.Track>
|
||||
<RadixSlider.Thumb className="ui-slider__thumb" />
|
||||
<RadixSlider.Thumb className="block h-3 w-3 cursor-pointer rounded-full border-2 border-bg bg-fg shadow-[var(--shadow-sm)] outline-none [transition:transform_var(--dur-fast)_var(--ease-spring),background_var(--dur-fast)] hover:[transform:scale(1.25)] focus-visible:shadow-[var(--focus-ring)] active:bg-brand active:[transform:scale(1.1)]" />
|
||||
</RadixSlider.Root>
|
||||
{showValue && (
|
||||
<span className="ui-slider__value" aria-live="polite">
|
||||
<span
|
||||
className={`min-w-[2em] shrink-0 rounded-sm border border-border bg-bg-elev-2 text-center font-mono text-fg tabular-nums ${
|
||||
isSm
|
||||
? 'px-1 py-0 text-[length:var(--text-2xs)]'
|
||||
: 'px-[5px] py-px text-[length:var(--text-xs)]'
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{format(value)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
/* ── Tabs primitive — chrome tokens ────────────────────────────── */
|
||||
|
||||
.ui-tabs {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
--ui-tab-accent: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* pill variant — flat chrome segmented bar */
|
||||
.ui-tabs--pill {
|
||||
background: var(--chrome-bg);
|
||||
padding: 3px;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
border: 1px solid var(--chrome-border);
|
||||
box-shadow: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 14px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--chrome-radius-pill);
|
||||
background: transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-base);
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-out),
|
||||
color var(--dur-fast) var(--ease-out),
|
||||
border-color var(--dur-fast) var(--ease-out);
|
||||
position: relative;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab:hover:not(.is-active) {
|
||||
color: var(--chrome-fg);
|
||||
background: var(--chrome-hover-bg);
|
||||
transform: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab.is-active {
|
||||
background: var(--chrome-accent-bg);
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent-border);
|
||||
box-shadow: none;
|
||||
font-weight: 600;
|
||||
transform: none;
|
||||
}
|
||||
.ui-tabs--pill .ui-tabs__tab.is-active .ui-tabs__icon { color: var(--chrome-accent); }
|
||||
|
||||
/* underline variant — compact secondary nav */
|
||||
.ui-tabs--underline {
|
||||
gap: var(--space-5);
|
||||
border-bottom: 1px solid var(--chrome-border);
|
||||
}
|
||||
.ui-tabs--underline .ui-tabs__tab {
|
||||
padding: 6px 2px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--chrome-fg-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 500;
|
||||
font-size: var(--text-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
transition: color var(--dur-fast), border-color var(--dur-fast);
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
.ui-tabs--underline .ui-tabs__tab:hover:not(.is-active) { color: var(--chrome-fg); }
|
||||
.ui-tabs--underline .ui-tabs__tab.is-active {
|
||||
color: var(--chrome-accent);
|
||||
border-color: var(--chrome-accent);
|
||||
}
|
||||
|
||||
/* size */
|
||||
.ui-tabs--size-sm.ui-tabs--pill .ui-tabs__tab { padding: 3px 10px; font-size: var(--text-xs); }
|
||||
|
||||
.ui-tabs__tab:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import * as RadixTabs from '@radix-ui/react-tabs';
|
||||
import './Tabs.css';
|
||||
|
||||
/**
|
||||
* Tabs — pill-style segmented tab group, backed by @radix-ui/react-tabs.
|
||||
@@ -23,20 +22,49 @@ export default function Tabs({
|
||||
className = '',
|
||||
...rest
|
||||
}) {
|
||||
const isPill = variant === 'pill';
|
||||
const isSm = size === 'sm';
|
||||
|
||||
// The `ui-tabs*` / `is-active` semantic classes carry no styling here (the
|
||||
// Tabs.css that defined them was converted to the utilities below). They are
|
||||
// retained as stable hooks for page-level overrides — Settings.css restyles
|
||||
// the tab rail via `.ui-tabs.settings-tabs-ui .ui-tabs__tab.is-active …`,
|
||||
// which is unlayered and so still wins over these layered utilities.
|
||||
const listClass = isPill
|
||||
? 'inline-flex shrink-0 gap-[3px] rounded-[var(--chrome-radius-pill)] border border-[var(--chrome-border)] bg-[var(--chrome-bg)] p-[3px]'
|
||||
: 'inline-flex shrink-0 gap-[var(--space-5)] border-b border-[var(--chrome-border)]';
|
||||
|
||||
return (
|
||||
<RadixTabs.Root value={value} onValueChange={onChange} activationMode="manual">
|
||||
<RadixTabs.List
|
||||
className={`ui-tabs ui-tabs--${variant} ui-tabs--size-${size} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
<RadixTabs.List className={`ui-tabs ${listClass} ${className}`} {...rest}>
|
||||
{items.map((item) => {
|
||||
const active = value === item.id;
|
||||
const Icon = item.icon;
|
||||
const tabClass = isPill
|
||||
? [
|
||||
'relative flex flex-[0_0_auto] cursor-pointer items-center justify-center gap-[var(--space-3)] rounded-[var(--chrome-radius-pill)] border font-sans tracking-[0.01em]',
|
||||
'[transition:background_var(--dur-fast)_var(--ease-out),color_var(--dur-fast)_var(--ease-out),border-color_var(--dur-fast)_var(--ease-out)]',
|
||||
'focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none',
|
||||
isSm
|
||||
? 'px-[10px] py-[3px] text-[length:var(--text-xs)]'
|
||||
: 'px-[14px] py-[5px] text-[length:var(--text-base)]',
|
||||
active
|
||||
? 'border-[var(--chrome-accent-border)] bg-[var(--chrome-accent-bg)] font-semibold text-[color:var(--chrome-accent)]'
|
||||
: 'border-transparent bg-transparent font-medium text-[color:var(--chrome-fg-muted)] hover:bg-[var(--chrome-hover-bg)] hover:text-[color:var(--chrome-fg)]',
|
||||
].join(' ')
|
||||
: [
|
||||
'mb-[-1px] flex cursor-pointer items-center gap-[var(--space-2)] border-b-2 bg-transparent px-[2px] py-[6px] font-sans font-medium text-[length:var(--text-base)]',
|
||||
'[transition:color_var(--dur-fast),border-color_var(--dur-fast)]',
|
||||
'focus-visible:shadow-[var(--focus-ring)] focus-visible:outline-none',
|
||||
active
|
||||
? 'border-b-[var(--chrome-accent)] text-[color:var(--chrome-accent)]'
|
||||
: 'border-b-transparent text-[color:var(--chrome-fg-muted)] hover:text-[color:var(--chrome-fg)]',
|
||||
].join(' ');
|
||||
return (
|
||||
<RadixTabs.Trigger
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
className={`ui-tabs__tab ${active ? 'is-active' : ''}`}
|
||||
className={`ui-tabs__tab ${active ? 'is-active' : ''} ${tabClass}`}
|
||||
style={active && item.accent ? { '--ui-tab-accent': item.accent } : undefined}
|
||||
>
|
||||
{Icon && <Icon size={12} className="ui-tabs__icon" />}
|
||||
|
||||