feat(ui): add the design system checkbox as the compact appearance (#61516)

This commit is contained in:
Ludvig Rask
2026-11-04 05:50:30 +01:00
committed by GitHub
Unverified
parent 774a3dd510
commit e756624814
4 changed files with 323 additions and 33 deletions
@@ -125,6 +125,34 @@ story("select fields come in the text input sizes and default to the inline trig
await expect(fields.nth(3)).toHaveCSS("box-shadow", "none")
})
story("compact checkboxes use the design system control states and focus ring", async ({ mount, page }) => {
const root = await mount("ui-checkbox--compact")
await page.addStyleTag({
content: `[data-slot="checkbox-v2"] {
--v2-background-bg-base: rgb(20, 30, 40);
--v2-background-bg-layer-01: rgb(50, 60, 70);
--v2-background-bg-accent: rgb(1, 2, 3);
--v2-state-fg-danger: rgb(7, 8, 9);
}`,
})
const controls = root.locator('[data-slot="checkbox-v2-control"]')
await expect(controls.nth(0)).toHaveCSS("background-color", "rgb(20, 30, 40)")
await expect(controls.nth(0)).toHaveCSS("background-image", "none")
await expect(controls.nth(1)).toHaveCSS("background-color", "rgb(1, 2, 3)")
await expect(controls.nth(1)).not.toHaveCSS("background-image", "none")
await expect(controls.nth(2)).toHaveCSS("background-color", "rgb(1, 2, 3)")
await expect(controls.nth(3)).toHaveCSS("box-shadow", "rgb(7, 8, 9) 0px 0px 0px 0.5px inset")
await expect(controls.nth(6)).toHaveCSS("background-color", "rgb(50, 60, 70)")
await page.keyboard.press("Tab")
await expect(root.getByRole("checkbox", { name: "Default" })).toBeFocused()
await expect(controls.nth(0)).toHaveCSS("outline-width", "2px")
await expect(controls.nth(0)).toHaveCSS("outline-offset", "2px")
})
story(
"text inputs keep clear and copy actions equally sized and inset across sizes and directions",
async ({ mount, page }) => {
@@ -0,0 +1,169 @@
/* Vite can link this before the app entry CSS, so name every layer here or `components` ranks below Tailwind's base reset. */
@layer properties, theme, base, components, utilities;
@layer components {
[data-slot="checkbox-v2"] {
display: flex;
flex-direction: column;
gap: 6px;
cursor: default;
&:where([data-disabled]) {
cursor: not-allowed;
}
&:where([data-readonly]) {
pointer-events: none;
}
}
[data-slot="checkbox-v2-error"] {
color: var(--v2-state-fg-danger);
font-size: 12px;
font-weight: 440;
line-height: var(--line-height-compact);
}
[data-slot="checkbox-v2-error"]:empty {
display: none;
}
[data-slot="checkbox-v2-row"] {
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
}
[data-slot="checkbox-v2-input"] {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* 20px tall so the 16px control centres on the first 20px line of the label. */
[data-slot="checkbox-v2-control-stack"] {
position: relative;
width: 16px;
height: 20px;
flex: none;
}
[data-slot="checkbox-v2-control"] {
box-sizing: border-box;
position: absolute;
top: 2px;
left: 0;
width: 16px;
height: 16px;
border-radius: 4px;
color: var(--v2-icon-icon-contrast);
background: var(--v2-background-bg-base);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-strong);
transition:
background 85ms ease-out,
box-shadow 85ms ease-out;
}
[data-slot="checkbox-v2"][data-surface="layer-1"] [data-slot="checkbox-v2-control"] {
background: var(--v2-background-bg-layer-01);
}
[data-slot="checkbox-v2-indicator"] {
position: absolute;
inset: 0;
pointer-events: none;
}
[data-slot="checkbox-v2-text"] {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
[data-slot="checkbox-v2-label"] {
user-select: none;
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
letter-spacing: -0.04px;
font-variation-settings: "slnt" 0;
}
[data-slot="checkbox-v2-description"] {
color: var(--v2-text-text-muted);
font-size: 11px;
font-weight: 440;
line-height: var(--line-height-tight);
letter-spacing: 0.05px;
user-select: none;
}
[data-slot="checkbox-v2"]:where(:not([data-readonly]))
[data-slot="checkbox-v2-input"]:focus-visible
~ [data-slot="checkbox-v2-control-stack"]
[data-slot="checkbox-v2-control"] {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
@media (hover: hover) {
[data-slot="checkbox-v2"]:where(:hover):where(:not([data-disabled], [data-readonly], [data-invalid])):where(
:not([data-checked], [data-indeterminate])
)
[data-slot="checkbox-v2-control"] {
box-shadow:
inset 0 0 0 0.5px var(--v2-border-border-strong),
inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-hover);
}
[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]):where(:hover):where(
:not([data-disabled], [data-readonly])
)
[data-slot="checkbox-v2-control"] {
box-shadow:
inset 0 0 0 0.5px var(--v2-border-border-strong),
inset 0 0 0 100vmax var(--v2-overlay-simple-overlay-contrast-hover);
}
}
/* The depth gradient is Figma's white-to-clear fill at 20% opacity over the accent. */
[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]) [data-slot="checkbox-v2-control"] {
background:
linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-accent);
}
[data-slot="checkbox-v2"]:where([data-invalid]):where(:not([data-checked], [data-indeterminate]))
[data-slot="checkbox-v2-control"] {
background: var(--v2-state-bg-danger);
box-shadow: inset 0 0 0 0.5px var(--v2-state-fg-danger);
}
[data-slot="checkbox-v2"] .checkbox-v2-icon {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: none;
}
[data-slot="checkbox-v2"]:where([data-checked]):where(:not([data-indeterminate])) .checkbox-v2-icon--check,
[data-slot="checkbox-v2"]:where([data-indeterminate]) .checkbox-v2-icon--minus {
display: block;
}
[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-row"] {
opacity: 0.5;
}
}
@@ -7,11 +7,14 @@ Checkbox control for multi-select or agreement inputs.
### API
- Uses Kobalte Checkbox props.
- Optional: \`hideLabel\`, \`description\`, \`icon\`.
- Optional: \`hideLabel\`, \`description\`, \`icon\` (standard only).
- Children render as the label.
- \`appearance\`: \`standard\` (default, legacy) or \`compact\`, the design system checkbox.
- \`surface\`: \`base\` (default) or \`layer-1\` for the compact control.
### Theming/tokens
- Uses \`data-component="checkbox"\` and related slots.
- Standard uses \`data-component="checkbox"\` and related slots.
- Compact uses \`data-slot="checkbox-v2"\` with \`--v2-background-bg-accent\`, \`--v2-border-border-strong\` and \`--v2-state-fg-danger\`.
`
export default {
@@ -44,3 +47,31 @@ export const CustomIcon = {
}
export const HiddenLabel = { render: () => <Checkbox hideLabel>Hidden label</Checkbox> }
export const Compact = {
render: () => (
<div style={{ display: "grid", gap: "16px" }}>
<Checkbox appearance="compact" description="Suffix">
Default
</Checkbox>
<Checkbox appearance="compact" defaultChecked description="Suffix">
Checked
</Checkbox>
<Checkbox appearance="compact" indeterminate>
Indeterminate
</Checkbox>
<Checkbox appearance="compact" validationState="invalid">
Invalid
</Checkbox>
<Checkbox appearance="compact" disabled description="Suffix">
Disabled
</Checkbox>
<Checkbox appearance="compact" disabled defaultChecked>
Checked, disabled
</Checkbox>
<Checkbox appearance="compact" surface="layer-1">
Layer 1
</Checkbox>
</div>
),
}
+93 -31
View File
@@ -1,44 +1,106 @@
import { Control, Description, ErrorMessage, Indicator, Input, Label, Root } from "@kobalte/core/checkbox"
import { Show, splitProps } from "solid-js"
import { Match, Show, Switch, splitProps } from "solid-js"
import type { ComponentProps, JSX, ParentProps } from "solid-js"
import "./checkbox-compact.css"
export interface CheckboxProps extends ParentProps<ComponentProps<typeof Root>> {
/** `compact` is the design system checkbox; `standard` keeps the legacy look. */
appearance?: "standard" | "compact"
/** Background of the compact control. */
surface?: "base" | "layer-1"
hideLabel?: boolean
description?: string
description?: JSX.Element
icon?: JSX.Element
}
export function Checkbox(props: CheckboxProps) {
const [local, others] = splitProps(props, ["children", "class", "label", "hideLabel", "description", "icon"])
const [local, others] = splitProps(props, [
"children",
"class",
"label",
"appearance",
"surface",
"hideLabel",
"description",
"icon",
])
return (
<Root {...others} data-component="checkbox">
<Input data-slot="checkbox-checkbox-input" />
<Control data-slot="checkbox-checkbox-control">
<Indicator data-slot="checkbox-checkbox-indicator">
{local.icon || (
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 7.17905L5.02703 8.85135L9 3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
)}
</Indicator>
</Control>
<div data-slot="checkbox-checkbox-content">
<Show when={props.children}>
<Label data-slot="checkbox-checkbox-label" classList={{ "sr-only": local.hideLabel }}>
{props.children}
</Label>
</Show>
<Show when={local.description}>
<Description data-slot="checkbox-checkbox-description">{local.description}</Description>
</Show>
<ErrorMessage data-slot="checkbox-checkbox-error" />
</div>
</Root>
<Switch>
<Match when={local.appearance === "compact"}>
<Root {...others} class={local.class} data-slot="checkbox-v2" data-surface={local.surface ?? "base"}>
<div data-slot="checkbox-v2-row">
<Input data-slot="checkbox-v2-input" />
<div data-slot="checkbox-v2-control-stack">
<Control data-slot="checkbox-v2-control">
<Indicator data-slot="checkbox-v2-indicator">
<svg
class="checkbox-v2-icon checkbox-v2-icon--check"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M3.53564 8.17857L6.39279 11.75L12.4642 4.25" stroke="currentColor" stroke-width="1" />
</svg>
<svg
class="checkbox-v2-icon checkbox-v2-icon--minus"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<path d="M12.75 8H3.25" stroke="currentColor" stroke-linejoin="round" stroke-width="1" />
</svg>
</Indicator>
</Control>
</div>
<div data-slot="checkbox-v2-text">
<Show when={local.children}>
<Label data-slot="checkbox-v2-label" classList={{ "sr-only": local.hideLabel }}>
{local.children}
</Label>
</Show>
<Show when={local.description}>
<Description data-slot="checkbox-v2-description">{local.description}</Description>
</Show>
</div>
</div>
<ErrorMessage data-slot="checkbox-v2-error" />
</Root>
</Match>
<Match when={true}>
<Root {...others} class={local.class} data-component="checkbox">
<Input data-slot="checkbox-checkbox-input" />
<Control data-slot="checkbox-checkbox-control">
<Indicator data-slot="checkbox-checkbox-indicator">
{local.icon || (
<svg viewBox="0 0 12 12" fill="none" width="10" height="10" xmlns="http://www.w3.org/2000/svg">
<path
d="M3 7.17905L5.02703 8.85135L9 3.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
)}
</Indicator>
</Control>
<div data-slot="checkbox-checkbox-content">
<Show when={props.children}>
<Label data-slot="checkbox-checkbox-label" classList={{ "sr-only": local.hideLabel }}>
{props.children}
</Label>
</Show>
<Show when={local.description}>
<Description data-slot="checkbox-checkbox-description">{local.description}</Description>
</Show>
<ErrorMessage data-slot="checkbox-checkbox-error" />
</div>
</Root>
</Match>
</Switch>
)
}