mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
chore(design-system): standardise keyboard focus rings (#41575)
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? UI / design-system consistency (accessibility). ## What is the current behavior? Keyboard focus rings are inconsistent across Studio and `packages/ui`: - Custom Button uses thick `outline` with per-variant colours (brand / grey / destructive / warning) - Form controls use muted grey rings (`ring-background-control`) - Tabs / NavMenu / Radio use soft brand `ring-ring` - Studio `.inset-focus` uses dark green `outline-brand-600` Related: [DEPR-354](https://linear.app/supabase/issue/DEPR-354). ## What is the new behavior? One shared focus recipe, exposed as Tailwind `@utility` classes in `packages/config/css/utilities.css`: | Utility | Use when | | --- | --- | | `focus-ring` | Buttons, inputs, most controls (offset ring) | | `focus-inset` | Dense/flush surfaces such as interactive table rows (renamed from `inset-focus`) | ```txt # focus-ring outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ``` Applied on Button, shadcn form controls, Menu/NavMenu, Command palette trigger, Studio table rows, and related call sites. Documented in the design-system accessibility docs. Variants do not change focus ring colour. When the ring must appear on a different element than the focused one (e.g. Menu + ProductMenu `Link` via `group-focus-visible`, or InputGroup via `:has()`), keep an explicit ring stack. The utilities bake in `:focus-visible` on the same element. ## Additional context **Out of scope** - Full `packages/ui` / Studio / www sweep - Legacy Studio form-group green box-shadow cleanup - ESLint rule for bare `outline-none` ## Test plan Prefer Safari (“hard mode” for `tabIndex`). Expect one soft brand ring everywhere: not grey, not solid green outline. ### Design system - [ ] [Accessibility](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/accessibility): recipe docs match what you see - [ ] [Button](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/button): Tab primary / default / danger; same ring colour - [ ] [Table → Row-level navigation](https://design-system-git-dnywh-choreimprove-tab-focus-styles-supabase.vercel.app/design-system/docs/components/table#row-level-navigation): Tab an interactive row; inset outline (`focus-inset`) sits inside the row ### Studio - [ ] **Org home → table view** (`/organizations/_` or org projects): switch to the table layout, Tab onto a project row; inset outline sits inside the row (list/card view uses CardButton, not `focus-inset`) - [ ] **Project sidebar** (Database, Auth, Storage, …): Tab the main product nav links; ring follows the focused item (not the nested section menus like Tables / Roles) - [ ] **Storage → Files**: Tab a bucket row; same inset outline as org table rows - [ ] **Project Settings → General** (or Compute and Disk): Tab through inputs, checkboxes, switches, selects; same offset ring, no ring on mouse click - [ ] **Header ⌘K** (desktop width): Tab to the search control after Feedback; same soft brand `focus-ring` (was a thicker `ring-border-strong` before) - [ ] **Table Editor or SQL Editor tabs**: focus a tab, Tab to × if active; close shows a ring - [ ] **Light + dark**: ring stays visible against both backgrounds
This commit is contained in:
1 parent
71410b187a
commit
c8aca8d3a0
45 files changed
+136
-101
No files matched your search
@@ -25,14 +25,68 @@ All interactive page elements should be reachable by keyboard. Given the below i
|
||||
|
||||
Chromium-based browsers and Firefox handle this automatically via the Tab key. Safari, by default, requires the Option key to also be held down. Enabling _Keyboard navigation_ on macOS Settings [removes this requirement](https://mayank.co/blog/safari-focus/#keyboard-navigation) but makes links non-tabbable as a result.
|
||||
|
||||
Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use consistent focus styles such as `inset-focus` so users recognize this state instantly.
|
||||
Interactive page elements should also provide visual feedback upon selection via a `focus-visible` state. We use one shared focus ring so users recognize this state instantly.
|
||||
|
||||
[Button](components/button) has all of the above built-in. The same explicit `tabIndex` default is also baked into Checkbox, Switch, Select Trigger, Toggle, Accordion Trigger, Collapsible Trigger, Dropdown Menu Trigger, Popover Trigger, Dialog Trigger, Sheet Trigger, Alert Dialog Trigger, and the Sidebar Menu and action buttons. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually:
|
||||
### Focus ring recipe
|
||||
|
||||
Prefer the shared utilities over inventing local styles:
|
||||
|
||||
| Utility | Use when |
|
||||
| ------------- | -------------------------------------------------------------------------- |
|
||||
| `focus-ring` | Buttons, inputs, and most controls (offset **ring**) |
|
||||
| `focus-inset` | Dense or flush surfaces such as interactive table rows (inset **outline**) |
|
||||
|
||||
```tsx
|
||||
className = 'focus-ring'
|
||||
// or
|
||||
className = 'relative cursor-pointer focus-inset'
|
||||
```
|
||||
|
||||
These expand to:
|
||||
|
||||
**`focus-ring`**
|
||||
|
||||
```txt
|
||||
outline-hidden
|
||||
focus-visible:ring-2
|
||||
focus-visible:ring-ring
|
||||
focus-visible:ring-offset-2
|
||||
focus-visible:ring-offset-background
|
||||
```
|
||||
|
||||
**`focus-inset`**
|
||||
|
||||
Uses `outline` (not `ring`) so it paints reliably on interactive `<tr>`s. Tailwind `ring` is `box-shadow`, which browsers often skip on `display: table-row` (notably Safari). Do not put `focus-ring` or raw `ring-*` on a `<tr>`, and do not add `outline-hidden` alongside `focus-inset`. `outline-hidden` sets `outline-style: none` and will hide the indicator.
|
||||
|
||||
```txt
|
||||
&:focus-visible {
|
||||
outline-style: solid
|
||||
outline-width: 2px
|
||||
outline-offset: -2px
|
||||
outline-color: var(--ring)
|
||||
border-radius: var(--radius-md)
|
||||
}
|
||||
```
|
||||
|
||||
`outline-hidden` is always on (not `focus-visible:`-prefixed) so mouse click does not show the browser’s default outline; the focus indicator replaces it for keyboard focus only.
|
||||
|
||||
Rules:
|
||||
|
||||
- Prefer `:focus-visible` over `:focus` so click/tap does not show a focus indicator
|
||||
- Never use `outline-none` / `outline-hidden` without a ring or outline replacement
|
||||
- Always use the shared color (`ring-ring` / `outline-ring`). Variants (primary, danger, warning) do not change focus colour
|
||||
- Do not animate the focus indicator; avoid `transition-all` / `transition` on controls that show one (prefer `transition-colors`)
|
||||
- Prefer `focus-ring` / `focus-inset` over copy-pasting the class stack
|
||||
- On interactive `<tr>`s, use `focus-inset` only. `focus-ring` will look fine in some browsers and invisible in others
|
||||
|
||||
When the focused element is not the thing that should show the ring (e.g. a wrapping `Link` with `group`, or an `InputGroup` parent using `:has()`), keep the explicit `group-focus-visible:ring-*` / `has-[…]:focus-visible:ring-*` stack. The utilities bake in `:focus-visible` on the same element and do not compose as `group-focus-visible:focus-ring`.
|
||||
|
||||
[Button](components/button) has focus, `tabIndex`, and the shared ring built-in. The same explicit `tabIndex` default is also baked into Checkbox, Switch, Select Trigger, Toggle, Accordion Trigger, Collapsible Trigger, Dropdown Menu Trigger, Popover Trigger, Dialog Trigger, Sheet Trigger, Alert Dialog Trigger, and the Sidebar Menu and action buttons. Bespoke interactive elements however, such as the below interactive [Table Row](components/table#examples), require these props to be added manually:
|
||||
|
||||
```tsx showLineNumbers {4-14}
|
||||
<TableRow
|
||||
key={id}
|
||||
className="relative cursor-pointer h-16 inset-focus"
|
||||
className="relative cursor-pointer h-16 focus-inset"
|
||||
onClick={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
handleBucketNavigation(bucket.id, event)
|
||||
|
||||
@@ -144,5 +144,6 @@ Inside [Admonition](../fragments/admonition#split-button-with-dropdown) actions,
|
||||
- Enabled buttons default to `tabIndex={0}` (keyboard accessible)
|
||||
- Disabled buttons default to `tabIndex={-1}` (removed from tab order)
|
||||
- You can still override with an explicit `tabIndex` prop when needed
|
||||
- Keyboard focus uses the shared `focus-ring` utility; variants do not change ring colour
|
||||
|
||||
You therefore don't need to manually set `tabIndex`, as Button handles it automatically based on its `disabled` state.
|
||||
@@ -221,7 +221,7 @@ Avoid adding other actions when using row-level navigation, as multiple interact
|
||||
When implementing row-level navigation, pay close attention to [Accessibility](/accessibility#focus-management) requirements. The row must be keyboard accessible with proper focus management. Also consider these affordances:
|
||||
|
||||
- Handle `Enter` and `Space` key presses for activation
|
||||
- Provide visual focus indicators using classes like `inset-focus`
|
||||
- Provide visual focus indicators using classes like `focus-inset`
|
||||
- Support modifier keys (`Ctrl`/`Cmd`, middle-click) for opening links in new tabs
|
||||
- Consider using the shared `createNavigationHandler` function to handle modifier keys
|
||||
- Avoid bubbling up action events from _within_ the row
|
||||
|
||||
@@ -70,7 +70,7 @@ export default function TableRowLinkActions() {
|
||||
{policies.map((policy) => (
|
||||
<TableRow
|
||||
key={policy.id}
|
||||
className="relative cursor-pointer inset-focus"
|
||||
className="relative cursor-pointer focus-inset"
|
||||
onClick={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
handlePolicyNavigation(policy.id, event)
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function TableRowLink() {
|
||||
{buckets.map((bucket) => (
|
||||
<TableRow
|
||||
key={bucket.id}
|
||||
className="relative cursor-pointer inset-focus"
|
||||
className="relative cursor-pointer focus-inset"
|
||||
onClick={(event) => {
|
||||
if (event.currentTarget !== event.target) return
|
||||
handleBucketNavigation(bucket.id, event)
|
||||
|
||||
@@ -62,7 +62,7 @@ export const EdgeFunctionsListItem = ({ function: item }: EdgeFunctionsListItemP
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
tabIndex={0}
|
||||
className="cursor-pointer inset-focus"
|
||||
className="cursor-pointer focus-inset"
|
||||
>
|
||||
<TableCell>
|
||||
<p className="text-sm text-foreground whitespace-nowrap py-2">{item.name}</p>
|
||||
|
||||
@@ -75,7 +75,7 @@ export const ProjectTableRow = ({
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className="cursor-pointer hover:bg-surface-200 inset-focus"
|
||||
className="cursor-pointer hover:bg-surface-200 focus-inset"
|
||||
onClick={handleNavigation}
|
||||
onAuxClick={handleNavigation}
|
||||
onKeyDown={handleNavigation}
|
||||
|
||||
+1
-1
@@ -321,7 +321,7 @@ export const PlatformWebhooksEndpointDetails = ({
|
||||
paginatedDeliveries.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className="cursor-pointer inset-focus"
|
||||
className="cursor-pointer focus-inset"
|
||||
onClick={() => onOpenDelivery(row.original.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
|
||||
+1
-1
@@ -170,7 +170,7 @@ export const PlatformWebhooksEndpointList = ({
|
||||
return (
|
||||
<TableRow
|
||||
key={endpoint.id}
|
||||
className="relative cursor-pointer inset-focus"
|
||||
className="relative cursor-pointer focus-inset"
|
||||
onClick={createNavigationHandler(
|
||||
`${webhooksHref}/${encodeURIComponent(endpoint.id)}`,
|
||||
router
|
||||
|
||||
+1
-1
@@ -127,7 +127,7 @@ export function JitDbAccessRulesTable({
|
||||
return (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={rowIsInteractive ? 'relative inset-focus cursor-pointer' : undefined}
|
||||
className={rowIsInteractive ? 'relative focus-inset cursor-pointer' : undefined}
|
||||
onClick={
|
||||
rowIsInteractive
|
||||
? (event) => {
|
||||
|
||||
@@ -154,7 +154,7 @@ export const AnalyticsBuckets = () => {
|
||||
return (
|
||||
<TableRow
|
||||
key={bucket.name}
|
||||
className="relative cursor-pointer h-16 inset-focus"
|
||||
className="relative cursor-pointer h-16 focus-inset"
|
||||
onClick={handleBucketNavigation}
|
||||
onAuxClick={handleBucketNavigation}
|
||||
onKeyDown={handleBucketNavigation}
|
||||
|
||||
@@ -80,7 +80,7 @@ export const BucketTableRow = ({
|
||||
key={bucket.id}
|
||||
data-bucket-id={bucket.id}
|
||||
className={cn(
|
||||
'relative cursor-pointer h-16 group inset-focus',
|
||||
'relative cursor-pointer h-16 group focus-inset',
|
||||
isDisabled && 'opacity-50 [&>td]:hover:bg-transparent cursor-not-allowed'
|
||||
)}
|
||||
onClick={handleRowActivate}
|
||||
|
||||
@@ -107,7 +107,7 @@ export const BucketTableRow = ({
|
||||
<BucketTableRow
|
||||
key={bucket.id}
|
||||
data-bucket-id={bucket.id}
|
||||
className="relative cursor-pointer h-16 group inset-focus"
|
||||
className="relative cursor-pointer h-16 group focus-inset"
|
||||
onClick={handleBucketNavigation}
|
||||
onAuxClick={handleBucketNavigation}
|
||||
onKeyDown={handleBucketNavigation}
|
||||
|
||||
@@ -130,7 +130,7 @@ export const VectorsBuckets = () => {
|
||||
return (
|
||||
<TableRow
|
||||
key={id}
|
||||
className="relative cursor-pointer h-16 inset-focus"
|
||||
className="relative cursor-pointer h-16 focus-inset"
|
||||
onClick={handleBucketNavigation}
|
||||
onAuxClick={handleBucketNavigation}
|
||||
onKeyDown={handleBucketNavigation}
|
||||
|
||||
@@ -195,7 +195,7 @@ export const ProjectNeedsSecuringView = ({
|
||||
return (
|
||||
<TableRow
|
||||
key={getTableKey(table)}
|
||||
className="relative cursor-pointer inset-focus"
|
||||
className="relative cursor-pointer focus-inset"
|
||||
onClick={(event) => {
|
||||
trackViewPolicies()
|
||||
handleNavigation(event)
|
||||
|
||||
@@ -158,7 +158,7 @@ export const SortableTab = ({
|
||||
'absolute top-1/2 right-2.5 z-10 -translate-y-1/2',
|
||||
'flex size-5 items-center justify-center rounded-xs',
|
||||
'opacity-0 group-hover/tab:opacity-100 group-focus-within/tab:opacity-100 focus-visible:opacity-100',
|
||||
'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'hover:bg-200 focus-ring',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -259,7 +259,7 @@ export const EditorTabs = () => {
|
||||
className={cn(
|
||||
'absolute top-1/2 right-3 z-10 flex -translate-y-1/2 items-center justify-center rounded-xs',
|
||||
'opacity-0 group-hover/new-tab:opacity-100 group-focus-within/new-tab:opacity-100 focus-visible:opacity-100',
|
||||
'hover:bg-200 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'hover:bg-200 focus-ring',
|
||||
'cursor-pointer'
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -153,7 +153,7 @@ export function DataTableFilterCheckbox<TData>({
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:text-foreground',
|
||||
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
|
||||
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
'focus-ring'
|
||||
)}
|
||||
>
|
||||
<span className="pl-1 pr-2">Only</span>
|
||||
|
||||
+1
-1
@@ -123,7 +123,7 @@ export function DataTableFilterCheckboxAsync<TData>({
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground hover:text-foreground',
|
||||
'absolute inset-y-0 right-0 hidden bg-surface-100 group-hover:flex group-focus-within:flex items-center cursor-pointer',
|
||||
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2'
|
||||
'focus-ring'
|
||||
)}
|
||||
>
|
||||
<span className="px-2">Only</span>
|
||||
|
||||
@@ -187,12 +187,7 @@ export function DataTableSheetRowAction<TData, TFields extends DataTableFilterFi
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
'rounded-md ring-offset-background',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'relative py-0',
|
||||
className
|
||||
)}
|
||||
className={cn('rounded-md', 'focus-ring', 'relative py-0', className)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
// REMINDER: default behavior is to open the dropdown menu
|
||||
|
||||
@@ -22,7 +22,7 @@ export const Slider = forwardRef<
|
||||
</SliderPrimitive.Track>
|
||||
{initialValue.map((_, index) => (
|
||||
<Fragment key={index}>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-2 border-primary bg-background transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Fragment>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -85,7 +85,7 @@ export const ProductMenuItem = ({
|
||||
}
|
||||
|
||||
const link = (
|
||||
<Link href={url} className="block" target={target} onClick={onClick}>
|
||||
<Link href={url} className="group block" target={target} onClick={onClick}>
|
||||
{menuItem}
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import '@/styles/code.css'
|
||||
import '@/styles/focus.css'
|
||||
import '@/styles/globals.css'
|
||||
import '@/styles/graphiql-base.css'
|
||||
import '@/styles/grid.css'
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import 'react-data-grid/lib/styles.css'
|
||||
import '@/styles/code.css'
|
||||
import '@/styles/focus.css'
|
||||
// Vite-only: defines @font-face for the custom fonts. The Next pipeline
|
||||
// (pages/_app.tsx) loads these via next/font instead, so this import has no
|
||||
// counterpart there — but dropping it under Vite makes the browser fall back
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
@reference "./globals.css";
|
||||
|
||||
/* Focus styles for keyboard navigation on interactive table rows */
|
||||
.inset-focus {
|
||||
@apply ease-out duration-100 focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-brand-600 focus-visible:rounded-md transition-all;
|
||||
}
|
||||
@@ -180,3 +180,29 @@
|
||||
-webkit-text-fill-color: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Canonical keyboard focus rings. Prefer these over inventing local ring/outline
|
||||
* stacks.
|
||||
*
|
||||
* - focus-ring: default offset ring (buttons, inputs, most controls).
|
||||
* `outline-hidden` stays always-on so mouse click does not show the browser
|
||||
* default; the ring is `:focus-visible` only.
|
||||
* - focus-inset: dense/flush surfaces such as interactive <tr>s. Must use
|
||||
* outline (not ring/box-shadow) — box-shadow on table-row is unreliable,
|
||||
* especially in Safari. Do NOT pair with outline-hidden: that sets
|
||||
* outline-style: none and suppresses the focus outline.
|
||||
*/
|
||||
@utility focus-ring {
|
||||
@apply outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background;
|
||||
}
|
||||
|
||||
@utility focus-inset {
|
||||
&:focus-visible {
|
||||
outline-style: solid;
|
||||
outline-width: 2px;
|
||||
outline-offset: -2px;
|
||||
outline-color: var(--ring);
|
||||
border-radius: var(--radius-md, 0.375rem);
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function CommandMenuTrigger({ children }: PropsWithChildren) {
|
||||
'rounded-md border border-input bg-background',
|
||||
'text-sm',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
'ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'focus-ring',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'transition-colors',
|
||||
childFromProps.props.className
|
||||
@@ -182,6 +182,7 @@ function CommandMenuTriggerInput({
|
||||
<CommandMenuTrigger>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'group',
|
||||
'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md',
|
||||
@@ -189,8 +190,8 @@ function CommandMenuTriggerInput({
|
||||
'flex items-center justify-between',
|
||||
'bg-surface-100/75 text-foreground-lighter border',
|
||||
'hover:bg-surface-100/100 hover:border-stronger',
|
||||
'focus-visible:outline-hidden focus-visible:ring-4 focus-visible:ring-border-strong focus-visible:ring-offset-1 focus-visible:ring-offset-background',
|
||||
'transition',
|
||||
'focus-ring',
|
||||
'transition-colors',
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -585,7 +585,7 @@ const MultiSelectorItem = React.forwardRef<
|
||||
'peer h-4 w-4 shrink-0 rounded-sm border border-control bg-control/25 ring-offset-background',
|
||||
'transition-colors duration-150 ease-in-out',
|
||||
'hover:border-strong',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'focus-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=checked]:text-background',
|
||||
isSelected ? 'bg-foreground text-background' : '[&_svg]:invisible'
|
||||
)}
|
||||
|
||||
@@ -20,12 +20,8 @@ const buttonVariants = cva(
|
||||
ease-out
|
||||
duration-200
|
||||
rounded-md
|
||||
outline-hidden
|
||||
transition-all
|
||||
outline-0
|
||||
focus-visible:outline-solid
|
||||
focus-visible:outline-4
|
||||
focus-visible:outline-offset-1
|
||||
transition-colors
|
||||
focus-ring
|
||||
border
|
||||
`,
|
||||
{
|
||||
@@ -37,35 +33,26 @@ const buttonVariants = cva(
|
||||
text-foreground
|
||||
border-brand-500/75 dark:border-brand/30
|
||||
hover:border-brand-600 dark:hover:border-brand
|
||||
focus-visible:outline-brand-600
|
||||
data-[state=open]:bg-brand-400/80 dark:data-[state=open]:bg-brand-500/80
|
||||
data-[state=open]:outline-brand-600
|
||||
`,
|
||||
default: `
|
||||
text-foreground
|
||||
bg-alternative dark:bg-muted hover:bg-selection
|
||||
border-strong hover:border-stronger
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:bg-selection
|
||||
data-[state=open]:outline-border-strong
|
||||
data-[state=open]:border-button-hover
|
||||
`,
|
||||
secondary: `
|
||||
bg-foreground
|
||||
text-background hover:text-background/80
|
||||
focus-visible:text-border-control
|
||||
border-foreground-light hover:border-foreground-lighter
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:border-foreground-lighter
|
||||
data-[state=open]:outline-border-strong
|
||||
`,
|
||||
outline: `
|
||||
text-foreground
|
||||
bg-transparent
|
||||
border-strong hover:border-foreground-muted
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:border-stronger
|
||||
data-[state=open]:outline-border-strong
|
||||
`,
|
||||
dashed: `
|
||||
text-foreground
|
||||
@@ -73,9 +60,7 @@ const buttonVariants = cva(
|
||||
border-dashed
|
||||
border-strong hover:border-stronger
|
||||
bg-transparent
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:border-stronger
|
||||
data-[state=open]:outline-border-strong
|
||||
`,
|
||||
link: `
|
||||
text-brand-600
|
||||
@@ -83,17 +68,13 @@ const buttonVariants = cva(
|
||||
border-transparent/0
|
||||
hover:bg-brand-400
|
||||
shadow-none
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:bg-brand-400
|
||||
data-[state=open]:outline-border-strong
|
||||
`,
|
||||
text: `
|
||||
text-foreground
|
||||
hover:bg-accent
|
||||
shadow-none
|
||||
focus-visible:outline-border-strong
|
||||
data-[state=open]:bg-accent
|
||||
data-[state=open]:outline-border-strong
|
||||
border-transparent
|
||||
`,
|
||||
danger: `
|
||||
@@ -101,20 +82,16 @@ const buttonVariants = cva(
|
||||
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
|
||||
focus-visible:outline-destructive
|
||||
data-[state=open]:border-destructive
|
||||
data-[state=open]:bg-destructive-400 dark:data-[state=open]:bg-destructive/50
|
||||
data-[state=open]:outline-destructive
|
||||
`,
|
||||
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
|
||||
focus-visible:outline-warning
|
||||
data-[state=open]:border-warning
|
||||
data-[state=open]:bg-warning-400 dark:data-[state=open]:bg-warning/50
|
||||
data-[state=open]:outline-warning
|
||||
`,
|
||||
},
|
||||
block: {
|
||||
|
||||
@@ -45,7 +45,13 @@ interface ItemProps {
|
||||
|
||||
export const menuItemVariants = cva(
|
||||
cn(
|
||||
'cursor-pointer flex space-x-3 items-center outline-hidden focus-visible:ring-1 ring-foreground-muted focus-visible:z-10 group'
|
||||
// focus-ring: when the item itself is focused.
|
||||
// group-focus-visible: when a wrapping Link/button (e.g. ProductMenu) is the
|
||||
// focus target — cannot use focus-ring here (utility targets :focus-visible
|
||||
// on the same element).
|
||||
'cursor-pointer flex space-x-3 items-center focus-visible:z-10 group',
|
||||
'focus-ring',
|
||||
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background'
|
||||
),
|
||||
{
|
||||
variants: {
|
||||
|
||||
@@ -33,7 +33,7 @@ export const NavMenuItem = forwardRef<HTMLLIElement, NavMenuItemProps>(
|
||||
aria-selected={active ? 'true' : 'false'}
|
||||
data-state={active ? 'active' : 'inactive'}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent *:py-1.5',
|
||||
'inline-flex items-center justify-center whitespace-nowrap text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent *:py-1.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -58,10 +58,10 @@ const RadioGroupCardItem = React.forwardRef<
|
||||
group-hover:border-foreground-muted
|
||||
ring-offset-background
|
||||
group-focus:outline-hidden
|
||||
group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2
|
||||
group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background
|
||||
group-disabled:cursor-not-allowed group-disabled:opacity-50
|
||||
flex items-center justify-center
|
||||
transition
|
||||
transition-colors
|
||||
"
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { cn } from '../../../lib/utils/cn'
|
||||
import { getExplicitTabIndex } from '../../../lib/utils/getExplicitTabIndex'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-foreground-muted focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -20,7 +20,7 @@ const Checkbox = React.forwardRef<
|
||||
'peer flex items-center justify-center h-4 w-4 shrink-0 rounded-sm border border-control bg-control/25 ring-offset-background',
|
||||
'transition-colors duration-150 ease-in-out',
|
||||
'hover:border-strong',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-background-control focus-visible:ring-offset-2 focus-visible:ring-offset-foreground-muted',
|
||||
'focus-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-foreground data-[state=checked]:text-background',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -55,8 +55,9 @@ function InputGroup({
|
||||
'has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3',
|
||||
'has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3',
|
||||
|
||||
// Focus state.
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:outline-hidden has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-background-control has-[[data-slot=input-group-control]:focus-visible]:ring-offset-2 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-foreground-muted',
|
||||
// Focus state. Parent listens via :has() — cannot use the focus-ring utility
|
||||
// (that utility targets :focus-visible on the element itself).
|
||||
'has-[[data-slot=input-group-control]:focus-visible]:outline-hidden has-[[data-slot=input-group-control]:focus-visible]:ring-2 has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-offset-2 has-[[data-slot=input-group-control]:focus-visible]:ring-offset-background',
|
||||
|
||||
// Error state.
|
||||
'has-[[data-slot][aria-invalid=true]]:bg-destructive-200 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive-400 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40',
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface InputProps
|
||||
export const InputVariants = cva(
|
||||
cn(
|
||||
'flex h-10 w-full rounded-md border border-control read-only:border-button bg-foreground/[.026] px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-foreground-muted read-only:text-foreground-light',
|
||||
'focus:ring-background-control focus:border-control focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-background-control focus-visible:ring-offset-2 focus-visible:ring-offset-foreground-muted disabled:cursor-not-allowed disabled:text-foreground-muted',
|
||||
'focus:border-control focus-ring disabled:cursor-not-allowed disabled:text-foreground-muted',
|
||||
'aria-[] aria-[invalid=true]:bg-destructive-200 aria-[invalid=true]:border-destructive-400 aria-[invalid=true]:focus:border-destructive aria-[invalid=true]:focus-visible:border-destructive'
|
||||
),
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'relative aspect-square h-4 w-4 rounded-full border border-primary text-primary focus-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -47,7 +47,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs ring-offset-background-control data-[placeholder]:text-foreground-lighter focus:outline-hidden ring-border-control focus:ring-2 focus:ring-background-control focus:ring-offset-2 focus-visible:ring-offset-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200',
|
||||
'flex w-full items-center justify-between rounded-md border border-strong hover:border-stronger bg-alternative dark:bg-muted hover:bg-selection text-xs data-[placeholder]:text-foreground-lighter ring-border-control focus-ring disabled:cursor-not-allowed disabled:opacity-50 transition-colors duration-200',
|
||||
'data-[state=open]:bg-selection data-[state=open]:border-stronger',
|
||||
'gap-2',
|
||||
'[&>span]:truncate text-left', // [kemal] This is to prevent double lines rendering when a string is particularly long.
|
||||
|
||||
@@ -41,7 +41,7 @@ function Slider({
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
key={index}
|
||||
className="block h-5 w-5 rounded-full border-2 border-background-surface-100 bg-foreground ring-offset-background transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
className="block h-5 w-5 rounded-full border-2 border-background-surface-100 bg-foreground transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
))}
|
||||
</SliderPrimitive.Root>
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from 'react'
|
||||
import { cn } from '../../../lib/utils/cn'
|
||||
|
||||
const switchRootVariants = cva(
|
||||
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-background-control focus-visible:ring-offset-2 focus-visible:ring-offset-foreground-muted disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=checked]:hover:bg-brand-600/90 data-[state=unchecked]:bg-control data-[state=unchecked]:hover:bg-border',
|
||||
'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors focus-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=checked]:hover:bg-brand-600/90 data-[state=unchecked]:bg-control data-[state=unchecked]:hover:bg-border',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
|
||||
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap py-1.5 text-sm ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent',
|
||||
'inline-flex items-center justify-center whitespace-nowrap py-1.5 text-sm transition-colors focus-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-foreground data-[state=active]:shadow-xs text-foreground-lighter hover:text-foreground data-[state=active]:border-foreground border-b-2 border-transparent',
|
||||
'group',
|
||||
className
|
||||
)}
|
||||
@@ -39,14 +39,7 @@ const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-4 ring-offset-background focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<TabsPrimitive.Content ref={ref} className={cn('mt-4 focus-ring', className)} {...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-10 w-full rounded-md border border-control focus:border-control bg-control px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-foreground-muted',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-background-control focus-visible:ring-offset-2 focus-visible:ring-offset-foreground-muted disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
...customClasses,
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[80px] w-full rounded-md border border-control bg-foreground/[.026] px-3 py-2 text-base md:text-sm ring-offset-background placeholder:text-foreground-muted focus:ring-background-control focus:border-control focus-visible:border-control focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-offset-foreground-muted focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'flex min-h-[80px] w-full rounded-md border border-control bg-foreground/[.026] px-3 py-2 text-base md:text-sm placeholder:text-foreground-muted focus:border-control focus-visible:border-control focus-ring disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as React from 'react'
|
||||
import { cn } from '../../../lib/utils/cn'
|
||||
|
||||
const toggleVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors text-foreground-light data-[state=on]:bg-accent data-[state=on]:bg-surface-300 data-[state=on]:text-foreground aria-checked:bg-accent aria-checked:bg-surface-300 aria-checked:text-foreground focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 ring-offset-background bg-surface-200 hover:bg-surface-300 px-3 py-1 h-auto transition-all',
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors text-foreground-light data-[state=on]:bg-accent data-[state=on]:bg-surface-300 data-[state=on]:text-foreground aria-checked:bg-accent aria-checked:bg-surface-300 aria-checked:text-foreground focus-ring disabled:pointer-events-none disabled:opacity-50 bg-surface-200 hover:bg-surface-300 px-3 py-1 h-auto',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -19,17 +19,6 @@ const defaults = {
|
||||
alternative: 'border-alternative',
|
||||
},
|
||||
placeholder: 'placeholder-border-stronger',
|
||||
focus: `
|
||||
outline-hidden
|
||||
focus:ring-current focus:ring-2
|
||||
`,
|
||||
'focus-visible': `
|
||||
outline-hidden
|
||||
transition-all
|
||||
outline-0
|
||||
focus-visible:outline-4
|
||||
focus-visible:outline-offset-1
|
||||
`,
|
||||
size: {
|
||||
// buttons, inputs, input labels use these sizes
|
||||
text: {
|
||||
|
||||
Reference in new issue
Block a user