From e75662481496bbdeffbe5fa2ae3d28919bc08840 Mon Sep 17 00:00:00 2001 From: Ludvig Rask <74653458+ludvigrask@users.noreply.github.com> Date: Wed, 4 Nov 2026 05:50:30 +0100 Subject: [PATCH] feat(ui): add the design system checkbox as the compact appearance (#61516) --- .../component-tests/input-surfaces.spec.ts | 28 +++ .../src/forms/checkbox/checkbox-compact.css | 169 ++++++++++++++++++ .../src/forms/checkbox/checkbox.stories.tsx | 35 +++- packages/ui/src/forms/checkbox/checkbox.tsx | 124 +++++++++---- 4 files changed, 323 insertions(+), 33 deletions(-) create mode 100644 packages/ui/src/forms/checkbox/checkbox-compact.css diff --git a/packages/app/component-tests/input-surfaces.spec.ts b/packages/app/component-tests/input-surfaces.spec.ts index 12806180f69..fa2fde9473f 100644 --- a/packages/app/component-tests/input-surfaces.spec.ts +++ b/packages/app/component-tests/input-surfaces.spec.ts @@ -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 }) => { diff --git a/packages/ui/src/forms/checkbox/checkbox-compact.css b/packages/ui/src/forms/checkbox/checkbox-compact.css new file mode 100644 index 00000000000..42e948126c5 --- /dev/null +++ b/packages/ui/src/forms/checkbox/checkbox-compact.css @@ -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; + } +} diff --git a/packages/ui/src/forms/checkbox/checkbox.stories.tsx b/packages/ui/src/forms/checkbox/checkbox.stories.tsx index cf940f0f201..af619c08f8d 100644 --- a/packages/ui/src/forms/checkbox/checkbox.stories.tsx +++ b/packages/ui/src/forms/checkbox/checkbox.stories.tsx @@ -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: () => Hidden label } + +export const Compact = { + render: () => ( +
+ + Default + + + Checked + + + Indeterminate + + + Invalid + + + Disabled + + + Checked, disabled + + + Layer 1 + +
+ ), +} diff --git a/packages/ui/src/forms/checkbox/checkbox.tsx b/packages/ui/src/forms/checkbox/checkbox.tsx index 248fe8149cb..7d70db1c90f 100644 --- a/packages/ui/src/forms/checkbox/checkbox.tsx +++ b/packages/ui/src/forms/checkbox/checkbox.tsx @@ -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> { + /** `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 ( - - - - - {local.icon || ( - - - - )} - - -
- - - - - {local.description} - - -
-
+ + + +
+ +
+ + + + + + +
+
+ + + + + {local.description} + +
+
+ +
+
+ + + + + + {local.icon || ( + + + + )} + + +
+ + + + + {local.description} + + +
+
+
+
) }