mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(ui): refresh shared button styles (#50197)
## What kind of change does this PR introduce? UI polish / design system: refreshed button styles, related token tweaks, and a shared floating-button plate. Resolves DEPR-652. ## What is the current behavior? Default, primary, and secondary buttons use older fills, borders, and hover treatments. Primary still leans on brand scale utilities. Default fills don’t always read as raised chrome across surfaces, and floating copy / expand / scroll controls can let busy content show through translucent fills. Call sites hand-roll `rounded-* bg-background` wrappers for that. ## What is the new behavior? Refreshes primary, default, and secondary buttons with medium-weight labels, subtle shadows and inset edges, and smoother transitions. Light-mode default buttons use a raised fill with an accent hover state, primary text is brighter, and inline keyboard shortcuts inherit the button’s colour. Adds `FloatingPlate`: an opaque `bg-popover` shell for floating default buttons (and small clusters). Migrates Studio, Docs-related patterns, www, and `ui-patterns` floaters onto it so busy content no longer shows through translucent fills. Positioning, z-index, and hover/focus reveal stay on the plate’s `className`. Use `rounded="full"` for pills. Also: - Moves primary onto semantic `--primary` / `--primary-hover` (with a light-theme override) instead of brand utility fills - Tokenises button shadows as `--button-shadow-drop` / `--button-shadow-raised` / `--button-shadow-default` on the Button base - Aligns hover direction: darken on light mode, lighten on dark mode for both default and primary - Default fill stays opaque `bg-card` in light (occlusion) and translucent `bg-muted` in dark (adapts to the local surface) - Documents fills and `FloatingPlate` on the design-system Button page (with a live example) - Scales shared radius tokens in Studio and www; medium+ Button sizes use a proportionally softer radius - Fixes www nav CTA centering (`lg:inline-flex` instead of `lg:block`) - Query detail Expand/Collapse wires `aria-expanded` / `aria-controls` | Before | After | | --- | --- | | <img width="1074" height="438" alt="CleanShot 2026-09-18 at 15 52 51@2x" src="https://github.com/user-attachments/assets/ef43da21-b053-4b7e-9ac4-ab8b428228ab" /> | <img width="1090" height="464" alt="CleanShot 2026-09-18 at 15 50 59@2x" src="https://github.com/user-attachments/assets/2ddc55fc-4c8d-499c-a280-f3db3d99023c" /> | | <img width="1082" height="446" alt="CleanShot 2026-09-18 at 15 52 35@2x" src="https://github.com/user-attachments/assets/3dd5452d-325a-4e4a-a79d-26c6c6950a31" /> | <img width="1078" height="446" alt="CleanShot 2026-09-18 at 15 51 13@2x" src="https://github.com/user-attachments/assets/93666385-3e6e-42e0-9891-9cd6bb935b67" /> | ## To test ### Design system - [Button page](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button): default / primary in light and dark; hover should darken on light, lighten on dark - Same page: [Floating over content](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-over-content) / [Floating plate](https://design-system-git-chore-button-styles-supabase.vercel.app/design-system/docs/components/button#floating-plate) example; Copy over SQL should stay opaque - Spot-check hover on a code preview Copy control ### Docs [Docs deploy preview](https://docs-git-chore-button-styles-supabase.vercel.app/docs): - [Docs homepage](https://docs-git-chore-button-styles-supabase.vercel.app/docs): top-right **Sign up** / **Dashboard** primary; menu icon beside it (default icon button) - Shrink below `lg` and open the hamburger drawer: bottom **Sign in** (default) + **Start your project** (primary) medium block buttons - Tab once for **Skip to content** (FloatingPlate) - [MCP guide](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/ai-tools/mcp): project picker - [Apple login](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/auth/social-login/auth-apple): **Generate Secret Key** button in the Apple Secret Generator - Optional opacity check: any guide code block Copy control (e.g. at the bottom of [Import data into Supabase](https://docs-git-chore-button-styles-supabase.vercel.app/docs/guides/database/import-data)) ### Studio [Studio deploy preview](https://studio-staging-git-chore-button-styles-supabase.vercel.app/): - **Observability → Query Performance**: open a query detail → Expand/Collapse pill + SQL Copy chip (dark: no bleed-through) - **Observability → Query Insights**: select a query → Clear query pill - **Table Editor → any table → Definition** → floating **Open in SQL Editor** - **Connect → Framework → Add files**: Copy on the code tabs (FloatingPlate; light hover follow-up is DEPR-694) - Tab once for **Skip to content** ### WWW - [www deploy preview](https://zone-www-dot-com-git-chore-button-styles-supabase.vercel.app/): nav Sign in / Start your project vertical centering; hero medium CTAs radius --------- Co-authored-by: Danny White <3104761+dnywh@users.noreply.github.com>
This commit is contained in:
1 parent
59e2122833
commit
cd77bebafd
37 files changed
+437
-183
No files matched your search
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
||||
import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, FloatingPlate } from 'ui'
|
||||
|
||||
interface CodeBlockProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
expandButtonTitle?: string
|
||||
@@ -34,11 +34,13 @@ export function CodeBlockWrapper({
|
||||
isOpened ? 'inset-x-0 bottom-0 h-12' : 'inset-0'
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="secondary" className="h-8 text-xs">
|
||||
{isOpened ? 'Collapse' : expandButtonTitle}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<FloatingPlate>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="secondary" className="h-8 text-xs">
|
||||
{isOpened ? 'Collapse' : expandButtonTitle}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ChevronRight, Expand } from 'lucide-react'
|
||||
import * as React from 'react'
|
||||
import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger } from 'ui'
|
||||
import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, FloatingPlate } from 'ui'
|
||||
|
||||
import { Index } from '@/__registry__'
|
||||
import { useConfig } from '@/hooks/use-config'
|
||||
@@ -122,13 +122,15 @@ export function ComponentPreview({
|
||||
>
|
||||
{Code}
|
||||
<div className="absolute bottom-0 w-full flex justify-center mb-4">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => setExpandState(!expand)}
|
||||
icon={<Expand className="text-foreground-lighter" />}
|
||||
>
|
||||
{expand ? 'Collapse code' : 'Expand code'}
|
||||
</Button>
|
||||
<FloatingPlate rounded="full">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => setExpandState(!expand)}
|
||||
icon={<Expand className="text-foreground-lighter" />}
|
||||
>
|
||||
{expand ? 'Collapse code' : 'Expand code'}
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -133,6 +133,40 @@ Ensure the middle border is shared rather than doubled-up. Do not use `border-l-
|
||||
|
||||
Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions when `layout="responsive"`: also use `flex w-full @lg:w-auto` with `flex-1 @lg:flex-none` on the primary action.
|
||||
|
||||
## Default fill and floating buttons
|
||||
|
||||
The default variant is meant to read as raised chrome on whatever surface it sits on.
|
||||
|
||||
- **Light:** opaque `bg-card` (a solid elevated plate). Opaque on purpose so the button can cover busy content underneath.
|
||||
- **Dark:** translucent `bg-muted` (a foreground wash). That adapts to the local surface, but content can show through the fill.
|
||||
|
||||
Hover: light uses `hover:bg-muted`; dark uses `dark:hover:bg-accent`.
|
||||
|
||||
### Floating over content
|
||||
|
||||
If a default button is absolutely or sticky-positioned over code, tables, maps, or other busy UI, wrap it in [`FloatingPlate`](#floating-plate) so nothing bleeds through:
|
||||
|
||||
<ComponentPreview name="button-floating-plate" peekCode />
|
||||
|
||||
Keep positioning, z-index, and hover/focus reveal on the plate's `className`. Use `rounded="full"` when the child is a pill. For clusters (split toggles, parallel actions), wrap the group once.
|
||||
|
||||
Do not override the button fill with `bg-popover` at the callsite. The plate owns occlusion; the button stays a normal default control.
|
||||
|
||||
## Floating plate
|
||||
|
||||
`FloatingPlate` is an opaque `bg-popover` shell for floating default buttons (and small clusters).
|
||||
|
||||
```tsx
|
||||
import { Button, FloatingPlate } from 'ui'
|
||||
```
|
||||
|
||||
| Prop | Default | Notes |
|
||||
| ----------- | ------- | --------------------------------------------------------------- |
|
||||
| `rounded` | `lg` | `md`, `lg`, or `full`. Match or exceed the child button radius. |
|
||||
| `className` | — | Positioning, opacity, gaps for clusters. |
|
||||
|
||||
See [Floating over content](#floating-over-content) for when to use it.
|
||||
|
||||
## Accessibility
|
||||
|
||||
[Keyboard focus](../accessibility#focus-management) is automatically handled:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Copy } from 'lucide-react'
|
||||
import { Button, FloatingPlate } from 'ui'
|
||||
|
||||
export default function ButtonFloatingPlate() {
|
||||
return (
|
||||
<div className="relative w-full max-w-md overflow-hidden rounded-md border border-border">
|
||||
<pre className="bg-surface-100 p-4 pr-16 font-mono text-xs text-foreground-light leading-relaxed">
|
||||
{`select *
|
||||
from projects
|
||||
where status = 'ACTIVE_HEALTHY'
|
||||
order by created_at desc
|
||||
limit 20;`}
|
||||
</pre>
|
||||
<FloatingPlate className="absolute right-2 top-2">
|
||||
<Button size="tiny" icon={<Copy />}>
|
||||
Copy
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -199,6 +199,12 @@ export const examples: Registry = [
|
||||
registryDependencies: ['button'],
|
||||
files: ['example/button-default.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'button-floating-plate',
|
||||
type: 'components:example',
|
||||
registryDependencies: ['button'],
|
||||
files: ['example/button-floating-plate.tsx'],
|
||||
},
|
||||
{
|
||||
name: 'button-warning',
|
||||
type: 'components:example',
|
||||
|
||||
@@ -75,7 +75,7 @@ const TopNavBar: FC = () => {
|
||||
title="Menu dropdown button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'default' }),
|
||||
'flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] data-open:bg-overlay-hover/30'
|
||||
'flex lg:hidden border-default bg-surface-100/75 text-foreground-light rounded-md min-w-[30px] w-[30px] h-[30px] px-0 data-open:bg-overlay-hover/30'
|
||||
)}
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
|
||||
@@ -71,7 +71,7 @@ const TopNavDropdown = () => {
|
||||
title="Menu dropdown button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'default' }),
|
||||
'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] data-open:bg-overlay-hover/30 hover:border-strong data-open:border-stronger hover:bg-overlay-hover/50! bg-transparent'
|
||||
'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] px-0 data-open:bg-overlay-hover/30 hover:border-strong data-open:border-stronger hover:bg-overlay-hover/50! bg-transparent'
|
||||
)}
|
||||
>
|
||||
<Menu size={18} strokeWidth={1} />
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type Feature } from 'common'
|
||||
import { mdxToMarkdown } from 'mdast-util-mdx'
|
||||
import { toMarkdown } from 'mdast-util-to-markdown'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { type Feature } from 'common'
|
||||
import { showRemark } from './Show'
|
||||
import { fromDocsMarkdown } from './utils.server'
|
||||
|
||||
|
||||
+1
-1
@@ -325,7 +325,7 @@ describe('ReplicationPipelineLayout', () => {
|
||||
renderLayout()
|
||||
|
||||
const updateButton = await screen.findByRole('button', { name: 'Update available' })
|
||||
expect(updateButton).toHaveClass('bg-brand-400')
|
||||
expect(updateButton).toHaveClass('bg-primary')
|
||||
await userEvent.click(updateButton)
|
||||
// The trigger button shares this name, so match the dialog's heading specifically
|
||||
expect(await screen.findByRole('heading', { name: 'Update available' })).toBeVisible()
|
||||
|
||||
+10
-8
@@ -6,7 +6,7 @@ import { useRouter } from 'next/router'
|
||||
import { parseAsArrayOf, parseAsString, useQueryStates } from 'nuqs'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { DataGridHandle, Row } from 'react-data-grid'
|
||||
import { Button, cn, Tabs, TabsList, TabsTrigger } from 'ui'
|
||||
import { Button, cn, FloatingPlate, Tabs, TabsList, TabsTrigger } from 'ui'
|
||||
import { Input } from 'ui-patterns/DataInputs/Input'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
@@ -422,13 +422,15 @@ export const QueryInsightsTable = ({
|
||||
: 'opacity-0 translate-y-4 pointer-events-none',
|
||||
].join(' ')}
|
||||
>
|
||||
<Button
|
||||
size="tiny"
|
||||
className="rounded-full shadow-md"
|
||||
onClick={() => onCurrentSelectQuery?.(null)}
|
||||
>
|
||||
Clear query
|
||||
</Button>
|
||||
<FloatingPlate rounded="full">
|
||||
<Button
|
||||
size="tiny"
|
||||
className="rounded-full shadow-md"
|
||||
onClick={() => onCurrentSelectQuery?.(null)}
|
||||
>
|
||||
Clear query
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="px-6 py-4">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronsUpDown, Lightbulb } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert, AlertDescription, AlertTitle, Button, cn } from 'ui'
|
||||
import { Alert, AlertDescription, AlertTitle, Button, cn, FloatingPlate } from 'ui'
|
||||
|
||||
import { QueryPanelContainer, QueryPanelSection } from './QueryPanel'
|
||||
import { buildQueryExplanationPrompt } from './QueryPerformance.ai'
|
||||
@@ -92,6 +92,7 @@ export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: Que
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="query-detail-sql-panel"
|
||||
className={cn(
|
||||
'overflow-hidden pb-0 z-0 relative transition-all duration-300',
|
||||
isExpanded ? 'h-[348px]' : 'h-[120px]'
|
||||
@@ -126,13 +127,18 @@ export const QueryDetail = ({ selectedRow, onClickViewSuggestion, onClose }: Que
|
||||
)}
|
||||
/>
|
||||
<div className="absolute bottom-[-13px] left-0 right-0 w-full flex items-center justify-center z-10">
|
||||
<Button
|
||||
className="rounded-full"
|
||||
icon={<ChevronsUpDown />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
<FloatingPlate rounded="full">
|
||||
<Button
|
||||
type="button"
|
||||
className="rounded-full"
|
||||
icon={<ChevronsUpDown />}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls="query-detail-sql-panel"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isExpanded ? 'Collapse' : 'Expand'}
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
</QueryPanelSection>
|
||||
<QueryPanelSection className="pb-3 pt-6">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Button, cn, copyToClipboard } from 'ui'
|
||||
import { Button, cn, copyToClipboard, FloatingPlate } from 'ui'
|
||||
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
@@ -36,7 +36,7 @@ export const SqlMonacoBlock = ({ value, wrapperClassName }: SqlMonacoBlockProps)
|
||||
options={{ padding: { top: 12, bottom: 12 } }}
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<FloatingPlate className="absolute right-2 top-2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
|
||||
<Button
|
||||
className="px-1.5"
|
||||
icon={copied ? <Check /> : <Copy />}
|
||||
@@ -44,7 +44,7 @@ export const SqlMonacoBlock = ({ value, wrapperClassName }: SqlMonacoBlockProps)
|
||||
>
|
||||
{copied ? 'Copied' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+3
-3
@@ -3,7 +3,7 @@ import { useParams } from 'common'
|
||||
import { partition } from 'lodash'
|
||||
import { Globe2, Loader2, Network } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Button, FloatingPlate } from 'ui'
|
||||
|
||||
import { DiagramFlow } from './DiagramFlow'
|
||||
import { SmoothstepEdge } from './Edge'
|
||||
@@ -177,7 +177,7 @@ const InstanceConfigurationUI = () => {
|
||||
{isSuccessReplicas && !isLoadingProject && (
|
||||
<>
|
||||
{infrastructureReadReplicas && (
|
||||
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
|
||||
<FloatingPlate className="z-10 absolute top-4 right-4 items-center justify-center gap-x-2">
|
||||
{isAws && (
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
@@ -196,7 +196,7 @@ const InstanceConfigurationUI = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FloatingPlate>
|
||||
)}
|
||||
{view === 'flow' ? (
|
||||
<DiagramFlow
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams } from 'common'
|
||||
import Link from 'next/link'
|
||||
import { useMemo } from 'react'
|
||||
import { Button } from 'ui'
|
||||
import { Button, FloatingPlate } from 'ui'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { Footer } from '@/components/grid/components/footer/Footer'
|
||||
@@ -66,15 +66,17 @@ export const TableDefinition = ({ entity }: TableDefinitionProps) => {
|
||||
return (
|
||||
<>
|
||||
<div className="grow overflow-y-auto border-t border-muted relative">
|
||||
<Button asChild className="absolute top-2 right-5 z-10">
|
||||
<Link
|
||||
href={`/project/${ref}/sql/new?content=${encodeURIComponent(
|
||||
formattedDefinition ?? ''
|
||||
)}`}
|
||||
>
|
||||
Open in SQL Editor
|
||||
</Link>
|
||||
</Button>
|
||||
<FloatingPlate className="absolute top-2 right-5 z-10">
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/project/${ref}/sql/new?content=${encodeURIComponent(
|
||||
formattedDefinition ?? ''
|
||||
)}`}
|
||||
>
|
||||
Open in SQL Editor
|
||||
</Link>
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
|
||||
<CodeEditor isReadOnly language="pgsql" value={formattedDefinition} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Button,
|
||||
copyToClipboard,
|
||||
FloatingPlate,
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
Skeleton,
|
||||
@@ -246,18 +247,20 @@ export function ServiceFlowPanel({
|
||||
</div>
|
||||
)}
|
||||
<div className="sticky top-2 z-10 flex justify-end px-2 -mb-9 pointer-events-none">
|
||||
<Button
|
||||
size="tiny"
|
||||
className="pointer-events-auto px-1.5"
|
||||
icon={jsonCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
onClick={() => {
|
||||
copyToClipboard(JSON.stringify(formattedJsonData, null, 2))
|
||||
setJsonCopied(true)
|
||||
setTimeout(() => setJsonCopied(false), 1000)
|
||||
}}
|
||||
>
|
||||
{jsonCopied ? 'Copied' : ''}
|
||||
</Button>
|
||||
<FloatingPlate className="pointer-events-auto">
|
||||
<Button
|
||||
size="tiny"
|
||||
className="px-1.5"
|
||||
icon={jsonCopied ? <Check size={12} /> : <Copy size={12} />}
|
||||
onClick={() => {
|
||||
copyToClipboard(JSON.stringify(formattedJsonData, null, 2))
|
||||
setJsonCopied(true)
|
||||
setTimeout(() => setJsonCopied(false), 1000)
|
||||
}}
|
||||
>
|
||||
{jsonCopied ? 'Copied' : ''}
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowDownIcon } from 'lucide-react'
|
||||
import type { ComponentProps, ReactNode } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Button, cn, FloatingPlate } from 'ui'
|
||||
import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
|
||||
|
||||
type ConversationProps = Omit<ComponentProps<typeof StickToBottom>, 'children'> & {
|
||||
@@ -60,14 +60,16 @@ export const ConversationScrollButton = ({
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn('absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full', className)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="tiny"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
<FloatingPlate rounded="full" className="absolute bottom-4 left-[50%] translate-x-[-50%]">
|
||||
<Button
|
||||
className={cn('rounded-full', className)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="tiny"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Expand } from 'lucide-react'
|
||||
import { Button, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Button, FloatingPlate, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { formatCellValue, isLargeValue } from './DataGridResults.utils'
|
||||
import { NullValue } from '@/components/grid/components/common/NullValue'
|
||||
@@ -25,18 +25,20 @@ export const ResultCell = ({ column, value, onContextMenu, onExpand }: ResultCel
|
||||
{value === null ? <NullValue /> : formatCellValue(value)}
|
||||
{showExpand && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 px-1 opacity-0 group-hover/cell:opacity-100 focus-visible:opacity-100"
|
||||
icon={<Expand size={10} />}
|
||||
aria-label="View full cell content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpand(column, value)
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<FloatingPlate className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/cell:opacity-100 focus-within:opacity-100">
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="tiny"
|
||||
className="px-1"
|
||||
icon={<Expand size={10} />}
|
||||
aria-label="View full cell content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onExpand(column, value)
|
||||
}}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
</FloatingPlate>
|
||||
<TooltipContent side="left">View full cell content</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,17 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Scale Tailwind's default radius tokens by 4/3 to match the Studio curve. */
|
||||
--radius: 0.333333rem;
|
||||
--radius-xs: 0.166667rem;
|
||||
--radius-sm: 0.333333rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.666667rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.333333rem;
|
||||
--radius-3xl: 2rem;
|
||||
--radius-4xl: 2.666667rem;
|
||||
|
||||
--text-grid: 13px;
|
||||
|
||||
--color-typography-body-light: var(--foreground-light);
|
||||
|
||||
@@ -217,14 +217,14 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
<GitHubButton />
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Button className="hidden lg:block" asChild>
|
||||
<Button className="hidden lg:inline-flex" asChild>
|
||||
<Link href="/dashboard/projects">Dashboard</Link>
|
||||
</Button>
|
||||
<AuthenticatedDropdownMenu menu={userMenu} user={user} site="www" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="hidden lg:block" asChild>
|
||||
<Button className="hidden lg:inline-flex" asChild>
|
||||
<Link
|
||||
href="https://supabase.com/dashboard"
|
||||
onClick={() =>
|
||||
@@ -237,7 +237,7 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => {
|
||||
Sign in
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="primary" className="hidden lg:block" asChild>
|
||||
<Button variant="primary" className="hidden lg:inline-flex" asChild>
|
||||
<Link
|
||||
href="https://supabase.com/dashboard/sign-up"
|
||||
onClick={() =>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ArrowUpRight, Check, Copy } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React, { useState } from 'react'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { Button, cn, FloatingPlate, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { TextLink } from 'ui-patterns/TextLink'
|
||||
|
||||
import SectionContainer from '@/components/Layouts/SectionContainer'
|
||||
@@ -52,17 +52,19 @@ const CodeSnippet = ({ prompt }: { prompt: AIPrompt }) => {
|
||||
<div className="p-4 relative">
|
||||
{prompt.copyable && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="text-foreground-light hover:text-foreground absolute top-2 right-2 w-7 h-7 p-1 shadow-lg"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(prompt.code)
|
||||
updateCopyStatus()
|
||||
}}
|
||||
>
|
||||
{text === 'copy' ? <Copy className="w-3 h-3" /> : <Check className="w-3 h-3" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<FloatingPlate className="absolute top-2 right-2">
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="text-foreground-light hover:text-foreground w-7 h-7 p-1 shadow-lg"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(prompt.code)
|
||||
updateCopyStatus()
|
||||
}}
|
||||
>
|
||||
{text === 'copy' ? <Copy className="w-3 h-3" /> : <Check className="w-3 h-3" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</FloatingPlate>
|
||||
<TooltipContent>Copy to clipboard</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -46,6 +46,17 @@
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Scale Tailwind's default radius tokens by 4/3 to match the shared Studio curve. */
|
||||
--radius: 0.333333rem;
|
||||
--radius-xs: 0.166667rem;
|
||||
--radius-sm: 0.333333rem;
|
||||
--radius-md: 0.5rem;
|
||||
--radius-lg: 0.666667rem;
|
||||
--radius-xl: 1rem;
|
||||
--radius-2xl: 1.333333rem;
|
||||
--radius-3xl: 2rem;
|
||||
--radius-4xl: 2.666667rem;
|
||||
|
||||
--color-purple-sos-100: hsl(293 80% 97%);
|
||||
--color-purple-sos-200: hsl(293 85% 91%);
|
||||
--color-purple-sos-300: hsl(293 90% 80%);
|
||||
|
||||
@@ -53,7 +53,7 @@ export const AuthenticatedDropdownMenu = ({ user, menu, site }: Props) => {
|
||||
title="Menu dropdown button"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'default' }),
|
||||
'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] data-[state=open]:bg-overlay-hover/30 hover:border-strong data-[state=open]:border-stronger hover:!bg-overlay-hover/50 bg-transparent',
|
||||
'text-foreground-light border-default w-[30px] min-w-[30px] h-[30px] px-0 data-[state=open]:bg-overlay-hover/30 hover:border-strong data-[state=open]:border-stronger hover:!bg-overlay-hover/50 bg-transparent',
|
||||
'rounded-full overflow-hidden opacity-0 transition-opacity animate-fade-in'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -28,7 +28,7 @@ import swift from 'react-syntax-highlighter/dist/cjs/languages/hljs/swift'
|
||||
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript'
|
||||
import xml from 'react-syntax-highlighter/dist/cjs/languages/hljs/xml'
|
||||
import yaml from 'react-syntax-highlighter/dist/cjs/languages/hljs/yaml'
|
||||
import { Button, cn, copyToClipboard } from 'ui'
|
||||
import { Button, cn, copyToClipboard, FloatingPlate } from 'ui'
|
||||
|
||||
import { monokaiCustomTheme } from './CodeBlock.utils'
|
||||
|
||||
@@ -266,12 +266,12 @@ export const CodeBlock = ({
|
||||
{codeValue}
|
||||
</SyntaxHighlighter>
|
||||
{!hideCopy && (value || children) && className ? (
|
||||
<div
|
||||
className={[
|
||||
'absolute right-2 top-2',
|
||||
<FloatingPlate
|
||||
className={cn(
|
||||
'absolute right-2 top-2 bg-surface-100 dark:bg-200',
|
||||
'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition',
|
||||
`${isDarkTheme ? 'dark' : ''}`,
|
||||
].join(' ')}
|
||||
isDarkTheme && 'dark'
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="px-1.5 dark:bg-200! dark:hover:bg-button! hover:bg-alternative!"
|
||||
@@ -281,7 +281,7 @@ export const CodeBlock = ({
|
||||
>
|
||||
{copied ? 'Copied' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingPlate>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Button, cn, FloatingPlate } from 'ui'
|
||||
|
||||
import { useMeasuredWidth } from './Row.utils'
|
||||
|
||||
@@ -146,23 +146,27 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
return (
|
||||
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
|
||||
{showArrows && canScrollLeft && (
|
||||
<Button
|
||||
onClick={scrollLeft}
|
||||
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<FloatingPlate rounded="full" className="absolute left-0 top-1/2 z-10 -translate-y-1/2">
|
||||
<Button
|
||||
onClick={scrollLeft}
|
||||
className="w-8 h-8 rounded-full p-2"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
)}
|
||||
|
||||
{showArrows && canScrollRight && hasContentToScroll && (
|
||||
<Button
|
||||
onClick={scrollRight}
|
||||
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
<FloatingPlate rounded="full" className="absolute right-0 top-1/2 z-10 -translate-y-1/2">
|
||||
<Button
|
||||
onClick={scrollRight}
|
||||
className="w-8 h-8 rounded-full p-2"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</FloatingPlate>
|
||||
)}
|
||||
|
||||
<div
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { useTheme } from 'next-themes'
|
||||
import { Highlight, Language, Prism, themes } from 'prism-react-renderer'
|
||||
import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { Button, cn, copyToClipboard } from 'ui'
|
||||
import { Button, cn, copyToClipboard, FloatingPlate } from 'ui'
|
||||
|
||||
import { dart } from './prism'
|
||||
|
||||
@@ -98,11 +98,11 @@ export const SimpleCodeBlock = ({
|
||||
})}
|
||||
</pre>
|
||||
{showCopy && (
|
||||
<div className="invisible absolute right-0 top-0 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100">
|
||||
<FloatingPlate className="invisible absolute right-0 top-0 bg-surface-100 opacity-0 transition-opacity group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
|
||||
<Button size="tiny" onClick={() => handleCopyCode(children)}>
|
||||
{showCopied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingPlate>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -34,7 +34,8 @@ describe('SkipToContent', () => {
|
||||
render(<SkipToContent href="#main" />)
|
||||
|
||||
const link = screen.getByRole('link', { name: 'Skip to content' })
|
||||
expect(link.className).toContain('hover:bg-popover')
|
||||
expect(link.className).toContain('hover:bg-muted')
|
||||
expect(link.className).toContain('dark:hover:bg-accent')
|
||||
expect(link.className).not.toContain('bg-surface-300')
|
||||
expect(link.className).not.toContain('hover:bg-secondary')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import { Button, cn, FloatingPlate } from 'ui'
|
||||
|
||||
export interface SkipToContentProps {
|
||||
/** Hash href to the main content landmark, e.g. `#main`. */
|
||||
@@ -17,9 +17,8 @@ export interface SkipToContentProps {
|
||||
*/
|
||||
function SkipToContent({ href, children = 'Skip to content', className }: SkipToContentProps) {
|
||||
return (
|
||||
<div
|
||||
<FloatingPlate
|
||||
className={cn(
|
||||
// w-fit: wrapper is a block div by default and would otherwise span the full content column.
|
||||
'fixed top-0 left-[10px] z-[100] w-fit',
|
||||
'-translate-y-full focus-within:translate-y-[10px]',
|
||||
'transition-transform duration-200 ease-out',
|
||||
@@ -29,7 +28,7 @@ function SkipToContent({ href, children = 'Skip to content', className }: SkipTo
|
||||
<Button size="tiny" asChild>
|
||||
<a href={href}>{children}</a>
|
||||
</Button>
|
||||
</div>
|
||||
</FloatingPlate>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
*/
|
||||
--expressive-chroma: 0.14;
|
||||
--warning-lightness: 0.8;
|
||||
--destructive-lightness: 0.75;
|
||||
--destructive-lightness: 0.55;
|
||||
--info-lightness: 0.7;
|
||||
|
||||
/* shadcn semantic pairs */
|
||||
@@ -150,7 +150,7 @@
|
||||
);
|
||||
/* Inverse of --foreground: canvas-coloured text for use on inverted fills
|
||||
(bg-foreground, bg-black / dark:bg-white, etc). Not the same as
|
||||
--primary-foreground, which stays dark for the vivid primary fill. */
|
||||
--primary-foreground, which stays light for the dark primary fill. */
|
||||
--foreground-contrast: oklch(var(--surface) calc(var(--chroma) * 0.55) var(--surface-hue));
|
||||
|
||||
--card: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-1)) c h);
|
||||
@@ -158,9 +158,24 @@
|
||||
--popover: oklch(from var(--background) calc(l + var(--elevation-step) * var(--elevation-2)) c h);
|
||||
--popover-foreground: var(--foreground);
|
||||
|
||||
/* Primary is the accessible functional green (text, controls). Bright
|
||||
expressive fills use brand-default. Light theme overrides --primary for AA text. */
|
||||
/*
|
||||
* Primary is the accessible functional green (text, controls). Bright
|
||||
* expressive fills use brand-default. Light theme overrides --primary for AA text.
|
||||
*
|
||||
* Hover shifts lightness only (preserves hue/chroma). Mixing with --foreground
|
||||
* pulls light-mode surface-hue (warm) into the green and reads as yellow.
|
||||
* Sign of --tone-span: darken in light, lighten in dark.
|
||||
*/
|
||||
--primary: oklch(0.76 0.15 var(--primary-hue));
|
||||
--button-fill-hover-delta: 0.04;
|
||||
--primary-hover: oklch(
|
||||
from var(--primary)
|
||||
calc(
|
||||
l + var(--button-fill-hover-delta) * var(--tone-span) /
|
||||
max(var(--tone-span), calc(var(--tone-span) * -1))
|
||||
)
|
||||
c h
|
||||
);
|
||||
--primary-foreground: oklch(
|
||||
min(var(--surface), var(--foreground-lightness)) calc(var(--chroma) * 0.45) var(--primary-hue)
|
||||
);
|
||||
@@ -217,9 +232,25 @@
|
||||
--control: var(--control-raised);
|
||||
|
||||
--warning: oklch(var(--warning-lightness) var(--expressive-chroma) var(--warning-hue));
|
||||
--warning-hover: oklch(
|
||||
from var(--warning)
|
||||
calc(
|
||||
l + var(--button-fill-hover-delta) * var(--tone-span) /
|
||||
max(var(--tone-span), calc(var(--tone-span) * -1))
|
||||
)
|
||||
c h
|
||||
);
|
||||
--destructive: oklch(
|
||||
var(--destructive-lightness) var(--expressive-chroma) var(--destructive-hue)
|
||||
);
|
||||
--destructive-hover: oklch(
|
||||
from var(--destructive)
|
||||
calc(
|
||||
l + var(--button-fill-hover-delta) * var(--tone-span) /
|
||||
max(var(--tone-span), calc(var(--tone-span) * -1))
|
||||
)
|
||||
c h
|
||||
);
|
||||
/*
|
||||
* Text that sits on the solid fill: dark ink on light fills (amber), white on
|
||||
* dark fills (red). The clamp turns the fill's own lightness into a hard flip
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
* against the near-white surface (a light amber on white disappears). Lightness
|
||||
* is mode-aware; chroma is the shared --expressive-chroma from semantic.css.
|
||||
*/
|
||||
--warning-hue: 58;
|
||||
--warning-lightness: 0.68;
|
||||
--destructive-lightness: 0.52;
|
||||
--info-lightness: 0.54;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// GENERAL
|
||||
|
||||
export * from './src/components/Button'
|
||||
export * from './src/components/FloatingPlate'
|
||||
export * from './src/components/Icon/IconBackground'
|
||||
|
||||
// NAV
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import React from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { Button } from './Button'
|
||||
import { Button, buttonVariants } from './Button'
|
||||
|
||||
const SIZES = ['tiny', 'small', 'medium', 'large', 'xlarge'] as const
|
||||
const TYPES = [
|
||||
@@ -30,16 +30,22 @@ describe('#Button', () => {
|
||||
render(<Button>Neutral</Button>)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Neutral' })
|
||||
expect(button.className).toContain('bg-background')
|
||||
expect(button.className).toContain('hover:bg-popover')
|
||||
expect(button.className).not.toContain('bg-brand-400')
|
||||
expect(button.className).toContain('bg-card')
|
||||
expect(button.className).toContain('hover:bg-muted')
|
||||
expect(button.className).not.toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('should apply tiny size classes when buttonVariants omits size', () => {
|
||||
const className = buttonVariants({ variant: 'default' })
|
||||
expect(className).toContain('h-[26px]')
|
||||
expect(className).toContain('px-2.5')
|
||||
})
|
||||
|
||||
it('should allow an explicit primary variant override', () => {
|
||||
render(<Button variant="primary">Primary</Button>)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Primary' })
|
||||
expect(button.className).toContain('bg-brand-400')
|
||||
expect(button.className).toContain('bg-primary')
|
||||
})
|
||||
|
||||
it('should render different text', () => {
|
||||
|
||||
@@ -5,49 +5,61 @@ import { Loader2 } from 'lucide-react'
|
||||
import { Slot } from 'radix-ui'
|
||||
import { cloneElement, forwardRef, isValidElement, ReactNode } from 'react'
|
||||
|
||||
import { SIZE_VARIANTS, SIZE_VARIANTS_DEFAULT } from '../../lib/constants'
|
||||
import { SIZE_VARIANTS } from '../../lib/constants'
|
||||
import { cn } from '../../lib/utils/cn'
|
||||
import { getExplicitTabIndex } from '../../lib/utils/getExplicitTabIndex'
|
||||
|
||||
export type ButtonVariantProps = VariantProps<typeof buttonVariants>
|
||||
// Normalize the shared border curve at contrast 0.5: (0.05 + 0.95 * 0.5)² = 0.275625.
|
||||
const buttonVariants = cva(
|
||||
`relative
|
||||
inline-flex items-center justify-center
|
||||
cursor-pointer
|
||||
space-x-2
|
||||
text-center
|
||||
font-regular
|
||||
ease-out
|
||||
font-medium
|
||||
ease-[cubic-bezier(0.22,1,0.36,1)]
|
||||
duration-200
|
||||
rounded-md
|
||||
transition-[background-color,border-color,color,scale]
|
||||
[&:not([aria-haspopup])]:motion-safe:active:scale-[0.97]
|
||||
focus-ring
|
||||
border
|
||||
[--button-shadow-opacity:0.04] dark:[--button-shadow-opacity:0.2]
|
||||
[--button-edge-strength:calc(var(--contrast-border,0.275625)/0.275625*0.6)]
|
||||
dark:[--button-edge-strength:calc(var(--contrast-border,0.275625)/0.275625)]
|
||||
[--button-edge-color:var(--colors-black)] dark:[--button-edge-color:var(--colors-white)]
|
||||
[--button-shadow-drop:0_1px_3px_0_hsl(var(--colors-black)/var(--button-shadow-opacity))]
|
||||
[--button-shadow-raised:var(--button-shadow-drop),inset_0_1px_0_0_hsl(var(--button-edge-color)/calc(0.04*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.06*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.1*var(--button-edge-strength)))]
|
||||
[--button-shadow-default:var(--button-shadow-drop),inset_0_1px_0_0_hsl(var(--button-edge-color)/calc(0.04*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--colors-black)/calc(0.06*var(--button-edge-strength))),inset_0_-1px_0_0_hsl(var(--colors-black)/calc(0.06*var(--button-edge-strength))),inset_0_0_0_1px_hsl(var(--button-edge-color)/calc(0.1*var(--button-edge-strength)))]
|
||||
`,
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary: `
|
||||
bg-brand-400 dark:bg-brand-500
|
||||
hover:bg-brand-default/80 dark:hover:bg-brand-default/50
|
||||
text-foreground
|
||||
border-brand-500/75 dark:border-brand-default/30
|
||||
hover:border-brand-600 dark:hover:border-brand-default
|
||||
data-[state=open]:bg-brand-400/80 dark:data-[state=open]:bg-brand-500/80
|
||||
border-0
|
||||
bg-primary
|
||||
bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))]
|
||||
text-primary-foreground
|
||||
shadow-[var(--button-shadow-raised)]
|
||||
hover:bg-[var(--primary-hover)]
|
||||
data-[state=open]:bg-[var(--primary-hover)]
|
||||
`,
|
||||
default: `
|
||||
text-foreground
|
||||
bg-background dark:bg-card hover:bg-popover
|
||||
border-strong hover:border-control-hover
|
||||
data-[state=open]:bg-popover
|
||||
data-[state=open]:border-control-hover
|
||||
border-0
|
||||
bg-card hover:bg-muted dark:bg-muted dark:hover:bg-accent
|
||||
dark:bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))]
|
||||
shadow-[var(--button-shadow-default)]
|
||||
data-[state=open]:bg-muted dark:data-[state=open]:bg-accent
|
||||
`,
|
||||
secondary: `
|
||||
bg-foreground
|
||||
text-background hover:text-background/80
|
||||
border-foreground-light hover:border-foreground-lighter
|
||||
data-[state=open]:border-foreground-lighter
|
||||
text-background
|
||||
border-0
|
||||
shadow-[var(--button-shadow-drop)]
|
||||
hover:bg-foreground/90
|
||||
data-[state=open]:bg-foreground/90
|
||||
`,
|
||||
outline: `
|
||||
text-foreground
|
||||
@@ -79,27 +91,35 @@ const buttonVariants = cva(
|
||||
border-transparent
|
||||
`,
|
||||
danger: `
|
||||
text-foreground
|
||||
bg-destructive-300 dark:bg-destructive-400 hover:bg-destructive-400 dark:hover:bg-destructive/50
|
||||
border-border-destructive hover:border-destructive
|
||||
hover:text-hi-contrast
|
||||
data-[state=open]:border-destructive
|
||||
data-[state=open]:bg-destructive-400 dark:data-[state=open]:bg-destructive/50
|
||||
border-0
|
||||
bg-destructive
|
||||
bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))]
|
||||
text-destructive-foreground
|
||||
shadow-[var(--button-shadow-raised)]
|
||||
hover:bg-[var(--destructive-hover)]
|
||||
data-[state=open]:bg-[var(--destructive-hover)]
|
||||
`,
|
||||
warning: `
|
||||
text-foreground
|
||||
bg-warning-300 dark:bg-warning-400 hover:bg-warning-400 dark:hover:bg-warning/50
|
||||
border-border-warning hover:border-warning
|
||||
hover:text-hi-contrast
|
||||
data-[state=open]:border-warning
|
||||
data-[state=open]:bg-warning-400 dark:data-[state=open]:bg-warning/50
|
||||
border-0
|
||||
bg-warning
|
||||
bg-[linear-gradient(to_bottom,hsl(var(--colors-white)/0.015),hsl(var(--colors-black)/0.01))]
|
||||
text-warning-foreground
|
||||
shadow-[var(--button-shadow-raised)]
|
||||
hover:bg-[var(--warning-hover)]
|
||||
data-[state=open]:bg-[var(--warning-hover)]
|
||||
`,
|
||||
},
|
||||
block: {
|
||||
true: 'w-full flex items-center justify-center',
|
||||
},
|
||||
size: {
|
||||
...SIZE_VARIANTS,
|
||||
// Larger sizes soften the curve; cn() merges these over base rounded-md.
|
||||
// Radius stays on Button (not SIZE_VARIANTS) because that map is shared with Input/Select.
|
||||
tiny: `${SIZE_VARIANTS.tiny} rounded-md`,
|
||||
small: `${SIZE_VARIANTS.small} rounded-[calc(var(--radius-md)*(1+(34/26-1)*0.35))]`,
|
||||
medium: `${SIZE_VARIANTS.medium} rounded-[calc(var(--radius-md)*(1+(38/26-1)*0.35))]`,
|
||||
large: `${SIZE_VARIANTS.large} rounded-[calc(var(--radius-md)*(1+(42/26-1)*0.35))]`,
|
||||
xlarge: `${SIZE_VARIANTS.xlarge} rounded-[calc(var(--radius-md)*(1+(50/26-1)*0.35))]`,
|
||||
},
|
||||
overlay: {
|
||||
base: `absolute inset-0 bg-background opacity-50`,
|
||||
@@ -114,13 +134,11 @@ const buttonVariants = cva(
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
},
|
||||
defaultVariants: {
|
||||
// variant: 'default',
|
||||
// size: 'default',
|
||||
size: {
|
||||
SIZE_VARIANTS_DEFAULT,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Match <Button size="tiny"> so raw buttonVariants({ variant }) keeps sizing.
|
||||
// Fixed icon shells that omit size must override padding (e.g. px-0 with h/w-[30px]).
|
||||
defaultVariants: {
|
||||
size: 'tiny',
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -137,7 +155,7 @@ const IconContainerVariants = cva('inline-flex items-center justify-center shrin
|
||||
xxxlarge: '[&_svg]:h-[42px] [&_svg]:w-[42px]',
|
||||
},
|
||||
variant: {
|
||||
primary: 'text-brand-600',
|
||||
primary: 'text-primary-foreground/50',
|
||||
default: 'text-foreground-lighter',
|
||||
secondary: 'text-background',
|
||||
alternative: 'text-foreground-lighter',
|
||||
@@ -145,8 +163,8 @@ const IconContainerVariants = cva('inline-flex items-center justify-center shrin
|
||||
dashed: 'text-foreground-lighter',
|
||||
link: 'text-brand-600',
|
||||
text: 'text-foreground-lighter',
|
||||
danger: 'text-destructive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-destructive-foreground/50',
|
||||
warning: 'text-warning-foreground/50',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -155,7 +173,7 @@ export type LoadingVariantProps = VariantProps<typeof loadingVariants>
|
||||
const loadingVariants = cva('', {
|
||||
variants: {
|
||||
variant: {
|
||||
primary: 'text-brand-600',
|
||||
primary: 'text-primary-foreground/50',
|
||||
default: 'text-foreground-lighter',
|
||||
secondary: 'text-background',
|
||||
alternative: 'text-foreground-lighter',
|
||||
@@ -163,8 +181,8 @@ const loadingVariants = cva('', {
|
||||
dashed: 'text-foreground-lighter',
|
||||
link: 'text-brand-600',
|
||||
text: 'text-foreground-muted',
|
||||
danger: 'text-destructive',
|
||||
warning: 'text-warning',
|
||||
danger: 'text-destructive-foreground/50',
|
||||
warning: 'text-warning-foreground/50',
|
||||
},
|
||||
loading: {
|
||||
default: '',
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { FloatingPlate } from './FloatingPlate'
|
||||
|
||||
describe('FloatingPlate', () => {
|
||||
it('renders children inside an opaque plate', () => {
|
||||
render(
|
||||
<FloatingPlate>
|
||||
<button type="button">Copy</button>
|
||||
</FloatingPlate>
|
||||
)
|
||||
|
||||
const plate = screen.getByRole('button', { name: 'Copy' }).parentElement
|
||||
expect(plate?.className).toContain('inline-flex')
|
||||
expect(plate?.className).toContain('bg-popover')
|
||||
expect(plate?.className).toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('supports pill radius for rounded-full controls', () => {
|
||||
const { container } = render(
|
||||
<FloatingPlate rounded="full">
|
||||
<button type="button">Clear</button>
|
||||
</FloatingPlate>
|
||||
)
|
||||
|
||||
expect(container.firstElementChild?.className).toContain('rounded-full')
|
||||
expect(container.firstElementChild?.className).not.toContain('rounded-lg')
|
||||
})
|
||||
|
||||
it('merges layout and reveal classes from the callsite', () => {
|
||||
const { container } = render(
|
||||
<FloatingPlate className="absolute right-2 top-2 opacity-0 group-hover:opacity-100">
|
||||
<button type="button">Copy</button>
|
||||
</FloatingPlate>
|
||||
)
|
||||
|
||||
const plate = container.firstElementChild as HTMLElement
|
||||
expect(plate.className).toContain('absolute')
|
||||
expect(plate.className).toContain('right-2')
|
||||
expect(plate.className).toContain('opacity-0')
|
||||
expect(plate.className).toContain('group-hover:opacity-100')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type HTMLAttributes, type ReactNode } from 'react'
|
||||
|
||||
import { cn } from '../../lib/utils/cn'
|
||||
|
||||
const ROUNDED = {
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
full: 'rounded-full',
|
||||
} as const
|
||||
|
||||
export type FloatingPlateRounded = keyof typeof ROUNDED
|
||||
|
||||
export type FloatingPlateProps = HTMLAttributes<HTMLDivElement> & {
|
||||
children: ReactNode
|
||||
/**
|
||||
* Plate corner radius. Match or exceed the child control radius
|
||||
* (`full` for pill buttons, `lg` for most default buttons).
|
||||
* @default 'lg'
|
||||
*/
|
||||
rounded?: FloatingPlateRounded
|
||||
}
|
||||
|
||||
/**
|
||||
* Opaque plate behind default Buttons that float over busy content.
|
||||
*
|
||||
* Default buttons use a translucent fill in dark themes, so code, tables, and
|
||||
* gradients show through. Wrap those controls (or clusters) in FloatingPlate
|
||||
* so the plate occludes; keep positioning, z-index, and reveal opacity on
|
||||
* `className`.
|
||||
*/
|
||||
export function FloatingPlate({
|
||||
children,
|
||||
className,
|
||||
rounded = 'lg',
|
||||
...props
|
||||
}: FloatingPlateProps) {
|
||||
return (
|
||||
<div className={cn('inline-flex bg-popover', ROUNDED[rounded], className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './FloatingPlate'
|
||||
@@ -57,7 +57,7 @@ export const KeyboardShortcut = ({ keys, variant = 'pill', className }: Keyboard
|
||||
'inline-flex whitespace-nowrap shrink-0',
|
||||
variant === 'pill'
|
||||
? 'items-center text-[11px] leading-none tracking-[-0.025em] text-foreground-light bg-surface-200/50 dark:bg-surface-100/50 rounded px-[5px] py-[3px] border border-border-muted'
|
||||
: 'items-baseline text-[11px] leading-[inherit] text-foreground/40',
|
||||
: 'items-baseline text-[11px] leading-[inherit]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -12,7 +12,7 @@ const buttonVariants = cva(
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-[var(--primary-hover)]',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-control bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
|
||||
Reference in new issue
Block a user