mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
## What kind of change does this PR introduce? Bug fix / design-system token hygiene for form and selector chrome. ## What is the current behavior? After opaque default-button fills, text fields, selects, and selector tiles drifted apart: inputs and selects mixed ad-hoc washes, hover borders bounced between `border-stronger` / `border-foreground-muted`, invalid fields had no hover step, and composites like InputGroup leaked inner hover borders. Follow-up to #48837 (opaque button fills) where Select rest still felt darker than Input on forms such as scoped access tokens. ## What is the new behavior? Named control roles and one interactive border: | Role | Fill | Rest border | Hover / focus / open | | --- | --- | --- | --- | | Field (sunk) | `bg-field` | `border-control` | `border-control-hover` | | Raised control | `bg-control-raised` | `border-strong` | `border-control-hover` | | Overlaying action | card → popover | `border-strong` | `border-control-hover` | | Invalid field | `bg-destructive-200` | `border-destructive-400` | `border-destructive` | - `--field` / `--control-raised` / `--border-control-hover` live in `semantic.css` (source of truth for roles; README points there) - Input / Textarea / InputGroup / legacy TextArea use the field ladder (incl. invalid hover) - Select and empty MultiSelect use raised; filled MultiSelect sinks to field - Default + dashed Button, CommandMenu trigger, and radio card/stacked/large use `border-control-hover` - Studio selector tiles aligned: Connect mode, role impersonation, DuckLake modes, compute “Contact us” | Before and After | | --- | | <img width="1576" height="759" alt="Access Tokens Account Supabase" src="https://github.com/user-attachments/assets/4bbe8b2b-a31a-4d63-80ba-04a1a8a5609d" /> | | <img width="1576" height="759" alt="Access Tokens Account Supabase" src="https://github.com/user-attachments/assets/92271223-4103-4cc6-a7c0-9e4ff71cce30" /> | ## Additional context `--control-raised` aliases `--card` today (role name so fill can diverge later). Rest `border-control` / `border-strong` both still map to `--input` via compat; the shared interactive step is `--border-control-hover`. ## To test 1. **[Account → Access Tokens](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/account/tokens)** Open Generate / New scoped token. Side-by-side Input, Select, RadioGroupStacked, MultiSelect. Confirm sunk vs raised fills, shared hover border, MultiSelect flips to sunk once a value is selected. Leave a required field empty to check invalid rest → hover → focus. 2. **[Org → Projects](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/org/_)** Hover the dashed Status filter. Hover default / filled filter buttons when active. Confirm hover/open borders match. 3. **[Project → Connect](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/project/_)** Open Connect from the header. Mode grid tiles: hover + selected borders match radio cards (no old muted-foreground ring). 4. **[Project → Compute](https://studio-staging-git-dnywh-fixcontrol-surface-tokens-supabase.vercel.app/dashboard/project/_/settings/infrastructure)** (optional) Compute size radios + “Contact us” tile hover.
302 lines
8.3 KiB
TypeScript
302 lines
8.3 KiB
TypeScript
import { cva, type VariantProps } from 'class-variance-authority'
|
|
import { Box, Cable, Database, Server, Sparkles } from 'lucide-react'
|
|
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
|
import { cn } from 'ui'
|
|
|
|
import type { ConnectMode } from './Connect.types'
|
|
|
|
const MODE_ICONS: Record<string, ReactNode> = {
|
|
framework: <Box size={16} strokeWidth={1.5} />,
|
|
direct: <Database size={16} strokeWidth={1.5} />,
|
|
orm: <Cable size={16} strokeWidth={1.5} />,
|
|
mcp: <Sparkles size={16} strokeWidth={1.5} />,
|
|
server: <Server size={16} strokeWidth={1.5} />,
|
|
}
|
|
|
|
/** Maps mode count → container-query breakpoint used when collapsing to a single row. */
|
|
type ModeCountBreakpoint = 3 | 4 | 5 | 6
|
|
|
|
function toModeCountBreakpoint(count: number): ModeCountBreakpoint {
|
|
if (count === 3 || count === 4 || count === 5) return count
|
|
return 6
|
|
}
|
|
|
|
const connectModeButtonVariants = cva(
|
|
[
|
|
// Each cell owns a border; adjacent edges overlap (RadioGroupStacked-style)
|
|
'relative -mb-px -mr-px flex cursor-pointer flex-col items-center gap-2 border bg-overlay/50 p-4 shadow-xs transition-colors',
|
|
'focus-visible:z-1 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
|
],
|
|
{
|
|
variants: {
|
|
selected: {
|
|
true: 'z-1 border-control-hover bg-surface-300',
|
|
false: 'hover:z-1 hover:border-control-hover hover:bg-background dark:hover:bg-surface-200',
|
|
},
|
|
// Narrow 2-col outer corners
|
|
topLeft: {
|
|
true: 'rounded-tl-lg',
|
|
false: '',
|
|
},
|
|
topRight: {
|
|
true: 'rounded-tr-lg',
|
|
false: '',
|
|
},
|
|
bottomLeft: {
|
|
true: 'rounded-bl-lg',
|
|
false: '',
|
|
},
|
|
bottomRight: {
|
|
true: 'rounded-br-lg',
|
|
false: '',
|
|
},
|
|
// Wide single-row reshaping (compounded with modeCount for the breakpoint)
|
|
singleRowStart: {
|
|
true: '',
|
|
false: '',
|
|
},
|
|
singleRowEnd: {
|
|
true: '',
|
|
false: '',
|
|
},
|
|
clearTopRight: {
|
|
true: '',
|
|
false: '',
|
|
},
|
|
clearBottomLeft: {
|
|
true: '',
|
|
false: '',
|
|
},
|
|
clearBottomRight: {
|
|
true: '',
|
|
false: '',
|
|
},
|
|
modeCount: {
|
|
3: '',
|
|
4: '',
|
|
5: '',
|
|
6: '',
|
|
},
|
|
},
|
|
compoundVariants: [
|
|
// Full class strings so Tailwind can see them (no dynamic fragment concatenation)
|
|
{
|
|
singleRowStart: true,
|
|
modeCount: 3,
|
|
class: '@[28rem]:rounded-tl-lg @[28rem]:rounded-bl-lg',
|
|
},
|
|
{
|
|
singleRowStart: true,
|
|
modeCount: 4,
|
|
class: '@[30rem]:rounded-tl-lg @[30rem]:rounded-bl-lg',
|
|
},
|
|
{
|
|
singleRowStart: true,
|
|
modeCount: 5,
|
|
class: '@[32rem]:rounded-tl-lg @[32rem]:rounded-bl-lg',
|
|
},
|
|
{
|
|
singleRowStart: true,
|
|
modeCount: 6,
|
|
class: '@[36rem]:rounded-tl-lg @[36rem]:rounded-bl-lg',
|
|
},
|
|
|
|
{ singleRowEnd: true, modeCount: 3, class: '@[28rem]:rounded-tr-lg @[28rem]:rounded-br-lg' },
|
|
{ singleRowEnd: true, modeCount: 4, class: '@[30rem]:rounded-tr-lg @[30rem]:rounded-br-lg' },
|
|
{ singleRowEnd: true, modeCount: 5, class: '@[32rem]:rounded-tr-lg @[32rem]:rounded-br-lg' },
|
|
{ singleRowEnd: true, modeCount: 6, class: '@[36rem]:rounded-tr-lg @[36rem]:rounded-br-lg' },
|
|
|
|
{ clearTopRight: true, modeCount: 3, class: '@[28rem]:rounded-tr-none' },
|
|
{ clearTopRight: true, modeCount: 4, class: '@[30rem]:rounded-tr-none' },
|
|
{ clearTopRight: true, modeCount: 5, class: '@[32rem]:rounded-tr-none' },
|
|
{ clearTopRight: true, modeCount: 6, class: '@[36rem]:rounded-tr-none' },
|
|
|
|
{ clearBottomLeft: true, modeCount: 3, class: '@[28rem]:rounded-bl-none' },
|
|
{ clearBottomLeft: true, modeCount: 4, class: '@[30rem]:rounded-bl-none' },
|
|
{ clearBottomLeft: true, modeCount: 5, class: '@[32rem]:rounded-bl-none' },
|
|
{ clearBottomLeft: true, modeCount: 6, class: '@[36rem]:rounded-bl-none' },
|
|
|
|
{ clearBottomRight: true, modeCount: 3, class: '@[28rem]:rounded-br-none' },
|
|
{ clearBottomRight: true, modeCount: 4, class: '@[30rem]:rounded-br-none' },
|
|
{ clearBottomRight: true, modeCount: 5, class: '@[32rem]:rounded-br-none' },
|
|
{ clearBottomRight: true, modeCount: 6, class: '@[36rem]:rounded-br-none' },
|
|
],
|
|
defaultVariants: {
|
|
selected: false,
|
|
topLeft: false,
|
|
topRight: false,
|
|
bottomLeft: false,
|
|
bottomRight: false,
|
|
singleRowStart: false,
|
|
singleRowEnd: false,
|
|
clearTopRight: false,
|
|
clearBottomLeft: false,
|
|
clearBottomRight: false,
|
|
modeCount: 6,
|
|
},
|
|
}
|
|
)
|
|
|
|
const connectModeButtonIconVariants = cva('', {
|
|
variants: {
|
|
selected: {
|
|
true: 'text-foreground',
|
|
false: 'text-foreground-light',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
selected: false,
|
|
},
|
|
})
|
|
|
|
const connectModeButtonLabelVariants = cva('heading-default text-center', {
|
|
variants: {
|
|
selected: {
|
|
true: 'text-foreground',
|
|
false: 'text-foreground-light',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
selected: false,
|
|
},
|
|
})
|
|
|
|
const connectModeButtonDescriptionVariants = cva('text-sm leading-tight text-center', {
|
|
variants: {
|
|
selected: {
|
|
true: 'text-foreground-light',
|
|
false: 'text-foreground-lighter',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
selected: false,
|
|
},
|
|
})
|
|
|
|
const connectModeEmptySlotVariants = cva(
|
|
// Sunk vs mode tiles (bg-overlay/50); surface-200 reads clearly recessed on light
|
|
'relative -mb-px -mr-px rounded-br-lg border bg-surface-200 dark:bg-surface-100',
|
|
{
|
|
variants: {
|
|
modeCount: {
|
|
3: '@[28rem]:hidden',
|
|
4: '@[30rem]:hidden',
|
|
5: '@[32rem]:hidden',
|
|
6: '@[36rem]:hidden',
|
|
},
|
|
},
|
|
defaultVariants: {
|
|
modeCount: 6,
|
|
},
|
|
}
|
|
)
|
|
|
|
type ConnectModeButtonCornerVariants = Pick<
|
|
VariantProps<typeof connectModeButtonVariants>,
|
|
| 'topLeft'
|
|
| 'topRight'
|
|
| 'bottomLeft'
|
|
| 'bottomRight'
|
|
| 'singleRowStart'
|
|
| 'singleRowEnd'
|
|
| 'clearTopRight'
|
|
| 'clearBottomLeft'
|
|
| 'clearBottomRight'
|
|
| 'modeCount'
|
|
>
|
|
|
|
export interface ConnectModeButtonProps
|
|
extends Omit<ComponentPropsWithoutRef<'button'>, 'children'>, ConnectModeButtonCornerVariants {
|
|
modeId: ConnectMode
|
|
label: string
|
|
description: string
|
|
selected?: boolean
|
|
}
|
|
|
|
export function ConnectModeButton({
|
|
modeId,
|
|
label,
|
|
description,
|
|
selected = false,
|
|
topLeft,
|
|
topRight,
|
|
bottomLeft,
|
|
bottomRight,
|
|
singleRowStart,
|
|
singleRowEnd,
|
|
clearTopRight,
|
|
clearBottomLeft,
|
|
clearBottomRight,
|
|
modeCount,
|
|
className,
|
|
type = 'button',
|
|
...props
|
|
}: ConnectModeButtonProps) {
|
|
return (
|
|
<button
|
|
type={type}
|
|
tabIndex={0}
|
|
aria-pressed={selected}
|
|
className={cn(
|
|
connectModeButtonVariants({
|
|
selected,
|
|
topLeft,
|
|
topRight,
|
|
bottomLeft,
|
|
bottomRight,
|
|
singleRowStart,
|
|
singleRowEnd,
|
|
clearTopRight,
|
|
clearBottomLeft,
|
|
clearBottomRight,
|
|
modeCount,
|
|
}),
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
<span className={connectModeButtonIconVariants({ selected })}>{MODE_ICONS[modeId]}</span>
|
|
<div>
|
|
<p className={connectModeButtonLabelVariants({ selected })}>{label}</p>
|
|
<p className={connectModeButtonDescriptionVariants({ selected })}>{description}</p>
|
|
</div>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
/** Derive CVA corner variants from a cell's position in the mode grid. */
|
|
export function getConnectModeButtonCornerVariants({
|
|
index,
|
|
count,
|
|
emptySlots,
|
|
}: {
|
|
index: number
|
|
count: number
|
|
emptySlots: number
|
|
}): ConnectModeButtonCornerVariants {
|
|
const isLast = index === count - 1
|
|
const narrowLastRowStart = (Math.ceil(count / 2) - 1) * 2
|
|
const topLeft = index === 0
|
|
const topRight = index === 1
|
|
const bottomLeft = index === narrowLastRowStart
|
|
const bottomRight = emptySlots === 0 && isLast
|
|
|
|
return {
|
|
modeCount: toModeCountBreakpoint(count),
|
|
topLeft,
|
|
topRight,
|
|
bottomLeft,
|
|
bottomRight,
|
|
// Once wide enough for a single row, reshape corners to left/right caps
|
|
singleRowStart: index === 0,
|
|
singleRowEnd: isLast,
|
|
clearTopRight: topRight && !isLast,
|
|
clearBottomLeft: bottomLeft && index !== 0,
|
|
clearBottomRight: bottomRight && !isLast,
|
|
}
|
|
}
|
|
|
|
export function getConnectModeEmptySlotClasses(count: number) {
|
|
return connectModeEmptySlotVariants({ modeCount: toModeCountBreakpoint(count) })
|
|
}
|