mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
feat(desktop): add prefix/suffix adornments to Input primitive; use $ prefix in billing top-up
This commit is contained in:
parent
f9a8ecc0ce
commit
c4acc4d2c5
9 changed files with 230 additions and 73 deletions
|
|
@ -120,7 +120,9 @@ describe('BillingSettings', () => {
|
|||
|
||||
renderBilling()
|
||||
|
||||
expect(await screen.findByText('No card on file')).toBeTruthy()
|
||||
// No card → the payment row collapses to a single "Add payment method" link.
|
||||
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
|
||||
expect(screen.queryByText('No card on file')).toBeNull()
|
||||
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@ import { useEffect, useMemo, useState } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
|
||||
import { ListRow, SectionHeading, SettingsCard, SettingsContent, SettingsSection } from '../primitives'
|
||||
import { ListRow, SectionHeading, SettingsContent, SettingsSection } from '../primitives'
|
||||
|
||||
import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
|
|
@ -45,6 +46,16 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
|
|||
|
||||
type BillingFixtureSelection = 'live' | BillingDevFixtureName
|
||||
|
||||
// DEV-only: a canned ~60%-full "ok" bar so the active progress-bar treatment can be
|
||||
// eyeballed on any account (a live/empty account only ever shows the hatch track).
|
||||
const DEV_PREVIEW_USAGE_ROW: BillingUsageRowView = {
|
||||
bar: { label: 'Example usage (dev preview)', state: 'ok', tone: 'subscription', value: 0.62 },
|
||||
caption: 'Preview only — dev build',
|
||||
id: 'subscription_credits',
|
||||
title: 'Example bar (dev preview)',
|
||||
value: '$62 of $100 left'
|
||||
}
|
||||
|
||||
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
|
|
@ -65,16 +76,11 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
|||
const warn = notice.tone === 'warn'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'mb-6 rounded-xl border p-4',
|
||||
warn ? 'border-amber-500/30 bg-amber-500/5' : 'border-border/70 bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-text-font-size)] font-medium',
|
||||
warn ? 'text-amber-600 dark:text-amber-300' : 'text-foreground'
|
||||
warn ? 'text-(--ui-yellow)' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{notice.title}
|
||||
|
|
@ -98,6 +104,31 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
|||
)
|
||||
}
|
||||
|
||||
// The payment method as it rides in the "Payment & credits" heading: the current
|
||||
// card (muted) plus a single underline text action (Update / Add payment method).
|
||||
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{row.value && (
|
||||
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
{row.action && (
|
||||
<Button
|
||||
disabled={row.action.disabled}
|
||||
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
{row.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
|
||||
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
|
||||
return <BuyCreditsRow billing={billing} row={row} />
|
||||
|
|
@ -155,22 +186,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
|||
<ListRow
|
||||
action={
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
|
||||
{presets.map(preset => (
|
||||
<Button
|
||||
aria-pressed={amount === preset.amount}
|
||||
disabled={controlsDisabled}
|
||||
key={preset.amount}
|
||||
onClick={() => setAmount(preset.amount)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={amount === preset.amount ? 'default' : 'outline'}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<SegmentedControl
|
||||
disabled={controlsDisabled}
|
||||
onChange={value => setAmount(value)}
|
||||
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
|
||||
value={amount}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Custom credit amount"
|
||||
className="w-24 py-[3px]"
|
||||
containerClassName="w-20"
|
||||
disabled={controlsDisabled}
|
||||
inputMode="decimal"
|
||||
max={billing.max_usd ?? undefined}
|
||||
|
|
@ -180,13 +204,14 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
|||
flow.reset()
|
||||
setAmount(event.target.value)
|
||||
}}
|
||||
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
|
||||
size="sm"
|
||||
placeholder={billing.min_usd ?? ''}
|
||||
prefix="$"
|
||||
size="xs"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={amount}
|
||||
/>
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -311,8 +336,9 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
|
|||
resolvedBar.track === 'danger'
|
||||
? 'dither text-destructive/60 bg-destructive/10'
|
||||
: isEmpty
|
||||
? 'dither bg-(--ui-bg-elevated)'
|
||||
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
|
||||
? // Muted currentColor so the hatch reads as a faint placeholder, not solid ink.
|
||||
'dither text-(--ui-text-quaternary) bg-(--ui-bg-elevated)'
|
||||
: 'bg-(--ui-bg-tertiary) shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
|
||||
)}
|
||||
role="progressbar"
|
||||
>
|
||||
|
|
@ -381,7 +407,7 @@ function BillingFixtureSelect({
|
|||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing preview fixture (dev only)"
|
||||
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
|
||||
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
|
|
@ -440,12 +466,10 @@ function BillingSettingsContent({
|
|||
|
||||
const { paymentRow, refillRow, topupRow } = view
|
||||
|
||||
// The Payment & credits card groups every money control (payment method,
|
||||
// one-time top-up, auto-refill) into a single divide-y list instead of three
|
||||
// free-floating one-row sections.
|
||||
const accountRows = [paymentRow, topupRow, refillRow].filter(
|
||||
(row): row is BillingAccountRowView => row !== undefined
|
||||
)
|
||||
// The payment method rides in the section header (right-aligned) — the
|
||||
// "Payment & credits" title already names it, so a full labelled row would just
|
||||
// repeat "Payment method". The stacked rows are the remaining money controls.
|
||||
const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined)
|
||||
|
||||
// Gate the plans sub-view on the SAME capability that renders the in-app button
|
||||
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
|
||||
|
|
@ -468,38 +492,41 @@ function BillingSettingsContent({
|
|||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
<div className="@container mb-6">
|
||||
<SettingsCard className="grid gap-3 p-4 @2xl:grid-cols-3">
|
||||
<div className="grid gap-3 @2xl:grid-cols-3">
|
||||
{view.summary.map(item => (
|
||||
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
|
||||
))}
|
||||
</SettingsCard>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.plan && (
|
||||
<SettingsSection icon={Package} title="Plan">
|
||||
<SettingsCard className="px-4">
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</SettingsCard>
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{accountRows.length > 0 && (
|
||||
<SettingsSection icon={CreditCard} title="Payment & credits">
|
||||
<SettingsCard className="divide-y divide-border/60 px-4">
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsCard>
|
||||
{(paymentRow || accountRows.length > 0) && (
|
||||
<SettingsSection
|
||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||
icon={CreditCard}
|
||||
title="Payment & credits"
|
||||
>
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<SettingsSection icon={BarChart3} title="Usage">
|
||||
<SettingsCard className="@container px-4 py-2">
|
||||
<div className="@container">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsCard>
|
||||
{/* DEV-only: a fully-filled example so the active (non-disabled) bar can be
|
||||
reviewed on any account. Compiled out of production. */}
|
||||
{import.meta.env.DEV && <UsageRow row={DEV_PREVIEW_USAGE_ROW} />}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
|||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
|
||||
ref={panelRef}
|
||||
role="status"
|
||||
tabIndex={-1}
|
||||
|
|
@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
|||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
|
||||
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
|
||||
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
|
||||
)}
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
|
|
@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No plans are available to change to right now.
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -534,17 +534,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
|||
const card = billing.card
|
||||
|
||||
if (!card) {
|
||||
// No card → a single "Add payment method" link, the way every other app does
|
||||
// it. The reason (buys/auto-refill are blocked) already leads the page as a
|
||||
// notice, so the row stays a bare call-to-action with no redundant status text.
|
||||
return {
|
||||
action: { label: 'Update ↗', url: portalUrl },
|
||||
description: 'Add a payment method on the portal before buying top-up credits.',
|
||||
action: { label: 'Add payment method', url: portalUrl },
|
||||
description: '',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method',
|
||||
value: 'No card on file'
|
||||
title: 'Payment method'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
action: { label: 'Update ↗', url: portalUrl },
|
||||
action: { label: 'Update', url: portalUrl },
|
||||
description: 'Manage the card used for top-ups and subscription renewals.',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method',
|
||||
|
|
|
|||
|
|
@ -28,32 +28,40 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA
|
|||
return <Badge variant={PILL_VARIANT[tone]}>{children}</Badge>
|
||||
}
|
||||
|
||||
export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) {
|
||||
export function SectionHeading({
|
||||
aside,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title
|
||||
}: {
|
||||
// Right-aligned trailing content on the heading row (e.g. a compact status +
|
||||
// action), so a single-item section needn't repeat its own label as a row.
|
||||
aside?: ReactNode
|
||||
icon: IconComponent
|
||||
meta?: string
|
||||
title: string
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
|
||||
<Icon className="size-4 text-muted-foreground" />
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>{title}</span>
|
||||
{meta && <Pill>{meta}</Pill>}
|
||||
{aside && <div className="ml-auto flex min-w-0 items-center">{aside}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The canonical settings surface: a soft-bordered muted well. Callers own the
|
||||
// inner padding (a `divide-y` list wants none; a single block wants `p-4`) so the
|
||||
// one container styling stays consistent across every settings page.
|
||||
export function SettingsCard({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('rounded-xl border border-border/70 bg-muted/20', className)}>{children}</div>
|
||||
}
|
||||
|
||||
// A titled section: heading + body with the shared vertical rhythm. Keeps the
|
||||
// heading and its content welded together so pages stop hand-rolling
|
||||
// `<div className="mb-…"><SectionHeading/>…</div>` at every call site.
|
||||
export function SettingsSection({
|
||||
aside,
|
||||
children,
|
||||
icon,
|
||||
meta,
|
||||
title
|
||||
}: {
|
||||
aside?: ReactNode
|
||||
children: ReactNode
|
||||
icon: IconComponent
|
||||
meta?: string
|
||||
|
|
@ -61,7 +69,7 @@ export function SettingsSection({
|
|||
}) {
|
||||
return (
|
||||
<section className="mb-6">
|
||||
<SectionHeading icon={icon} meta={meta} title={title} />
|
||||
<SectionHeading aside={aside} icon={icon} meta={meta} title={title} />
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
|
|
|
|||
66
apps/desktop/src/components/ui/input.test.tsx
Normal file
66
apps/desktop/src/components/ui/input.test.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Input } from './input'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('Input', () => {
|
||||
it('renders a bare input carrying the control chrome when unadorned', () => {
|
||||
const { getByRole } = render(<Input aria-label="plain" />)
|
||||
const el = getByRole('textbox')
|
||||
|
||||
expect(el.tagName).toBe('INPUT')
|
||||
expect(el.className).toContain('desktop-input-chrome')
|
||||
// No group wrapper — the input is the top-level node.
|
||||
expect(el.parentElement?.getAttribute('data-slot')).not.toBe('input-group')
|
||||
})
|
||||
|
||||
it('wraps the field in a chrome-owning group and renders the prefix when adorned', () => {
|
||||
const { getByRole, getByText } = render(<Input aria-label="amount" prefix="$" />)
|
||||
const el = getByRole('textbox')
|
||||
const group = el.closest('[data-slot="input-group"]')
|
||||
|
||||
expect(group).not.toBeNull()
|
||||
// Chrome moves to the wrapper; the input itself goes transparent/borderless.
|
||||
expect(group?.className).toContain('desktop-input-chrome')
|
||||
expect(el.className).not.toContain('desktop-input-chrome')
|
||||
expect(el.className).toContain('bg-transparent')
|
||||
|
||||
const prefix = getByText('$')
|
||||
expect(group?.contains(prefix)).toBe(true)
|
||||
})
|
||||
|
||||
it('renders a trailing suffix inside the group', () => {
|
||||
const { getByText } = render(<Input aria-label="rate" suffix="%" />)
|
||||
const suffix = getByText('%')
|
||||
|
||||
expect(suffix.closest('[data-slot="input-group"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('forwards value/onChange through the adorned field', () => {
|
||||
const onChange = vi.fn()
|
||||
const { getByRole } = render(<Input aria-label="amount" onChange={onChange} prefix="$" value="" />)
|
||||
|
||||
fireEvent.change(getByRole('textbox'), { target: { value: '100' } })
|
||||
expect(onChange).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dims the group and disables the field when disabled', () => {
|
||||
const { getByRole } = render(<Input aria-label="amount" disabled prefix="$" />)
|
||||
const el = getByRole('textbox') as HTMLInputElement
|
||||
const group = el.closest('[data-slot="input-group"]')
|
||||
|
||||
expect(el.disabled).toBe(true)
|
||||
expect(group?.className).toContain('opacity-50')
|
||||
})
|
||||
|
||||
it('lets containerClassName override the wrapper width', () => {
|
||||
const { getByRole } = render(<Input aria-label="amount" containerClassName="w-20" prefix="$" />)
|
||||
const group = getByRole('textbox').closest('[data-slot="input-group"]')
|
||||
|
||||
// twMerge resolves the control's default w-full down to the caller's width.
|
||||
expect(group?.className).toContain('w-20')
|
||||
expect(group?.className).not.toContain('w-full')
|
||||
})
|
||||
})
|
||||
|
|
@ -4,8 +4,22 @@ import { cn } from '@/lib/utils'
|
|||
|
||||
import { type ControlVariantProps, controlVariants } from './control'
|
||||
|
||||
function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'input'>, 'size'> & ControlVariantProps) {
|
||||
return (
|
||||
// `prefix`/`suffix` are DOM string attributes on the native element; we shadow
|
||||
// them with ReactNode adornments, so they're omitted from the base props.
|
||||
type InputProps = Omit<React.ComponentProps<'input'>, 'size' | 'prefix' | 'suffix'> &
|
||||
ControlVariantProps & {
|
||||
/** Leading adornment rendered inside the field (e.g. a `$` for money). */
|
||||
prefix?: React.ReactNode
|
||||
/** Trailing adornment rendered inside the field (e.g. a unit label). */
|
||||
suffix?: React.ReactNode
|
||||
/** Applied to the wrapper when an adornment promotes the field to a group. */
|
||||
containerClassName?: string
|
||||
}
|
||||
|
||||
function Input({ className, containerClassName, prefix, suffix, size, type, ...props }: InputProps) {
|
||||
const grouped = prefix != null || suffix != null
|
||||
|
||||
const field = (
|
||||
<input
|
||||
// Off by default for every consumer — these are code/config/search fields,
|
||||
// not prose. Callers can re-enable per-instance by passing the prop.
|
||||
|
|
@ -13,7 +27,12 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
|
|||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
controlVariants({ size }),
|
||||
// When adorned, the wrapper owns the chrome (border/background/focus
|
||||
// glow) and the input goes transparent so the whole thing reads as one
|
||||
// box; otherwise the input carries the chrome itself.
|
||||
grouped
|
||||
? 'min-w-0 flex-1 border-0 bg-transparent p-0 text-xs leading-4 text-foreground outline-none placeholder:text-muted-foreground'
|
||||
: controlVariants({ size }),
|
||||
'selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-xs file:font-medium file:text-foreground',
|
||||
className
|
||||
)}
|
||||
|
|
@ -23,6 +42,28 @@ function Input({ className, type, size, ...props }: Omit<React.ComponentProps<'i
|
|||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!grouped) {
|
||||
return field
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// Same control chrome/sizing as a bare input; `.desktop-input-chrome`
|
||||
// lights on `:focus-within` (styles.css) since the div never focuses.
|
||||
controlVariants({ size }),
|
||||
'inline-flex items-center gap-1',
|
||||
props.disabled && 'cursor-not-allowed opacity-50',
|
||||
containerClassName
|
||||
)}
|
||||
data-slot="input-group"
|
||||
>
|
||||
{prefix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{prefix}</span>}
|
||||
{field}
|
||||
{suffix != null && <span className="pointer-events-none shrink-0 select-none text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ interface SegmentedControlProps<T extends string> {
|
|||
value: T
|
||||
onChange: (id: T) => void
|
||||
className?: string
|
||||
/** Dims the whole track and blocks selection (e.g. gated behind a prerequisite). */
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -19,11 +21,18 @@ interface SegmentedControlProps<T extends string> {
|
|||
* (color mode, tool-call display, usage period, etc.). Flat by design —
|
||||
* no per-option borders, just a tinted track with a raised active pill.
|
||||
*/
|
||||
export function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
|
||||
export function SegmentedControl<T extends string>({
|
||||
className,
|
||||
disabled = false,
|
||||
onChange,
|
||||
options,
|
||||
value
|
||||
}: SegmentedControlProps<T>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-grid w-fit auto-cols-fr grid-flow-col gap-0.5 rounded-[5px] bg-(--ui-bg-tertiary) p-0.5',
|
||||
disabled && 'opacity-50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
|
@ -34,9 +43,10 @@ export function SegmentedControl<T extends string>({ options, value, onChange, c
|
|||
<button
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors',
|
||||
'flex items-center justify-center gap-1 rounded-[3px] px-2.5 py-0.5 text-[0.6875rem] font-medium transition-colors disabled:cursor-default',
|
||||
active ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
disabled={disabled}
|
||||
key={id}
|
||||
onClick={() => onChange(id)}
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -840,6 +840,7 @@ text-* variant utilities. */ .btn-arc {
|
|||
/* `[data-state='open']` keeps the trigger looking focused while its dropdown is
|
||||
open — Radix moves focus into the list, so the trigger itself loses :focus. */
|
||||
.desktop-input-chrome:focus,
|
||||
.desktop-input-chrome:focus-within,
|
||||
.desktop-input-chrome[data-state='open'] {
|
||||
border-color: var(--dt-composer-ring);
|
||||
box-shadow: none;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue