app: Initial tabs impl (#28436)

This commit is contained in:
Brendan Allan
2026-05-20 14:40:06 +08:00
committed by GitHub
parent 4702cddb3e
commit 38b406fb35
86 changed files with 10786 additions and 604 deletions

View File

@@ -1,11 +1,12 @@
import { createEffect, createMemo, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/components/icon-button-v2.jsx"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
@@ -14,6 +15,9 @@ import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { WindowsAppMenu } from "./windows-app-menu"
import { applyPath, backPath, forwardPath } from "./titlebar-history"
import { useGlobalSync } from "@/context/global-sync"
import { decodeDirectory } from "@/pages/directory-layout"
import { iife } from "@opencode-ai/core/util/iife"
type TauriDesktopWindow = {
startDragging?: () => Promise<void>
@@ -40,6 +44,8 @@ const titlebarHeight = 40
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}`
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
@@ -53,6 +59,7 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
@@ -176,165 +183,378 @@ export function Titlebar() {
return (
<header
class="h-10 shrink-0 bg-background-base relative overflow-hidden"
style={{ "min-height": minHeight() }}
class="h-10 shrink-0 bg-background-base relative overflow-hidden flex flex-row"
style={{ "min-height": minHeight(), "padding-left": mac() ? `${84 / zoom()}px` : 0 }}
data-tauri-drag-region
onMouseDown={drag}
onDblClick={maximize}
>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={windows()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
<Switch>
<Match when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
{(_) => {
const globalSync = useGlobalSync()
const navigate = useNavigate()
type Tab = { dir: string; sessionId: string; params: any; href: string }
const [tabsStore, tabsStoreActions] = iife(() => {
const [store, setStore] = createStore<Tab[]>(
iife(() => {
if (!params.dir || !params.id) return []
return [
{
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
},
]
}),
)
const actions = {
addTab: (tab: Tab) => {
setStore(
produce((tabs) => {
if (tabs.some((t) => t.href === tab.href)) return
tabs.push(tab)
}),
)
},
removeTab: (href: string) => {
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((t) => t.href === href)
if (index === -1) return
tabs.splice(index, 1)
const nextTab = tabs[index] ?? tabs[tabs.length - 1]
if (nextTab) navigate(nextTab.href)
else navigate("/")
}),
)
})
},
}
return [store, actions]
})
createEffect(() => {
const params = useParams()
if (!(params.dir && params.id)) return
tabsStoreActions.addTab({
dir: decodeDirectory(params.dir) ?? "",
sessionId: params.id,
params: { id: params.id, dir: params.dir },
href: makeSessionHref(params.dir, params.id),
})
})
const tabsEnriched = iife(() => {
const base = mapArray(
() => tabsStore,
(tab) => {
const sync = globalSync.createDirSyncContext(tab.dir)
const session = sync.session.get(tab.sessionId)
return session ? { ...tab, info: session } : null
},
)
return () => base().flatMap((s) => (s ? [s] : []))
})
return (
<div class="h-full flex-1 flex flex-row items-center gap-1.5 pr-3">
<ChannelIndicator />
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<IconButtonV2
as="a"
href="/"
variant="ghost-muted"
size="large"
class="!w-8"
state={!!useMatch(() => "/")() ? "pressed" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M13.9948 11.668H9.32812M11.6641 9.33203V13.9987M6.66667 9.33203V13.9987H2V9.33203H6.66667ZM6.66667 2V6.66667H2V2H6.66667ZM13.9948 2V6.66667H9.32812V2H13.9948Z"
stroke="currentColor"
stroke-miterlimit="10"
stroke-linecap="square"
/>
</svg>
</IconButtonV2>
<div class="flex flex-row items-center gap-2">
<For each={tabsEnriched()}>
{(tab, i) => (
<>
{i() !== 0 && <div class="w-[1.5px] h-3 rounded-full bg-[var(--v2-background-bg-layer-02)]" />}
<TabNavItem
href={tab.href}
title={tab.info.title}
onClose={() => tabsStoreActions.removeTab(tab.href)}
hideClose={tabsEnriched().length < 2}
/>
</>
)}
</For>
</div>
<button>
<div class="p-1.5">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
<path
d="M7.99978 2.88867V13.1109M2.88867 7.99978H13.1109"
stroke="#808080"
stroke-linejoin="round"
/>
</TooltipKeybind>
</svg>
</div>
</button>
<div class="flex-1" />
{/*<button class="px-2.5 py-1.5 bg-[rgba(0,0,0,0.08)] rounded-[6px]">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path
d="M10.4443 2.44436V13.5555M1.55546 13.5554H14.4443V2.44434H1.55542L1.55546 13.5554Z"
stroke="#3A3A3A"
/>
</svg>
</button>*/}
</div>
)
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !mac(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={2000}
>
<Button
variant="ghost"
icon={creating() ? "new-session-active" : "new-session"}
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
/>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<ChannelIndicator />
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
onMouseDown={drag}
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />}
<div data-tauri-decorum-tb class="flex flex-row" />
</Show>
</div>
</div>
</Match>
</Switch>
</header>
)
}
function TabNavItem(props: { href: string; title: string; hideClose?: boolean; onClose: () => void }) {
const match = useMatch(() => props.href)
const isActive = () => !!match()
return (
<div
class="group flex flex-row items-center max-w-60 whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] bg-[var(--tab-bg)] h-7 rounded-[6px] relative overflow-hidden"
data-active={isActive()}
>
<a
href={props.href}
class="w-full h-full pl-1.5 flex-1 max-w-full flex flex-row items-center overflow-hidden font-medium"
>
{props.title}
</a>
<div class="absolute right-0 inset-y-0 flex flex-row items-center pr-1 py-1 w-8 pl-2">
<div
class="absolute inset-0 bg-(image:--inactive-bg) group-hover:bg-(image:--active-bg) group-data-[active=true]:bg-(image:--active-bg)"
style={{
"--inactive-bg": "linear-gradient(to right, transparent 0%, var(--tab-bg) 80%)",
"--active-bg": "linear-gradient(90deg, transparent 0%, var(--tab-bg) 25%)",
}}
/>
<IconButtonV2
size="small"
variant="ghost-muted"
class="opacity-0 group-hover:opacity-100 group-data-[active='true']:opacity-100"
onClick={props.onClose}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
class="size-4"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
}
/>
</div>
</div>
)
}
function ChannelIndicator() {
return (
<>
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</>
)
}

View File

@@ -0,0 +1,596 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import type { Message, OpencodeClient, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
}
function runInflight(map: Map<string, Promise<void>>, key: string, task: () => Promise<void>) {
const pending = map.get(key)
if (pending) return pending
const promise = task().finally(() => {
map.delete(key)
})
map.set(key, promise)
return promise
}
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function merge<T extends { id: string }>(a: readonly T[], b: readonly T[]) {
const map = new Map(a.map((item) => [item.id, item] as const))
for (const item of b) map.set(item.id, item)
return [...map.values()].sort((x, y) => cmp(x.id, y.id))
}
type OptimisticStore = {
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
}
type OptimisticAddInput = {
sessionID: string
message: Message
parts: Part[]
}
type OptimisticRemoveInput = {
sessionID: string
messageID: string
}
type OptimisticItem = {
message: Message
parts: Part[]
}
type MessagePage = {
session: Message[]
part: { id: string; part: Part[] }[]
cursor?: string
complete: boolean
}
const hasParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return want.length === 0
return want.every((part) => Binary.search(parts, part.id, (item) => item.id).found)
}
const mergeParts = (parts: Part[] | undefined, want: Part[]) => {
if (!parts) return sortParts(want)
const next = [...parts]
let changed = false
for (const part of want) {
const result = Binary.search(next, part.id, (item) => item.id)
if (result.found) continue
next.splice(result.index, 0, part)
changed = true
}
if (!changed) return parts
return next
}
export function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
if (items.length === 0) return { ...page, confirmed: [] as string[] }
const session = [...page.session]
const part = new Map(page.part.map((item) => [item.id, sortParts(item.part)]))
const confirmed: string[] = []
for (const item of items) {
const result = Binary.search(session, item.message.id, (message) => message.id)
const found = result.found
if (!found) session.splice(result.index, 0, item.message)
const current = part.get(item.message.id)
if (found && hasParts(current, item.parts)) {
confirmed.push(item.message.id)
continue
}
part.set(item.message.id, mergeParts(current, item.parts))
}
return {
cursor: page.cursor,
complete: page.complete,
session,
part: [...part.entries()].sort((a, b) => cmp(a[0], b[0])).map(([id, part]) => ({ id, part })),
confirmed,
}
}
export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.message.id, (m) => m.id)
messages.splice(result.index, 0, input.message)
} else {
draft.message[input.sessionID] = [input.message]
}
draft.part[input.message.id] = sortParts(input.parts)
}
export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
const messages = draft.message[input.sessionID]
if (messages) {
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[input.messageID]
}
function setOptimisticAdd(setStore: (...args: unknown[]) => void, input: OptimisticAddInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return [input.message]
const result = Binary.search(messages, input.message.id, (m) => m.id)
const next = [...messages]
next.splice(result.index, 0, input.message)
return next
})
setStore("part", input.message.id, sortParts(input.parts))
}
function setOptimisticRemove(setStore: (...args: unknown[]) => void, input: OptimisticRemoveInput) {
setStore("message", input.sessionID, (messages: Message[] | undefined) => {
if (!messages) return messages
const result = Binary.search(messages, input.messageID, (m) => m.id)
if (!result.found) return messages
const next = [...messages]
next.splice(result.index, 1)
return next
})
setStore("part", (part: Record<string, Part[] | undefined>) => {
if (!(input.messageID in part)) return part
const next = { ...part }
delete next[input.messageID]
return next
})
}
export const createDirSyncContext = (client: OpencodeClient, directory: string) => {
const globalSync = useGlobalSync()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(directory))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: { client: typeof client; sessionID: string; limit: number; before?: string }) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
setOptimistic(_directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const _directory = input.directory ?? directory
const [, setStore] = target(input.directory)
clearOptimistic(_directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, _directory = directory) {
const [, setStore] = globalSync.child(_directory)
seenFor(_directory).delete(sessionID)
evict(_directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
}

View File

@@ -36,6 +36,7 @@ import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from
import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
type GlobalStore = {
ready: boolean
@@ -431,6 +432,9 @@ function createGlobalSync() {
},
}))
const dirSyncContexts = new Map<string, ReturnType<typeof createDirSyncContext>>()
const dirSyncContextRefCounts = new Map<string, number>()
return {
data: globalStore,
set,
@@ -449,6 +453,26 @@ function createGlobalSync() {
todo: {
set: setSessionTodo,
},
createDirSyncContext: (directory: string) => {
onCleanup(() => {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) - 1)
if (dirSyncContextRefCounts.get(directory) === 0) {
dirSyncContexts.delete(directory)
dirSyncContextRefCounts.delete(directory)
}
})
const cached = dirSyncContexts.get(directory)
if (cached) {
dirSyncContextRefCounts.set(directory, (dirSyncContextRefCounts.get(directory) ?? 0) + 1)
return cached
}
const ctx = createDirSyncContext(globalSDK.createClient({ directory, throwOnError: true }), directory)
dirSyncContexts.set(directory, ctx)
dirSyncContextRefCounts.set(directory, 1)
return ctx
},
}
}

View File

@@ -1,19 +1,8 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/core/util/binary"
import { retry } from "@opencode-ai/core/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
import {
clearSessionPrefetch,
getSessionPrefetch,
getSessionPrefetchPromise,
setSessionPrefetch,
} from "./global-sync/session-prefetch"
import { useGlobalSync } from "./global-sync"
import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
import { diffs as list, message as clean } from "@/utils/diffs"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
@@ -172,448 +161,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const globalSync = useGlobalSync()
const sdk = useSDK()
type Child = ReturnType<(typeof globalSync)["child"]>
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
const target = (directory?: string) => {
if (!directory || directory === sdk.directory) return current()
return globalSync.child(directory)
}
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const optimistic = new Map<string, Map<string, OptimisticItem>>()
const maxDirs = 30
const seen = new Map<string, Set<string>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
cursor: {} as Record<string, string | undefined>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const store = current()[0]
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const setOptimistic = (directory: string, sessionID: string, item: OptimisticItem) => {
const key = keyFor(directory, sessionID)
const list = optimistic.get(key)
if (list) {
list.set(item.message.id, { message: item.message, parts: sortParts(item.parts) })
return
}
optimistic.set(key, new Map([[item.message.id, { message: item.message, parts: sortParts(item.parts) }]]))
}
const clearOptimistic = (directory: string, sessionID: string, messageID?: string) => {
const key = keyFor(directory, sessionID)
if (!messageID) {
optimistic.delete(key)
return
}
const list = optimistic.get(key)
if (!list) return
list.delete(messageID)
if (list.size === 0) optimistic.delete(key)
}
const getOptimistic = (directory: string, sessionID: string) => [
...(optimistic.get(keyFor(directory, sessionID))?.values() ?? []),
]
const seenFor = (directory: string) => {
const existing = seen.get(directory)
if (existing) {
seen.delete(directory)
seen.set(directory, existing)
return existing
}
const created = new Set<string>()
seen.set(directory, created)
while (seen.size > maxDirs) {
const first = seen.keys().next().value
if (!first) break
const stale = [...(seen.get(first) ?? [])]
seen.delete(first)
const [, setStore] = globalSync.child(first, { bootstrap: false })
evict(first, setStore, stale)
}
return created
}
const clearMeta = (directory: string, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
for (const sessionID of sessionIDs) {
clearOptimistic(directory, sessionID)
}
setMeta(
produce((draft) => {
for (const sessionID of sessionIDs) {
const key = keyFor(directory, sessionID)
delete draft.limit[key]
delete draft.cursor[key]
delete draft.complete[key]
delete draft.loading[key]
}
}),
)
}
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
for (const sessionID of sessionIDs) {
globalSync.todo.set(sessionID, undefined)
}
setStore(
produce((draft) => {
dropSessionCaches(draft, sessionIDs)
}),
)
clearMeta(directory, sessionIDs)
}
const touch = (directory: string, setStore: Setter, sessionID: string) => {
const stale = pickSessionCacheEvictions({
seen: seenFor(directory),
keep: sessionID,
limit: SESSION_CACHE_LIMIT,
})
evict(directory, setStore, stale)
}
const fetchMessages = async (input: {
client: typeof sdk.client
sessionID: string
limit: number
before?: string
}) => {
const messages = await retry(() =>
input.client.session.messages({ sessionID: input.sessionID, limit: input.limit, before: input.before }),
)
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const session = items.map((x) => clean(x.info)).sort((a, b) => cmp(a.id, b.id))
const part = items.map((message) => ({ id: message.info.id, part: sortParts(message.parts) }))
const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
return {
session,
part,
cursor,
complete: !cursor,
}
}
const tracked = (directory: string, sessionID: string) => seen.get(directory)?.has(sessionID) ?? false
const loadMessages = async (input: {
directory: string
client: typeof sdk.client
setStore: Setter
sessionID: string
limit: number
before?: string
mode?: "replace" | "prepend"
}) => {
const key = keyFor(input.directory, input.sessionID)
if (meta.loading[key]) return
setMeta("loading", key, true)
await fetchMessages(input)
.then((page) => {
if (!tracked(input.directory, input.sessionID)) return
const next = mergeOptimisticPage(page, getOptimistic(input.directory, input.sessionID))
for (const messageID of next.confirmed) {
clearOptimistic(input.directory, input.sessionID, messageID)
}
const [store] = globalSync.child(input.directory, { bootstrap: false })
const cached = input.mode === "prepend" ? (store.message[input.sessionID] ?? []) : []
const message = input.mode === "prepend" ? merge(cached, next.session) : next.session
batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) {
const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
}
setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
cursor: next.cursor,
complete: next.complete,
})
})
})
.finally(() => {
setMeta(
produce((draft) => {
if (!tracked(input.directory, input.sessionID)) {
delete draft.loading[key]
return
}
draft.loading[key] = false
}),
)
})
}
return {
get data() {
return current()[0]
},
get set(): Setter {
return current()[1]
},
get status() {
return current()[0].status
},
get ready() {
return current()[0].status !== "loading"
},
get project() {
const store = current()[0]
const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
if (match.found) return globalSync.data.project[match.index]
return undefined
},
session: {
get: getSession,
optimistic: {
add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
setOptimistic(directory, input.sessionID, { message: input.message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, input)
},
remove(input: { directory?: string; sessionID: string; messageID: string }) {
const directory = input.directory ?? sdk.directory
const [, setStore] = target(input.directory)
clearOptimistic(directory, input.sessionID, input.messageID)
setOptimisticRemove(setStore as (...args: unknown[]) => void, input)
},
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
parts: Part[]
agent: string
model: { providerID: string; modelID: string }
variant?: string
}) {
const message: Message = {
id: input.messageID,
sessionID: input.sessionID,
role: "user",
time: { created: Date.now() },
agent: input.agent,
model: { ...input.model, variant: input.variant },
}
const [, setStore] = target()
setOptimistic(sdk.directory, input.sessionID, { message, parts: input.parts })
setOptimisticAdd(setStore as (...args: unknown[]) => void, {
sessionID: input.sessionID,
message,
parts: input.parts,
})
},
async sync(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
const key = keyFor(directory, sessionID)
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
setMeta("cursor", key, seeded.cursor)
setMeta("complete", key, seeded.complete)
setMeta("loading", key, false)
})
}
}
const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq =
hasSession && !opts?.force
? Promise.resolve()
: retry(() => client.session.get({ sessionID })).then((session) => {
if (!tracked(directory, sessionID)) return
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq =
cached && !opts?.force
? Promise.resolve()
: loadMessages({
directory,
client,
setStore,
sessionID,
limit,
})
await Promise.all([sessionReq, messagesReq])
})
},
async diff(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
if (store.session_diff[sessionID] !== undefined && !opts?.force) return
const key = keyFor(directory, sessionID)
return runInflight(inflightDiff, key, () =>
retry(() => client.session.diff({ sessionID })).then((diff) => {
if (!tracked(directory, sessionID)) return
setStore("session_diff", sessionID, reconcile(list(diff.data), { key: "file" }))
}),
)
},
async todo(sessionID: string, opts?: { force?: boolean }) {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const existing = store.todo[sessionID]
const cached = globalSync.data.session_todo[sessionID]
if (existing !== undefined) {
if (cached === undefined) {
globalSync.todo.set(sessionID, existing)
}
if (!opts?.force) return
}
if (cached !== undefined) {
setStore("todo", sessionID, reconcile(cached, { key: "id" }))
}
const key = keyFor(directory, sessionID)
return runInflight(inflightTodo, key, () =>
retry(() => client.session.todo({ sessionID })).then((todo) => {
if (!tracked(directory, sessionID)) return
const list = todo.data ?? []
setStore("todo", sessionID, reconcile(list, { key: "id" }))
globalSync.todo.set(sessionID, list)
}),
)
},
history: {
more(sessionID: string) {
const store = current()[0]
const key = keyFor(sdk.directory, sessionID)
if (store.message[sessionID] === undefined) return false
if (meta.limit[key] === undefined) return false
if (meta.complete[key]) return false
return !!meta.cursor[key]
},
loading(sessionID: string) {
const key = keyFor(sdk.directory, sessionID)
return meta.loading[key] ?? false
},
async loadMore(sessionID: string, count?: number) {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID)
const step = count ?? historyMessagePageSize
if (meta.loading[key]) return
if (meta.complete[key]) return
const before = meta.cursor[key]
if (!before) return
await loadMessages({
directory,
client,
setStore,
sessionID,
limit: step,
before,
mode: "prepend",
})
},
},
evict(sessionID: string, directory = sdk.directory) {
const [, setStore] = globalSync.child(directory)
seenFor(directory).delete(sessionID)
evict(directory, setStore, [sessionID])
},
fetch: async (count = 10) => {
const directory = sdk.directory
const client = sdk.client
const [store, setStore] = globalSync.child(directory)
setStore("limit", (x) => x + count)
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})
},
more: createMemo(() => current()[0].session.length >= current()[0].limit),
archive: async (sessionID: string) => {
const directory = sdk.directory
const client = sdk.client
const [, setStore] = globalSync.child(directory)
await client.session.update({ sessionID, time: { archived: Date.now() } })
setStore(
produce((draft) => {
const match = Binary.search(draft.session, sessionID, (s) => s.id)
if (match.found) draft.session.splice(match.index, 1)
}),
)
},
},
absolute,
get directory() {
return current()[0].path.directory
},
}
return globalSync.createDirSyncContext(sdk.directory)
},
})

View File

@@ -1,4 +1,5 @@
@import "@opencode-ai/ui/styles/tailwind";
@import "@opencode-ai/ui/v2/styles/tailwind.css";
@font-face {
font-family: "JetBrainsMono Nerd Font Mono";

View File

@@ -8,6 +8,7 @@ import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
@@ -40,6 +41,15 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
)
}
export const ProjectDirString = Schema.String.pipe(Schema.brand("ProjectDirString"))
export type ProjectDirString = Schema.Schema.Type<typeof ProjectDirString>
export function decodeDirectory(dir: string): ProjectDirString | undefined {
const decoded = decode64(dir)
if (!decoded) return
return ProjectDirString.make(decoded)
}
export default function Layout(props: ParentProps) {
const params = useParams()
const language = useLanguage()
@@ -48,7 +58,7 @@ export default function Layout(props: ParentProps) {
const resolved = createMemo(() => {
if (!params.dir) return ""
return decode64(params.dir) ?? ""
return decodeDirectory(params.dir) ?? ""
})
createEffect(() => {

View File

@@ -22,7 +22,8 @@
"./icons/file-type": "./src/components/file-icons/types.ts",
"./icons/app": "./src/components/app-icons/types.ts",
"./fonts/*": "./src/assets/fonts/*",
"./audio/*": "./src/assets/audio/*"
"./audio/*": "./src/assets/audio/*",
"./v2/*": "./src/v2/*"
},
"scripts": {
"typecheck": "tsgo --noEmit",

View File

@@ -0,0 +1,141 @@
[data-component="accordion-v2"] {
--accordion-v2-fg: var(--text-text-base);
--accordion-v2-icon: var(--icon-icon-base);
--accordion-v2-border: var(--border-border-strong);
--accordion-v2-bg: var(--background-bg-base);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
background: var(--accordion-v2-bg);
border-radius: 6px;
box-shadow: 0 0 0 0.5px var(--accordion-v2-border);
overflow: hidden;
font-family: var(--font-family-sans), 'Inter', system-ui, sans-serif;
color: var(--accordion-v2-fg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="accordion-v2-item"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
width: 100%;
background: var(--accordion-v2-bg);
box-shadow: inset 0 -0.5px 0 var(--accordion-v2-border);
}
[data-component="accordion-v2-item"]:last-child {
box-shadow: none;
}
[data-slot="accordion-v2-header"] {
display: flex;
margin: 0;
}
[data-component="accordion-v2-trigger"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
width: 100%;
min-height: 30px;
padding: 0 12px;
background: transparent;
border: 0;
outline: none;
cursor: default;
user-select: none;
font-family: inherit;
font-size: 13px;
font-weight: 440;
line-height: 100%;
letter-spacing: -0.04px;
color: var(--accordion-v2-fg);
text-align: left;
}
[data-component="accordion-v2-trigger"] [data-slot="accordion-v2-trigger-content"] {
display: flex;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
height: 30px;
}
[data-component="accordion-v2-trigger"] [data-slot="accordion-v2-chevron"] {
flex: none;
width: 14px;
height: 14px;
color: var(--accordion-v2-icon);
transition: transform 150ms ease;
}
[data-component="accordion-v2-trigger"][data-expanded] [data-slot="accordion-v2-chevron"] {
transform: rotate(180deg);
}
[data-component="accordion-v2-item"][data-expanded] [data-component="accordion-v2-trigger"] {
box-shadow: inset 0 -0.5px 0 var(--accordion-v2-border);
}
[data-component="accordion-v2-trigger"][data-disabled] {
pointer-events: none;
opacity: 0.5;
}
[data-component="accordion-v2-content"] {
overflow: hidden;
font-size: 13px;
line-height: 140%;
color: var(--accordion-v2-fg);
}
[data-component="accordion-v2-content"][data-expanded] {
animation: accordion-v2-down 180ms ease-out;
}
[data-component="accordion-v2-content"][data-closed] {
animation: accordion-v2-up 180ms ease-out;
}
[data-component="accordion-v2-content"] [data-slot="accordion-v2-content-inner"] {
display: flex;
flex-direction: row;
align-items: flex-start;
width: 100%;
padding: 12px;
}
@keyframes accordion-v2-down {
from {
height: 0;
}
to {
height: var(--kb-collapsible-content-height);
}
}
@keyframes accordion-v2-up {
from {
height: var(--kb-collapsible-content-height);
}
to {
height: 0;
}
}

View File

@@ -0,0 +1,175 @@
// @ts-nocheck
import { AccordionV2 } from "./accordion-v2"
const docs = `### Overview
Compound accordion built on Kobalte's \`Accordion\` primitive. The trigger automatically renders a chevron that rotates open.
### API
- \`AccordionV2\` — root; forwards Kobalte props (\`multiple\`, \`collapsible\`, \`value\`, \`defaultValue\`, \`onChange\`, etc.).
- \`AccordionV2.Item\` — one expandable row; requires a unique \`value: string\`.
- \`AccordionV2.Header\` — wraps the trigger; preserves heading semantics.
- \`AccordionV2.Trigger\` — auto-renders a trailing chevron; pass \`hideChevron\` to opt out.
- \`AccordionV2.Content\` — body shown when the item is expanded; height-animated.
### Behavior
- Single-select by default (\`collapsible\` allows closing the active item). Use \`multiple\` to let several items open at once.
- Open/closed state is reflected on items, triggers, and content via \`data-expanded\` / \`data-closed\`.
- Content height animates using Kobalte's \`--kb-collapsible-content-height\` variable.
`
export default {
title: "UI V2/Accordion",
id: "components-accordion-v2",
component: AccordionV2,
tags: ["autodocs"],
parameters: {
frameBackground: "#f5f5f5",
docs: {
description: {
component: docs,
},
},
},
}
const frame = { width: "346px", "font-family": "var(--font-family-sans)", "font-size": "13px" } as const
export const Basic = {
render: () => (
<div style={frame}>
<AccordionV2 collapsible defaultValue={["item-1"]}>
<AccordionV2.Item value="item-1">
<AccordionV2.Header>
<AccordionV2.Trigger>Is it accessible?</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>
Yes. It follows the WAI-ARIA Accordion pattern and ships with full keyboard support.
</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="item-2">
<AccordionV2.Header>
<AccordionV2.Trigger>Is it styled?</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Yeah</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="item-3">
<AccordionV2.Header>
<AccordionV2.Trigger>Is it animated?</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Yes. Height animates via Kobalte's collapsible height variable.</AccordionV2.Content>
</AccordionV2.Item>
</AccordionV2>
</div>
),
}
export const Multiple = {
render: () => (
<div style={frame}>
<AccordionV2 multiple defaultValue={["a", "c"]}>
<AccordionV2.Item value="a">
<AccordionV2.Header>
<AccordionV2.Trigger>Section A</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Multiple items can be open at once.</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="b">
<AccordionV2.Header>
<AccordionV2.Trigger>Section B</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Open me too.</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="c">
<AccordionV2.Header>
<AccordionV2.Trigger>Section C</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Already open by default.</AccordionV2.Content>
</AccordionV2.Item>
</AccordionV2>
</div>
),
}
export const Disabled = {
render: () => (
<div style={frame}>
<AccordionV2 collapsible>
<AccordionV2.Item value="one">
<AccordionV2.Header>
<AccordionV2.Trigger>Enabled item</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Body content.</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="two" disabled>
<AccordionV2.Header>
<AccordionV2.Trigger>Disabled item</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>You can't open this one.</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="three">
<AccordionV2.Header>
<AccordionV2.Trigger>Another enabled item</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Body content.</AccordionV2.Content>
</AccordionV2.Item>
</AccordionV2>
</div>
),
}
export const LongContent = {
render: () => (
<div style={frame}>
<AccordionV2 collapsible defaultValue={["long"]}>
<AccordionV2.Item value="long">
<AccordionV2.Header>
<AccordionV2.Trigger>What's inside?</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>
<div style={{ display: "grid", gap: "8px" }}>
<p style={{ margin: 0 }}>
Accordions are useful for compressing dense content into scannable sections. They preserve heading
semantics and announce open/closed state to screen readers.
</p>
<p style={{ margin: 0 }}>
The body can hold arbitrary content paragraphs, lists, even nested components.
</p>
<ul style={{ margin: 0, "padding-left": "16px" }}>
<li>Keyboard navigable</li>
<li>Animated</li>
<li>Themeable via CSS variables</li>
</ul>
</div>
</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="short">
<AccordionV2.Header>
<AccordionV2.Trigger>One more</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Short body.</AccordionV2.Content>
</AccordionV2.Item>
</AccordionV2>
</div>
),
}
export const NoChevron = {
render: () => (
<div style={frame}>
<AccordionV2 collapsible>
<AccordionV2.Item value="x">
<AccordionV2.Header>
<AccordionV2.Trigger hideChevron>Trigger without chevron</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Pass <code>hideChevron</code> on the trigger.</AccordionV2.Content>
</AccordionV2.Item>
<AccordionV2.Item value="y">
<AccordionV2.Header>
<AccordionV2.Trigger>Default trigger</AccordionV2.Trigger>
</AccordionV2.Header>
<AccordionV2.Content>Chevron renders by default.</AccordionV2.Content>
</AccordionV2.Item>
</AccordionV2>
</div>
),
}

View File

@@ -0,0 +1,96 @@
import { Accordion as Kobalte } from "@kobalte/core/accordion"
import { Show, splitProps, type Component, type ComponentProps, type ParentProps } from "solid-js"
import "./accordion-v2.css"
const ChevronDown: Component = () => (
<svg
data-slot="accordion-v2-chevron"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4 5.5L7 8.5L10 5.5" stroke="currentColor" />
</svg>
)
export interface AccordionV2Props extends ComponentProps<typeof Kobalte> {}
export interface AccordionV2ItemProps extends ComponentProps<typeof Kobalte.Item> {}
export interface AccordionV2HeaderProps extends ComponentProps<typeof Kobalte.Header> {}
export interface AccordionV2TriggerProps extends ComponentProps<typeof Kobalte.Trigger> {
hideChevron?: boolean
}
export interface AccordionV2ContentProps extends ComponentProps<typeof Kobalte.Content> {}
function AccordionV2Root(props: ParentProps<AccordionV2Props>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<Kobalte
{...r}
data-component="accordion-v2"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function AccordionV2Item(props: ParentProps<AccordionV2ItemProps>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<Kobalte.Item
{...r}
data-component="accordion-v2-item"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function AccordionV2Header(props: ParentProps<AccordionV2HeaderProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Header
{...r}
data-slot="accordion-v2-header"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
{s.children}
</Kobalte.Header>
)
}
function AccordionV2Trigger(props: ParentProps<AccordionV2TriggerProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children", "hideChevron"])
return (
<Kobalte.Trigger
{...r}
data-component="accordion-v2-trigger"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<span data-slot="accordion-v2-trigger-content">{s.children}</span>
<Show when={!s.hideChevron}>
<ChevronDown />
</Show>
</Kobalte.Trigger>
)
}
function AccordionV2Content(props: ParentProps<AccordionV2ContentProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...r}
data-component="accordion-v2-content"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<div data-slot="accordion-v2-content-inner">{s.children}</div>
</Kobalte.Content>
)
}
export const AccordionV2 = Object.assign(AccordionV2Root, {
Item: AccordionV2Item,
Header: AccordionV2Header,
Trigger: AccordionV2Trigger,
Content: AccordionV2Content,
})

View File

@@ -0,0 +1,71 @@
[data-component="avatar"] {
--avatar-bg: var(--background-bg-layer-02);
--avatar-fg: var(--text-text-muted);
--avatar-radius: 9999px;
--avatar-font-size: 11px;
--avatar-tracking: 0.05px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
overflow: hidden;
width: 28px;
height: 28px;
border-radius: var(--avatar-radius);
border: 0.5px solid var(--border-border-base);
font-family: var(--font-sans);
font-weight: 530;
font-size: var(--avatar-font-size);
line-height: 1;
letter-spacing: var(--avatar-tracking);
text-transform: uppercase;
background-color: var(--avatar-bg);
color: var(--avatar-fg);
user-select: none;
-webkit-user-select: none;
}
[data-component="avatar"]::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, var(--alpha-light-16) 0%, var(--alpha-light-0) 100%);
pointer-events: none;
}
[data-component="avatar"][data-has-image]::before {
display: none;
}
[data-component="avatar"][data-kind="org"] {
--avatar-radius: 6px;
}
[data-component="avatar"][data-kind="org"][data-size="normal"],
[data-component="avatar"][data-kind="org"][data-size="small"] {
--avatar-radius: 4px;
}
[data-component="avatar"][data-size="normal"] {
width: 20px;
height: 20px;
}
[data-component="avatar"][data-size="small"] {
width: 16px;
height: 16px;
}
[data-component="avatar"] [data-slot="avatar-image"] {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
display: block;
object-fit: cover;
border-radius: inherit;
user-select: none;
-webkit-user-select: none;
-webkit-user-drag: none;
}

View File

@@ -0,0 +1,85 @@
// @ts-nocheck
import { Avatar } from "./avatar-v2"
const docs = `### Overview
Avatar matching OpenCode DS variants from Figma.
Use in user lists and headers.
### API
- Required: \`fallback\` string.
- Optional: \`src\`, \`background\`, \`foreground\`, \`size\`, \`kind\`.
### Variants and states
- Sizes: small (16), normal (20), large (28).
- Kind: user (circle), org (rounded-square).
- Image vs initials content state.
### Behavior
- Uses grapheme-aware fallback rendering.
### Accessibility
- TODO: provide alt text when using images; currently image is decorative.
### Theming/tokens
- Uses \`data-component="avatar"\` with size and image state attributes.
`
export default {
title: "UI V2/Avatar",
id: "components-avatar-v2",
component: Avatar,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
argTypes: {
size: {
control: "select",
options: ["small", "normal", "large"],
},
kind: {
control: "select",
options: ["user", "org"],
},
},
args: {
fallback: "WW",
size: "large",
kind: "user",
},
}
export const Basic = {}
export const WithImage = {
args: {
src: "https://placehold.co/80x80/png",
fallback: "WW",
},
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<Avatar size="small" fallback="W" />
<Avatar size="normal" fallback="W" />
<Avatar size="large" fallback="WW" />
</div>
),
}
export const OrgVariant = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center" }}>
<Avatar kind="org" size="small" fallback="W" />
<Avatar kind="org" size="normal" fallback="W" />
<Avatar kind="org" size="large" fallback="WW" />
</div>
),
}

View File

@@ -0,0 +1,59 @@
import { type ComponentProps, splitProps, Show } from "solid-js"
import "./avatar-v2.css"
const segmenter =
typeof Intl !== "undefined" && "Segmenter" in Intl
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: undefined
function first(value: string) {
if (!value) return ""
if (!segmenter) return Array.from(value)[0] ?? ""
return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? ""
}
export interface AvatarProps extends ComponentProps<"div"> {
fallback: string
src?: string
background?: string
foreground?: string
size?: "small" | "normal" | "large"
kind?: "user" | "org"
}
export function Avatar(props: AvatarProps) {
const [split, rest] = splitProps(props, [
"fallback",
"src",
"background",
"foreground",
"size",
"kind",
"class",
"classList",
"style",
])
const src = split.src // did this so i can zero it out to test fallback
return (
<div
{...rest}
data-component="avatar"
data-size={split.size || "large"}
data-kind={split.kind || "user"}
data-has-image={src ? "" : undefined}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
style={{
...(typeof split.style === "object" ? split.style : {}),
...(!src && split.background ? { "--avatar-bg": split.background } : {}),
...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}),
}}
>
<Show when={src} fallback={first(split.fallback)}>
{(src) => <img src={src()} draggable={false} data-slot="avatar-image" />}
</Show>
</div>
)
}

View File

@@ -0,0 +1,28 @@
[data-component="tag"] {
box-sizing: border-box;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
height: 16px;
padding: 0 4px;
user-select: none;
border-radius: 2px;
border: 0.5px solid var(--border-border-base);
background: var(--background-bg-layer-02);
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
color: var(--text-text-muted);
font-variant-numeric: tabular-nums;
}
[data-component="tag"][data-high-contrast] {
border-color: var(--border-border-strong);
}

View File

@@ -0,0 +1,54 @@
// @ts-nocheck
import { Tag } from "./badge-v2"
const docs = `### Overview
Small label tag for metadata and status chips.
Use alongside headings or lists for quick metadata.
### API
- Accepts standard span props.
- Optional: \`data-high-contrast\` attribute for stronger border contrast.
### Variants and states
- Single size style.
- Optional high-contrast border style.
### Behavior
- Inline element with fixed 16px height and tabular numeric text.
### Accessibility
- Ensure text conveys meaning; avoid color-only distinction.
### Theming/tokens
- Uses \`data-component="tag"\`.
`
export default {
title: "UI V2/Badge",
id: "components-badge-v2",
component: Tag,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
children: "Label",
},
}
export const Basic = {}
export const HighContrast = {
render: () => (
<div style={{ display: "flex", gap: "8px", "align-items": "center" }}>
<Tag>Label</Tag>
<Tag data-high-contrast>Label</Tag>
</div>
),
}

View File

@@ -0,0 +1,20 @@
import { type ComponentProps, splitProps } from "solid-js"
import "./badge-v2.css"
export interface TagProps extends ComponentProps<"span"> {}
export function Tag(props: TagProps) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<span
{...rest}
data-component="tag"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</span>
)
}

View File

@@ -0,0 +1,164 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="basic-tool-v2"] {
--bt-title: var(--text-text-base);
--bt-sep: var(--text-text-muted);
--bt-subtitle: var(--text-text-muted);
--bt-args: var(--text-text-muted);
--bt-chevron: var(--text-text-faint);
--bt-content: var(--text-text-muted);
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 4px 0;
gap: 8px;
min-width: 0;
width: 100%;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
[data-slot="basic-tool-v2-trigger"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
width: 100%;
min-width: 0;
min-height: 20px;
padding: 0;
margin: 0;
text-align: left;
color: inherit;
outline: none;
}
[data-slot="basic-tool-v2-trigger"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 2px;
border-radius: 2px;
}
[data-slot="basic-tool-v2-labels"] {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
[data-slot="basic-tool-v2-title"] {
display: flex;
align-items: center;
flex-shrink: 0;
font-size: 13px;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
color: var(--bt-title);
font-variation-settings: "slnt" 0;
user-select: none;
}
[data-slot="basic-tool-v2-sep"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 11px;
font-weight: 530;
line-height: 12px;
letter-spacing: 0.05px;
color: var(--bt-sep);
font-variation-settings: "slnt" 0;
user-select: none;
}
[data-slot="basic-tool-v2-subtitle"] {
display: flex;
align-items: center;
min-width: 0;
flex: 0 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
color: var(--bt-subtitle);
font-variation-settings: "slnt" 0;
user-select: none;
}
[data-slot="basic-tool-v2-arg"] {
display: flex;
align-items: center;
flex-shrink: 0;
font-size: 13px;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
color: var(--bt-args);
font-variation-settings: "slnt" 0;
user-select: none;
}
[data-slot="basic-tool-v2-diff"] {
flex-shrink: 0;
}
[data-slot="basic-tool-v2-diff"] [data-component="diff-changes"] {
gap: 4px;
}
[data-slot="basic-tool-v2-chevron-wrap"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 2px;
border-radius: 4px;
color: var(--bt-chevron);
user-select: none;
}
[data-slot="basic-tool-v2-chevron"] {
display: block;
flex-shrink: 0;
width: 16px;
height: 16px;
transition: transform 0.15s ease-out;
}
&[data-expanded] [data-slot="basic-tool-v2-chevron"] {
transform: rotate(90deg);
}
[data-slot="basic-tool-v2-content"] {
box-sizing: border-box;
min-width: 0;
width: 100%;
overflow: hidden;
}
[data-slot="basic-tool-v2-content-inner"] {
font-size: 15px;
font-weight: 440;
line-height: 24px;
letter-spacing: 0;
color: var(--bt-content);
font-variation-settings: "slnt" 0;
}
}

View File

@@ -0,0 +1,135 @@
import { createSignal } from "solid-js"
import { BasicToolV2 } from "./basic-tool-v2"
const docs = `### Overview
Compact collapsible tool row showing title, subtitle, args, and diff changes, with an expand/collapse chevron.
### API
- \`BasicToolV2\` wraps Kobalte \`Collapsible\`. Pass \`open\`, \`defaultOpen\`, and \`onOpenChange\` for controlled/uncontrolled disclosure.
- \`trigger\` accepts either a \`BasicToolV2TriggerTitle\` object (title, subtitle, args, changes) or arbitrary JSX.
- When \`status\` is \`"pending"\` or \`"running"\`, subtitle/args/chevron hide and the title shows a shimmer animation.
- Pass \`children\` for expandable detail content.
### Theming
- Uses \`data-component="basic-tool-v2"\` and slot attributes; colors via \`--bt-*\` CSS variables.
`
export default {
title: "UI V2/BasicTool",
id: "components-basic-tool-v2",
component: BasicToolV2,
tags: ["autodocs"],
parameters: {
frameBackground: "#fff",
layout: "padded",
docs: {
description: {
component: docs,
},
},
},
}
export const Default = {
render: () => (
<BasicToolV2
trigger={{
title: "Read",
subtitle: "src/index.ts",
args: ["lines=1-50"],
changes: { additions: 12, deletions: 3 },
}}
defaultOpen={false}
>
File content appears here.
</BasicToolV2>
),
}
export const Expanded = {
render: () => (
<BasicToolV2
trigger={{
title: "Read",
subtitle: "src/index.ts",
args: ["lines=1-50"],
changes: { additions: 12, deletions: 3 },
}}
defaultOpen={true}
>
File content appears here.
</BasicToolV2>
),
}
export const Pending = {
render: () => (
<BasicToolV2
trigger={{
title: "Read",
subtitle: "src/index.ts",
args: ["lines=1-50"],
changes: { additions: 12, deletions: 3 },
}}
status="pending"
/>
),
}
export const NoChildren = {
render: () => (
<BasicToolV2
trigger={{
title: "Grep",
subtitle: "pattern=TODO",
args: ["recursive=true"],
}}
/>
),
}
export const CustomTrigger = {
render: () => (
<BasicToolV2
trigger={<span style={{ color: "#161616", "font-size": "13px", "font-weight": "440" }}>Custom trigger content</span>}
>
Expandable detail for custom trigger.
</BasicToolV2>
),
}
export const Controlled = {
render: () => {
const [open, setOpen] = createSignal(false)
return (
<div style={{ display: "flex", "flex-direction": "column", gap: "16px", "max-width": "420px" }}>
<button
type="button"
onClick={() => setOpen((o) => !o)}
style={{
padding: "4px 10px",
"font-size": "12px",
"border-radius": "6px",
border: "1px solid rgba(0,0,0,0.15)",
background: "#fff",
color: "#161616",
cursor: "pointer",
}}
>
Toggle from outside: {open() ? "Open" : "Closed"}
</button>
<BasicToolV2
trigger={{
title: "Write",
subtitle: "src/utils.ts",
changes: { additions: 8, deletions: 2 },
}}
open={open()}
onOpenChange={setOpen}
>
Controlled content.
</BasicToolV2>
</div>
)
},
}

View File

@@ -0,0 +1,160 @@
import { Collapsible } from "@kobalte/core/collapsible"
import {
type ComponentProps,
type JSX,
For,
Show,
createMemo,
splitProps,
} from "solid-js"
import { DiffChanges } from "./diff-changes-v2"
import { TextShimmerV2 } from "./text-shimmer-v2"
import "./basic-tool-v2.css"
function ChevronIcon() {
return (
<svg
data-slot="basic-tool-v2-chevron"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M6.75194 10.6243C6.41861 10.8187 6 10.5783 6 10.1924V5.80837C6 5.42247 6.41861 5.18204 6.75194 5.37648L10.5096 7.56846C10.8404 7.7614 10.8404 8.2393 10.5096 8.43224L6.75194 10.6243Z"
fill="currentColor"
/>
</svg>
)
}
export interface BasicToolV2TriggerTitle {
title: string
subtitle?: string
args?: string[]
changes?: { additions: number; deletions: number } | { additions: number; deletions: number }[]
action?: JSX.Element
}
const isTriggerTitle = (val: unknown): val is BasicToolV2TriggerTitle =>
typeof val === "object" &&
val !== null &&
"title" in val &&
(typeof Node === "undefined" || !(val instanceof Node))
export interface BasicToolV2Props extends Omit<ComponentProps<"div">, "children" | "title"> {
trigger: BasicToolV2TriggerTitle | JSX.Element
children?: JSX.Element
status?: string
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
onSubtitleClick?: () => void
}
export function BasicToolV2(props: BasicToolV2Props) {
const [local, rest] = splitProps(props, [
"trigger",
"children",
"status",
"open",
"defaultOpen",
"onOpenChange",
"onSubtitleClick",
"class",
"classList",
])
const pending = createMemo(() => local.status === "pending" || local.status === "running")
const hasChildren = createMemo(() => {
const c = local.children
if (c == null) return false
return true
})
const canExpand = createMemo(() => hasChildren() && !pending())
const handleOpenChange = (value: boolean) => {
if (pending()) return
local.onOpenChange?.(value)
}
return (
<Collapsible
{...rest}
data-component="basic-tool-v2"
open={local.open}
defaultOpen={local.defaultOpen}
onOpenChange={handleOpenChange}
disabled={!canExpand()}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Collapsible.Trigger
as="div"
role="button"
data-slot="basic-tool-v2-trigger"
>
<div data-slot="basic-tool-v2-labels">
<Show
when={isTriggerTitle(local.trigger) && local.trigger}
fallback={local.trigger as JSX.Element}
>
{(title) => (
<>
<span data-slot="basic-tool-v2-title">
<TextShimmerV2 text={title().title} active={pending()} />
</span>
<Show when={!pending() && title().subtitle}>
<span data-slot="basic-tool-v2-sep" aria-hidden="true">·</span>
<span
data-slot="basic-tool-v2-subtitle"
style={local.onSubtitleClick ? { cursor: "pointer" } : undefined}
onClick={(e) => {
if (local.onSubtitleClick) {
e.stopPropagation()
local.onSubtitleClick()
}
}}
>
{title().subtitle}
</span>
</Show>
<Show when={!pending() && title().args?.length}>
<For each={title().args}>
{(arg) => (
<span data-slot="basic-tool-v2-arg">{arg}</span>
)}
</For>
</Show>
<Show when={!pending() && title().changes}>
<span data-slot="basic-tool-v2-diff">
<DiffChanges changes={title().changes!} />
</span>
</Show>
<Show when={!pending() && title().action}>
{(action) => action()}
</Show>
</>
)}
</Show>
<Show when={canExpand()}>
<span data-slot="basic-tool-v2-chevron-wrap">
<ChevronIcon />
</span>
</Show>
</div>
</Collapsible.Trigger>
<Show when={canExpand()}>
<Collapsible.Content data-slot="basic-tool-v2-content">
<div data-slot="basic-tool-v2-content-inner">{local.children}</div>
</Collapsible.Content>
</Show>
</Collapsible>
)
}

View File

@@ -0,0 +1,139 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="button-v2"] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border-radius: 6px;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 1;
color: var(--text-text-base);
text-shadow: none;
font-variant-numeric: tabular-nums;
letter-spacing: -0.04px;
user-select: none;
cursor: pointer;
outline: none;
}
[data-component="button-v2"]:focus {
outline: none;
}
[data-component="button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
outline: 2px solid var(--border-border-focus);
outline-offset: 2.5px;
}
[data-component="button-v2"][data-size="normal"] {
height: 28px;
padding: 0 11px;
}
[data-component="button-v2"][data-size="small"] {
height: 24px;
padding: 0 9px;
border-radius: 4px;
}
[data-component="button-v2"][data-size="large"] {
height: 32px;
padding: 0 15px;
}
[data-component="button-v2"][data-icon][data-size="small"] {
padding-left: 9px;
}
[data-component="button-v2"][data-icon][data-size="normal"] {
padding-left: 11px;
}
[data-component="button-v2"][data-icon][data-size="large"] {
padding-left: 15px;
}
[data-component="button-v2"] [data-slot="icon-svg"] {
color: currentColor;
}
/* Neutral */
[data-component="button-v2"][data-variant="neutral"] {
background-color: var(--background-bg-button-neutral);
color: var(--text-text-base);
box-shadow: var(--elevation-button-neutral);
}
[data-component="button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--overlay-simple-overlay-hover) 0%, var(--overlay-simple-overlay-hover) 100%),
linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--overlay-simple-overlay-pressed) 0%, var(--overlay-simple-overlay-pressed) 100%),
linear-gradient(90deg, var(--background-bg-button-neutral) 0%, var(--background-bg-button-neutral) 100%);
}
[data-component="button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}
/* Contrast */
[data-component="button-v2"][data-variant="contrast"] {
background-image:
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
color: var(--text-text-contrast);
text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
box-shadow: var(--elevation-button-contrast);
}
[data-component="button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--overlay-simple-overlay-contrast-hover) 0%, var(--overlay-simple-overlay-contrast-hover) 100%),
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
}
[data-component="button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--overlay-simple-overlay-contrast-pressed) 0%, var(--overlay-simple-overlay-contrast-pressed) 100%),
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
linear-gradient(90deg, var(--background-bg-contrast) 0%, var(--background-bg-contrast) 100%);
}
[data-component="button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.4;
cursor: not-allowed;
}
/* Ghost */
[data-component="button-v2"][data-variant="ghost"] {
background-color: transparent;
color: var(--text-text-base);
}
[data-component="button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-hover);
}
[data-component="button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-pressed);
}
[data-component="button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,166 @@
import { ButtonV2 } from "./button-v2";
const docs = `### Overview
Button v2 with three visual variants and two sizes.
### API
- \`variant\`: "neutral" | "contrast" | "ghost".
- \`size\`: "normal" | "large".
- \`icon\`: Optional icon name.
- Inherits Kobalte Button props and native button attributes.
### States
- default, hover, pressed, focus, disabled.
- State selectors are available via pseudo-classes and \`[data-state]\`.
`;
export default {
title: "UI V2/Button",
id: "components-button-v2",
component: ButtonV2,
tags: ["autodocs"],
parameters: {
frameHeight: "240px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
children: "Button",
variant: "neutral",
size: "normal",
},
argTypes: {
icon: {
control: "text",
},
variant: {
control: "select",
options: ["neutral", "contrast", "ghost"],
},
size: {
control: "select",
options: ["normal", "large"],
},
},
};
export const Playground = {};
export const Variants = {
render: () => (
<div
style={{
display: "flex",
gap: "12px",
"align-items": "center",
"flex-wrap": "wrap",
}}
>
<ButtonV2 variant="neutral">Neutral</ButtonV2>
<ButtonV2 variant="contrast">Contrast</ButtonV2>
<ButtonV2 variant="ghost">Ghost</ButtonV2>
</div>
),
};
export const Sizes = {
render: () => (
<div
style={{
display: "flex",
gap: "12px",
"align-items": "center",
"flex-wrap": "wrap",
}}
>
<ButtonV2 size="small" variant="neutral">
Small
</ButtonV2>
<ButtonV2 size="normal" variant="neutral">
Normal
</ButtonV2>
<ButtonV2 size="large" variant="neutral">
Large
</ButtonV2>
</div>
),
};
export const Icon = {
render: () => (
<div
style={{
display: "flex",
gap: "12px",
"align-items": "center",
"flex-wrap": "wrap",
}}
>
<ButtonV2
variant="neutral"
size="normal"
icon="plus"
>
Normal
</ButtonV2>
<ButtonV2
variant="contrast"
size="large"
icon="plus"
>
Large
</ButtonV2>
</div>
),
};
export const AllStates = {
render: () => {
const variants = [
"neutral",
"contrast",
"ghost",
] as const;
const states = [
"default",
"hover",
"pressed",
"focus",
"disabled",
] as const;
const toTitleCase = (value: string) =>
value.charAt(0).toUpperCase() + value.slice(1);
return (
<div style={{ display: "grid", gap: "12px" }}>
{variants.map((variant) => (
<div style={{ display: "grid", gap: "8px" }}>
<div
style={{
"font-size": "12px",
color: "var(--text-weak)",
"text-transform": "capitalize",
}}
>
{variant}
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
{states.map((state) => (
<ButtonV2
variant={variant}
data-state={state === "default" ? undefined : state}
disabled={state === "disabled"}
>
{toTitleCase(state)}
</ButtonV2>
))}
</div>
</div>
))}
</div>
);
},
};

View File

@@ -0,0 +1,35 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, Show, createMemo, splitProps } from "solid-js"
import { Icon, type IconProps } from "./icon"
import "./button-v2.css"
export interface ButtonV2Props
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "small" | "normal" | "large"
variant?: "neutral" | "contrast" | "ghost"
icon?: IconProps["name"]
}
export function ButtonV2(props: ButtonV2Props) {
const [split, rest] = splitProps(props, ["variant", "size", "icon", "class", "classList"])
const resolvedIcon = createMemo(() => split.icon)
return (
<Kobalte
{...rest}
data-component="button-v2"
data-size={split.size || "normal"}
data-variant={split.variant || "neutral"}
data-icon={resolvedIcon()}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
<Show when={resolvedIcon()}>
<Icon name={resolvedIcon()!} size="small" />
</Show>
{props.children}
</Kobalte>
)
}

View File

@@ -0,0 +1,187 @@
[data-slot="checkbox-v2"] {
display: flex;
flex-direction: column;
gap: 6px;
cursor: default;
&:where([data-disabled]) {
cursor: not-allowed;
}
[data-slot="checkbox-v2-error"] {
color: var(--state-fg-danger);
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="checkbox-v2-error"]:empty {
display: none;
}
}
[data-slot="checkbox-v2-row"] {
position: relative;
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0px;
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;
}
[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;
width: 16px;
height: 16px;
flex: none;
flex-shrink: 0;
left: 0;
top: calc(50% - 16px / 2);
border-radius: 4px;
border: none;
box-shadow: inset 0 0 0 0.5px var(--border-border-strong);
background:
linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
transition:
background 170ms ease-out,
opacity 170ms ease-out,
outline-color 170ms ease-out;
}
[data-slot="checkbox-v2-indicator"] {
position: absolute;
inset: 0;
width: 16px;
height: 16px;
pointer-events: none;
}
[data-slot="checkbox-v2-text"] {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 0px;
gap: 6px;
}
[data-slot="checkbox-v2-label"] {
display: inline-flex;
user-select: none;
color: inherit;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
}
[data-slot="checkbox-v2-label-text"] {
display: inline-flex;
align-items: center;
user-select: none;
color: var(--text-text-base);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
}
[data-slot="checkbox-v2-description"] {
color: var(--text-text-muted);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
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(--border-border-focus);
outline-offset: 1px;
}
[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"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-control"] {
opacity: 0.5;
}
[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]) [data-slot="checkbox-v2-control"] {
background:
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
var(--background-bg-accent);
}
[data-slot="checkbox-v2"]:where([data-checked], [data-indeterminate]):where(:hover):where(
:not([data-disabled], [data-readonly])
)
[data-slot="checkbox-v2-control"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-contrast-hover), var(--overlay-simple-overlay-contrast-hover)),
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
var(--background-bg-accent);
}
[data-slot="checkbox-v2"]:where([data-invalid]):where(:not([data-checked], [data-indeterminate]))
[data-slot="checkbox-v2-control"] {
background: var(--state-bg-danger);
box-shadow: inset 0 0 0 0.5px #b82d35;
}
[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 {
display: block;
}
[data-slot="checkbox-v2"]:where([data-indeterminate]) .checkbox-v2-icon--minus {
display: block;
}
[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-label"],
[data-slot="checkbox-v2"]:where([data-disabled]) [data-slot="checkbox-v2-description"] {
opacity: 0.5;
}

View File

@@ -0,0 +1,98 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { CheckboxV2 } from "./checkbox-v2"
const docs = `### Overview
Binary and tri-state checkbox using Kobalte Checkbox.
### API
- Forwards Kobalte Checkbox props (\`checked\`, \`defaultChecked\`, \`onChange\`, \`indeterminate\`, \`name\`, \`required\`, \`validationState\`, \`disabled\`, etc.).
- Adds \`label\`, optional \`description\`, and \`hideLabel\`.
### Behavior
- Controlled or uncontrolled via \`checked\` / \`defaultChecked\`.
- Indeterminate is driven by the \`indeterminate\` prop (pass a reactive boolean, e.g. \`indeterminate={flag()}\`).
### Theming/tokens
- Uses \`data-slot="checkbox-v2"\` and slot attributes aligned with radio item layout.
`
export default {
title: "UI V2/Checkbox",
id: "components-checkbox-v2",
component: CheckboxV2,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<CheckboxV2 defaultChecked={false} name="terms" label="Accept terms" description="You must accept to continue." />
),
}
export const Controlled = {
render: () => {
const [checked, setChecked] = createSignal(false)
return (
<div style={{ display: "grid", gap: "12px" }}>
<CheckboxV2
name="controlled"
checked={checked()}
onChange={setChecked}
label="Controlled checkbox"
description="Toggled from Storybook state."
/>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
Checked: {String(checked())}
</div>
</div>
)
},
}
export const Indeterminate = {
render: () => {
const [indeterminate, setIndeterminate] = createSignal(true)
const [checked, setChecked] = createSignal(false)
return (
<CheckboxV2
name="indeterminate-demo"
checked={checked()}
indeterminate={indeterminate()}
onChange={(v) => {
setChecked(v)
if (v) setIndeterminate(false)
}}
label="Select all"
description="Starts indeterminate; checking clears mixed state."
/>
)
},
}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "20px" }}>
<CheckboxV2 name="s1" label="Default" description="Helper text." />
<CheckboxV2 name="s2" defaultChecked label="Checked" />
<CheckboxV2 name="s3" indeterminate label="Indeterminate" />
<CheckboxV2 name="s4" disabled label="Disabled" />
<CheckboxV2 name="s5" disabled defaultChecked label="Checked disabled" />
<CheckboxV2 name="s6" disabled indeterminate label="Indeterminate disabled" />
<CheckboxV2
name="s7"
label="Invalid"
description="Must be checked."
required
validationState="invalid"
/>
</div>
),
}

View File

@@ -0,0 +1,67 @@
import { Checkbox as Kobalte } from "@kobalte/core/checkbox"
import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
import "./checkbox-v2.css"
export interface CheckboxV2Props extends ComponentProps<typeof Kobalte> {
label: JSX.Element
description?: JSX.Element
hideLabel?: boolean
}
export function CheckboxV2(props: CheckboxV2Props) {
const [local, others] = splitProps(props, ["class", "classList", "label", "description", "hideLabel"])
return (
<Kobalte
{...others}
data-slot="checkbox-v2"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="checkbox-v2-row">
<Kobalte.Input data-slot="checkbox-v2-input" />
<div data-slot="checkbox-v2-control-stack">
<Kobalte.Control data-slot="checkbox-v2-control">
<Kobalte.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"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M3.53564 8.17857L6.39279 11.75L12.4642 4.25" stroke="#FAFAFA" stroke-width="1" />
</svg>
<svg
class="checkbox-v2-icon checkbox-v2-icon--minus"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M12.75 8H3.25" stroke="#FAFAFA" stroke-linejoin="round" stroke-width="1" />
</svg>
</Kobalte.Indicator>
</Kobalte.Control>
</div>
<Kobalte.Label data-slot="checkbox-v2-label" classList={{ "sr-only": local.hideLabel }}>
<div data-slot="checkbox-v2-text">
<span data-slot="checkbox-v2-label-text">{local.label}</span>
<Show when={local.description}>
{(description) => (
<span data-slot="checkbox-v2-description">{description()}</span>
)}
</Show>
</div>
</Kobalte.Label>
</div>
<Kobalte.ErrorMessage data-slot="checkbox-v2-error" />
</Kobalte>
)
}

View File

@@ -0,0 +1,150 @@
/* [data-component="dialog-trigger"] { } */
[data-component="dialog-overlay"] {
position: fixed;
inset: 0;
z-index: 50;
background-color: var(--overlay-simple-overlay-scrim);
}
[data-component="dialog"] {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
[data-slot="dialog-container"] {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 480px;
height: 368px;
background: var(--background-bg-layer-01);
box-shadow: var(--elevation-overlay);
border-radius: 6px;
overflow: visible;
pointer-events: auto;
[data-slot="dialog-content"] {
display: flex;
flex-direction: column;
align-items: flex-start;
align-self: stretch;
width: 100%;
max-height: 100%;
flex: 1;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
[data-slot="dialog-header"] {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 16px;
gap: 8px;
flex-shrink: 0;
align-self: stretch;
[data-slot="dialog-title-group"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
flex: 1;
min-width: 0;
}
[data-slot="dialog-title"] {
margin: 0;
font-family: 'Inter', var(--font-family-sans);
font-weight: 530;
font-size: 15px;
line-height: 100%;
letter-spacing: -0.13px;
color: var(--text-text-base);
font-variation-settings: 'slnt' 0;
}
[data-slot="dialog-description"] {
font-family: 'Inter', var(--font-family-sans);
font-weight: 440;
font-size: 13px;
line-height: 100%;
letter-spacing: -0.04px;
color: var(--text-text-muted);
font-variation-settings: 'slnt' 0;
}
[data-slot="dialog-close-button"] {
display: flex;
justify-content: center;
align-items: center;
width: 24px;
height: 24px;
border-radius: 4px;
flex-shrink: 0;
cursor: pointer;
&:hover {
background: var(--overlay-simple-overlay-hover);
}
}
}
[data-slot="dialog-footer"] {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: flex-start;
padding: 16px;
gap: 8px;
align-self: stretch;
flex-shrink: 0;
}
[data-slot="dialog-body"] {
width: 100%;
position: relative;
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
&:focus-visible {
outline: none;
}
}
&:focus-visible {
outline: none;
}
}
}
&[data-fit] {
[data-slot="dialog-container"] {
height: auto;
[data-slot="dialog-content"] {
min-height: 0;
}
}
}
&[data-size="large"] [data-slot="dialog-container"] {
width: 640px;
height: 480px;
}
&[data-size="x-large"] [data-slot="dialog-container"] {
width: 800px;
height: 560px;
}
}

View File

@@ -0,0 +1,170 @@
import { Dialog as KobalteDialog } from "@kobalte/core/dialog"
import { Dialog, DialogFooter } from "./dialog-v2"
import { ButtonV2 } from "./button-v2"
const docs = `### Overview
Dialog content wrapper built on Kobalte's dialog primitive with v2 styling.
### API
- Optional: \`title\`, \`description\`, \`action\`.
- \`size\`: normal | large | x-large.
- \`fit\` and \`transition\` control layout and animation.
### Variants and states
- Sizes and optional header/action controls.
### Accessibility
- Focus trapping and aria attributes provided by Kobalte Dialog.
### Theming/tokens
- Uses \`data-component="dialog"\` and slot attributes.
`
export default {
title: "UI V2/Dialog",
id: "components-dialog-v2",
component: Dialog,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<KobalteDialog defaultOpen>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Dialog" description="Description">
Dialog body content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px" }}>
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Normal
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Normal" description="Normal size">
Normal dialog content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Large
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="large" title="Large" description="Large size">
Large dialog content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
X-Large
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog size="x-large" title="Extra large" description="X-large size">
X-large dialog content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
</div>
),
}
export const CustomAction = {
render: () => (
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open action dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog
title="Custom action"
description="Dialog with a custom header action"
action={<ButtonV2 variant="neutral" size="small">Help</ButtonV2>}
>
Dialog body content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const WithFooter = {
render: () => (
<KobalteDialog defaultOpen>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Save changes" description="Your changes will be lost if you don't save them." fit>
<DialogFooter>
<ButtonV2 variant="neutral">Cancel</ButtonV2>
<ButtonV2 variant="contrast">Save</ButtonV2>
</DialogFooter>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const WithFooterThreeButtons = {
render: () => (
<KobalteDialog defaultOpen>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Unsaved changes" description="You have unsaved changes. What would you like to do?" fit>
<DialogFooter>
<span style={{ "margin-right": "auto" }}>
<ButtonV2 variant="ghost">Remind me later</ButtonV2>
</span>
<ButtonV2 variant="neutral">Cancel</ButtonV2>
<ButtonV2 variant="contrast">Save</ButtonV2>
</DialogFooter>
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}
export const Fit = {
render: () => (
<KobalteDialog>
<KobalteDialog.Trigger as={ButtonV2} variant="neutral">
Open fit dialog
</KobalteDialog.Trigger>
<KobalteDialog.Portal>
<KobalteDialog.Overlay />
<Dialog title="Fit content" fit>
Dialog fits its content.
</Dialog>
</KobalteDialog.Portal>
</KobalteDialog>
),
}

View File

@@ -0,0 +1,83 @@
import { Dialog as Kobalte } from "@kobalte/core/dialog"
import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js"
import "./dialog-v2.css"
export interface DialogProps extends ParentProps {
title?: JSXElement
description?: JSXElement
action?: JSXElement
size?: "normal" | "large" | "x-large"
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
fit?: boolean
}
export function DialogFooter(props: ParentProps) {
return <div data-slot="dialog-footer">{props.children}</div>
}
export function Dialog(props: DialogProps) {
const [local] = splitProps(props, [
"title",
"description",
"action",
"size",
"class",
"classList",
"fit",
"children",
])
const title = children(() => local.title)
const description = children(() => local.description)
const action = children(() => local.action)
const hasHeader = () => title() || action()
return (
<div
data-component="dialog"
data-fit={local.fit ? true : undefined}
data-size={local.size || "normal"}
>
<div data-slot="dialog-container">
<Kobalte.Content
data-slot="dialog-content"
data-no-header={!hasHeader() ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
onOpenAutoFocus={(e) => {
const target = e.currentTarget as HTMLElement | null
const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
if (autofocusEl) {
e.preventDefault()
autofocusEl.focus()
}
}}
>
<Show when={hasHeader()}>
<div data-slot="dialog-header">
<div data-slot="dialog-title-group">
<Show when={title()}>
{(t) => <Kobalte.Title data-slot="dialog-title">{t()}</Kobalte.Title>}
</Show>
<Show when={description()}>
{(d) => (
<Kobalte.Description data-slot="dialog-description">{d()}</Kobalte.Description>
)}
</Show>
</div>
<Show when={action()}>{(a) => a()}</Show>
<Kobalte.CloseButton data-slot="dialog-close-button" aria-label="Close">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M12.4446 3.55469L3.55566 12.4436M3.55566 3.55469L12.4446 12.4436" stroke="#808080" stroke-linejoin="round" />
</svg>
</Kobalte.CloseButton>
</div>
</Show>
<div data-slot="dialog-body">{local.children}</div>
</Kobalte.Content>
</div>
</div>
)
}

View File

@@ -0,0 +1,25 @@
[data-component="diff-changes"] {
display: flex;
gap: 8px;
justify-content: flex-end;
align-items: center;
[data-slot="diff-changes-additions"],
[data-slot="diff-changes-deletions"] {
font-family: var(--font-family-sans);
font-size: 11px;
font-style: normal;
font-weight: 440;
line-height: 1;
letter-spacing: 0.05px;
text-align: right;
}
[data-slot="diff-changes-additions"] {
color: var(--state-fg-success);
}
[data-slot="diff-changes-deletions"] {
color: var(--state-fg-danger);
}
}

View File

@@ -0,0 +1,60 @@
import { DiffChanges } from "./diff-changes-v2"
const docs = `### Overview
Summarize additions/deletions as compact text.
Pair with \`Diff\`/\`DiffSSR\` to contextualize a change set.
### API
- Required: \`changes\` as { additions, deletions } or an array of those objects.
### Variants and states
- Handles zero-change state (renders nothing).
### Behavior
- Aggregates arrays into total additions/deletions.
### Accessibility
- Ensure surrounding context conveys meaning of the counts/bars.
### Theming/tokens
- Uses \`data-component="diff-changes"\` and diff color tokens.
`
const changes = { additions: 12, deletions: 5 }
export default {
title: "UI V2/DiffChanges",
id: "components-diff-changes-v2",
component: DiffChanges,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
changes,
},
}
export const Default = {}
export const MultipleFiles = {
args: {
changes: [
{ additions: 4, deletions: 1 },
{ additions: 8, deletions: 3 },
{ additions: 2, deletions: 0 },
],
},
}
export const Zero = {
args: {
changes: { additions: 0, deletions: 0 },
},
}

View File

@@ -0,0 +1,28 @@
import { createMemo, Show } from "solid-js"
import "./diff-changes-v2.css"
export function DiffChanges(props: {
class?: string
changes: { additions: number; deletions: number } | { additions: number; deletions: number }[]
}) {
const additions = createMemo(() =>
Array.isArray(props.changes)
? props.changes.reduce((acc, diff) => acc + (diff.additions ?? 0), 0)
: props.changes.additions,
)
const deletions = createMemo(() =>
Array.isArray(props.changes)
? props.changes.reduce((acc, diff) => acc + (diff.deletions ?? 0), 0)
: props.changes.deletions,
)
const total = createMemo(() => (additions() ?? 0) + (deletions() ?? 0))
return (
<Show when={total() > 0}>
<div data-component="diff-changes" classList={{ [props.class ?? ""]: true }}>
<span data-slot="diff-changes-additions">{`+${additions()}`}</span>
<span data-slot="diff-changes-deletions">{`-${deletions()}`}</span>
</div>
</Show>
)
}

View File

@@ -0,0 +1,96 @@
[data-component="field-v2"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="field-v2"] [data-slot="field-v2-label"] {
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
gap: 4px;
min-height: 16px;
margin: 0;
padding: 0;
border: 0;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
cursor: default;
user-select: none;
}
[data-component="field-v2"] [data-slot="field-v2-label-text"] {
display: inline-flex;
align-items: center;
}
[data-component="field-v2"] [data-slot="field-v2-label"] [data-component="tooltip-v2-trigger"] {
display: inline-flex;
flex: none;
width: 16px;
height: 16px;
}
[data-component="field-v2"] [data-slot="field-v2-label-info"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
height: 16px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--icon-icon-muted);
cursor: pointer;
outline: none;
}
[data-component="field-v2"] [data-slot="field-v2-label-info"]:is(:hover, [data-state="hover"]) {
color: var(--text-text-base);
}
[data-component="field-v2"] [data-slot="field-v2-label-info"]:focus {
outline: none;
}
[data-component="field-v2"] [data-slot="field-v2-label-info"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
border-radius: 2px;
}
[data-component="field-v2"] [data-slot="field-v2-prefix"],
[data-component="field-v2"] [data-slot="field-v2-suffix"] {
align-self: stretch;
width: 100%;
min-height: 11px;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 11px;
line-height: 1;
letter-spacing: 0.05px;
color: var(--text-text-muted);
font-variation-settings: "slnt" 0;
user-select: none;
}
[data-component="field-v2"] [data-slot="field-v2-control"] {
display: flex;
flex-direction: column;
align-items: flex-start;
width: 100%;
min-width: 0;
}

View File

@@ -0,0 +1,140 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { Field } from "./field-v2"
import { InlineInputV2 } from "./inline-input-v2"
import { TextInputV2 } from "./text-input-v2"
import { TextareaV2 } from "./textarea-v2"
const docs = `### Overview
Composable field layout for TextInput, Textarea, and InlineInput v2.
### Usage
\`\`\`tsx
<Field invalid>
<Field.Label tooltip="Helper">Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<Field.Control>
<TextInputV2 placeholder="Text" />
</Field.Control>
<Field.Suffix>Suffix</Field.Suffix>
</Field>
\`\`\`
Omit \`Field.Control\` and place the input directly inside \`Field\` — a11y props are merged automatically.
### API
- \`Field\`: \`invalid\` propagates to the control.
- \`Field.Label\`: \`tooltip\` shows the info icon with tooltip text.
- \`Field.Prefix\` / \`Field.Suffix\`: helper copy above / below the control.
- \`Field.Control\`: optional wrapper (marker only).
`
export default {
title: "UI V2/Field",
id: "components-field-v2",
subcomponents: {
Label: Field.Label,
Prefix: Field.Prefix,
Suffix: Field.Suffix,
Control: Field.Control,
},
tags: ["autodocs"],
parameters: {
frameHeight: "500px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
}
export const TextInputExample = {
render: () => (
<div style={{ width: "280px" }}>
<Field>
<Field.Label tooltip="Additional context">Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<Field.Control>
<TextInputV2 placeholder="Text" showCopyButton />
</Field.Control>
<Field.Suffix>Suffix</Field.Suffix>
</Field>
</div>
),
}
export const TextInputDirectChild = {
render: () => (
<div style={{ width: "280px" }}>
<Field>
<Field.Label>Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<TextInputV2 placeholder="Text" />
<Field.Suffix>Suffix</Field.Suffix>
</Field>
</div>
),
}
export const TextareaExample = {
render: () => (
<div style={{ width: "280px" }}>
<Field>
<Field.Label>Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<TextareaV2 placeholder="Text" />
<Field.Suffix>Suffix</Field.Suffix>
</Field>
</div>
),
}
export const InlineInputExample = {
render: () => (
<div style={{ width: "280px" }}>
<Field>
<Field.Label>Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<InlineInputV2 prefix="USD" placeholder="0.00" numeric showCopyButton />
<Field.Suffix>Suffix</Field.Suffix>
</Field>
</div>
),
}
export const Invalid = {
render: () => (
<div style={{ width: "280px" }}>
<Field invalid>
<Field.Label>Label</Field.Label>
<Field.Prefix>Prefix</Field.Prefix>
<TextInputV2 placeholder="Text" defaultValue="Invalid" showCopyButton />
<Field.Suffix>Suffix</Field.Suffix>
</Field>
</div>
),
}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("")
return (
<div style={{ width: "280px" }}>
<Field>
<Field.Label>Amount</Field.Label>
<Field.Control>
<TextInputV2
placeholder="0.00"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
numeric
/>
</Field.Control>
<Field.Suffix>{value() ? `Entered: ${value()}` : "Suffix"}</Field.Suffix>
</Field>
</div>
)
},
}

View File

@@ -0,0 +1,275 @@
import {
createContext,
createEffect,
createSignal,
createUniqueId,
onCleanup,
onMount,
splitProps,
useContext,
Show,
type ComponentProps,
type ParentProps,
} from "solid-js"
import { TooltipV2 } from "./tooltip-v2"
import "./field-v2.css"
type FieldContextValue = {
controlId: string
labelId: string
prefixId: string
suffixId: string
invalid: () => boolean
registerPrefix: () => void
unregisterPrefix: () => void
registerSuffix: () => void
unregisterSuffix: () => void
getDescribedBy: () => string | undefined
}
const FieldContext = createContext<FieldContextValue>()
function useField() {
const ctx = useContext(FieldContext)
if (!ctx) {
throw new Error("Field subcomponents must be used within <Field>")
}
return ctx
}
const CONTROL_SELECTOR = [
"[data-slot='text-input-v2-input']",
"[data-slot='textarea-v2-textarea']",
"[data-slot='inline-input-v2-input']",
].join(", ")
export interface FieldV2Props extends ComponentProps<"div"> {
invalid?: boolean
}
function FieldV2Root(props: ParentProps<FieldV2Props>) {
const [local, rest] = splitProps(props, ["invalid", "class", "classList", "children"])
const controlId = `field-control-${createUniqueId()}`
const labelId = `field-label-${createUniqueId()}`
const prefixId = `field-prefix-${createUniqueId()}`
const suffixId = `field-suffix-${createUniqueId()}`
const [prefixCount, setPrefixCount] = createSignal(0)
const [suffixCount, setSuffixCount] = createSignal(0)
let rootRef: HTMLDivElement | undefined
const ctx: FieldContextValue = {
controlId,
labelId,
prefixId,
suffixId,
invalid: () => !!local.invalid,
registerPrefix: () => setPrefixCount((n) => n + 1),
unregisterPrefix: () => setPrefixCount((n) => Math.max(0, n - 1)),
registerSuffix: () => setSuffixCount((n) => n + 1),
unregisterSuffix: () => setSuffixCount((n) => Math.max(0, n - 1)),
getDescribedBy: () => {
const ids: string[] = []
if (prefixCount() > 0) ids.push(prefixId)
if (suffixCount() > 0) ids.push(suffixId)
return ids.length > 0 ? ids.join(" ") : undefined
},
}
const syncControlA11y = () => {
const root = rootRef
if (!root) return
const control = root.querySelector(CONTROL_SELECTOR) as
| HTMLInputElement
| HTMLTextAreaElement
| null
if (!control) return
const shell = control.closest(
"[data-component='text-input-v2'], [data-component='textarea-v2'], [data-component='inline-input-v2']",
) as HTMLElement | null
control.id = controlId
control.setAttribute("aria-labelledby", labelId)
const describedBy = ctx.getDescribedBy()
if (describedBy) {
control.setAttribute("aria-describedby", describedBy)
} else {
control.removeAttribute("aria-describedby")
}
if (ctx.invalid()) {
control.setAttribute("aria-invalid", "true")
shell?.setAttribute("data-invalid", "")
} else {
control.removeAttribute("aria-invalid")
shell?.removeAttribute("data-invalid")
}
}
onMount(() => {
syncControlA11y()
})
createEffect(() => {
prefixCount()
suffixCount()
local.invalid
syncControlA11y()
})
return (
<FieldContext.Provider value={ctx}>
<div
{...rest}
ref={rootRef}
data-component="field-v2"
data-invalid={local.invalid ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</div>
</FieldContext.Provider>
)
}
function FieldLabelInfoIcon() {
return (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13 13H3V3H13V13ZM6.46777 6.81641V7.81641H7.5791V11.3721H8.5791V6.81641H6.46777ZM7.30078 4.62891V5.62891H8.85645V4.62891H7.30078Z"
fill="currentColor"
/>
</svg>
)
}
export interface FieldLabelProps extends ComponentProps<"label"> {
/** When set, shows the info icon with a tooltip containing this text. */
tooltip?: string
}
function FieldLabel(props: ParentProps<FieldLabelProps>) {
const [local, rest] = splitProps(props, ["class", "classList", "children", "tooltip"])
const field = useField()
return (
<label
{...rest}
id={field.labelId}
for={field.controlId}
data-slot="field-v2-label"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<span data-slot="field-v2-label-text">{local.children}</span>
<Show when={local.tooltip}>
{(tooltip) => (
<TooltipV2 value={tooltip()}>
<button
type="button"
data-slot="field-v2-label-info"
aria-label={tooltip()}
onClick={(e) => e.stopPropagation()}
>
<FieldLabelInfoIcon />
</button>
</TooltipV2>
)}
</Show>
</label>
)
}
function FieldPrefix(props: ParentProps<ComponentProps<"div">>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
const field = useField()
onMount(() => {
field.registerPrefix()
onCleanup(() => field.unregisterPrefix())
})
return (
<div
{...rest}
id={field.prefixId}
data-slot="field-v2-prefix"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</div>
)
}
function FieldSuffix(props: ParentProps<ComponentProps<"div">>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
const field = useField()
onMount(() => {
field.registerSuffix()
onCleanup(() => field.unregisterSuffix())
})
return (
<div
{...rest}
id={field.suffixId}
data-slot="field-v2-suffix"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</div>
)
}
/** Optional layout wrapper around the control. */
function FieldControl(props: ParentProps<ComponentProps<"div">>) {
const [local, rest] = splitProps(props, ["class", "classList", "children"])
return (
<div
{...rest}
data-slot="field-v2-control"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</div>
)
}
export const FieldV2 = Object.assign(FieldV2Root, {
Label: FieldLabel,
Prefix: FieldPrefix,
Suffix: FieldSuffix,
Control: FieldControl,
})
export const Field = FieldV2

View File

@@ -0,0 +1,146 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="icon-button-v2"] {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
color: var(--v2-text-text-base);
user-select: none;
outline: none;
padding: 0;
cursor: default;
}
[data-component="icon-button-v2"]:focus {
outline: none;
}
[data-component="icon-button-v2"]:is(:focus-visible, [data-state="focus"]):not(:disabled) {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2.5px;
}
[data-component="icon-button-v2"][data-size="small"] {
width: 20px;
height: 20px;
border-radius: 4px;
}
[data-component="icon-button-v2"][data-size="normal"] {
width: 24px;
height: 24px;
}
[data-component="icon-button-v2"][data-size="large"] {
width: 28px;
height: 28px;
}
[data-component="icon-button-v2"] [data-slot="icon-svg"] {
color: currentColor;
}
/* Neutral */
[data-component="icon-button-v2"][data-variant="neutral"] {
background-color: var(--v2-background-bg-button-neutral);
color: var(--v2-text-text-base);
box-shadow: var(--v2-elevation-button-neutral);
}
[data-component="icon-button-v2"][data-variant="neutral"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-hover) 0%, var(--v2-overlay-simple-overlay-hover) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="icon-button-v2"][data-variant="neutral"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(90deg, var(--v2-overlay-simple-overlay-pressed) 0%, var(--v2-overlay-simple-overlay-pressed) 100%),
linear-gradient(90deg, var(--v2-background-bg-button-neutral) 0%, var(--v2-background-bg-button-neutral) 100%);
}
[data-component="icon-button-v2"][data-variant="neutral"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}
/* Contrast */
[data-component="icon-button-v2"][data-variant="contrast"] {
background-image:
linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
color: var(--v2-text-text-contrast);
text-shadow: 0 0.5px 0.5px rgba(0, 0, 0, 0.3);
box-shadow: var(--v2-elevation-button-contrast);
}
[data-component="icon-button-v2"][data-variant="contrast"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-image:
linear-gradient(
90deg,
var(--v2-overlay-simple-overlay-contrast-hover) 0%,
var(--v2-overlay-simple-overlay-contrast-hover) 100%
),
linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
}
[data-component="icon-button-v2"][data-variant="contrast"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-image:
linear-gradient(
90deg,
var(--v2-overlay-simple-overlay-contrast-pressed) 0%,
var(--v2-overlay-simple-overlay-contrast-pressed) 100%
),
linear-gradient(180deg, var(--v2-alpha-light-20) 0%, var(--v2-alpha-light-0) 100%),
linear-gradient(90deg, var(--v2-background-bg-contrast) 0%, var(--v2-background-bg-contrast) 100%);
}
[data-component="icon-button-v2"][data-variant="contrast"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.4;
cursor: not-allowed;
}
/* Ghost */
[data-component="icon-button-v2"][data-variant="ghost"] {
background-color: transparent;
color: var(--v2-text-text-base);
}
[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="icon-button-v2"][data-variant="ghost"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}
/* Ghost */
[data-component="icon-button-v2"][data-variant="ghost-muted"] {
background-color: transparent;
color: var(--v2-icon-icon-muted);
}
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-hover);
}
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed);
}
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {
opacity: 0.5;
cursor: not-allowed;
}

View File

@@ -0,0 +1,105 @@
import { IconButtonV2 } from "./icon-button-v2"
const docs = `### Overview
Square icon-only button v2 with three visual variants and three sizes.
### API
- \`icon\`: Icon name from the icon component.
- \`variant\`: "neutral" | "contrast" | "ghost".
- \`size\`: "small" | "normal" | "large".
- \`iconSize\`: Optional explicit icon size override.
- Inherits Kobalte Button props and native button attributes.
### States
- default, hover, pressed, focus, disabled.
- State selectors are available via pseudo-classes and \`[data-state]\`.
`
export default {
title: "UI V2/IconButton",
id: "components-icon-button-v2",
component: IconButtonV2,
tags: ["autodocs"],
parameters: {
frameHeight: "300px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
icon: "plus",
variant: "neutral",
size: "normal",
},
argTypes: {
icon: {
control: "text",
},
variant: {
control: "select",
options: ["neutral", "contrast", "ghost"],
},
size: {
control: "select",
options: ["small", "normal", "large"],
},
iconSize: {
control: "select",
options: ["small", "normal", "large"],
},
},
}
export const Playground = {}
export const Variants = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center", "flex-wrap": "wrap" }}>
<IconButtonV2 icon="plus" variant="neutral" />
<IconButtonV2 icon="plus" variant="contrast" />
<IconButtonV2 icon="plus" variant="ghost" />
</div>
),
}
export const Sizes = {
render: () => (
<div style={{ display: "flex", gap: "12px", "align-items": "center", "flex-wrap": "wrap" }}>
<IconButtonV2 icon="plus" size="small" variant="neutral" />
<IconButtonV2 icon="plus" size="normal" variant="neutral" />
<IconButtonV2 icon="plus" size="large" variant="neutral" />
</div>
),
}
export const AllStates = {
render: () => {
const variants = ["neutral", "contrast", "ghost"] as const
const states = ["default", "hover", "pressed", "focus", "disabled"] as const
return (
<div style={{ display: "grid", gap: "12px" }}>
{variants.map((variant) => (
<div style={{ display: "grid", gap: "8px" }}>
<div style={{ "font-size": "12px", color: "var(--text-weak)", "text-transform": "capitalize" }}>
{variant}
</div>
<div style={{ display: "flex", gap: "8px", "flex-wrap": "wrap" }}>
{states.map((state) => (
<IconButtonV2
icon="plus"
variant={variant}
data-state={state === "default" ? undefined : state}
disabled={state === "disabled"}
/>
))}
</div>
</div>
))}
</div>
)
},
}

View File

@@ -0,0 +1,37 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
import { JSX } from "solid-js"
import "./icon-button-v2.css"
export interface IconButtonV2Props
extends ComponentProps<typeof Kobalte>,
Pick<ComponentProps<"button">, "class" | "classList"> {
// temporary
icon?: JSX.Element
// icon: IconProps["name"]
size?: "small" | "normal" | "large"
// iconSize?: IconProps["size"]
variant?: "neutral" | "contrast" | "ghost" | "ghost-muted"
state?: "rest" | "hover" | "pressed"
}
export function IconButtonV2(props: ComponentProps<"button"> & IconButtonV2Props) {
const [split, rest] = splitProps(props, ["variant", "size", "iconSize", "class", "classList", "state"])
return (
<Kobalte
{...rest}
data-component="icon-button-v2"
// data-icon={props.icon}
data-size={split.size || "normal"}
data-variant={split.variant || "neutral"}
data-state={split.state}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{props.icon}
{/*<Icon name={props.icon} size={split.iconSize ?? (split.size === "large" ? "normal" : "small")} />*/}
</Kobalte>
)
}

View File

@@ -0,0 +1,29 @@
import { type ComponentProps, splitProps } from "solid-js"
export interface IconProps extends ComponentProps<"svg"> {
name: string
size?: "small" | "normal" | "large"
}
/**
* Placeholder icon component
*/
export function Icon(props: IconProps) {
const [split, rest] = splitProps(props, ["name", "size"])
const pixelSize = split.size === "small" ? 14 : split.size === "large" ? 20 : 16
return (
<svg
{...rest}
data-slot="icon-svg"
width={pixelSize}
height={pixelSize}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={rest["aria-hidden"] ?? "true"}
>
<path d="M8 2.88867V13.1109" stroke="currentColor" stroke-linejoin="round" />
<path d="M2.88867 8H13.1109" stroke="currentColor" stroke-linejoin="round" />
</svg>
)
}

View File

@@ -0,0 +1,220 @@
[data-component="inline-input-v2"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0;
width: 280px;
height: 28px;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
outline-offset: 0;
background:
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
box-shadow: var(--elevation-button-neutral);
flex: none;
align-self: stretch;
overflow: hidden;
transition:
background 85ms ease-out,
outline-color 85ms ease-out,
box-shadow 85ms ease-out;
}
[data-component="inline-input-v2"][data-appearance="large"] {
height: 32px;
}
[data-component="inline-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
[data-component="inline-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within)
[data-slot="inline-input-v2-prefix"] {
background: transparent;
}
[data-component="inline-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
outline-color: var(--border-border-focus);
box-shadow: none;
}
[data-component="inline-input-v2"]:where([data-invalid]):not([data-disabled]) {
outline-color: var(--state-fg-danger);
box-shadow: none;
}
[data-component="inline-input-v2"]:where([data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-prefix"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
flex: none;
align-self: stretch;
width: fit-content;
min-width: 0;
max-width: 100%;
padding: 4px 8px;
gap: 4px;
background: var(--background-bg-layer-01);
border-radius: 4px 0 0 4px;
transition: background 85ms ease-out;
}
[data-component="inline-input-v2"][data-label-width] [data-slot="inline-input-v2-prefix"] {
width: var(--inline-input-v2-label-width);
min-width: var(--inline-input-v2-label-width);
max-width: var(--inline-input-v2-label-width);
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-prefix-text"] {
display: block;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-muted);
font-variation-settings: "slnt" 0;
}
[data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-prefix-text"] {
font-variant-numeric: tabular-nums;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-divider"] {
flex: none;
align-self: stretch;
width: 0.5px;
min-width: 0.5px;
background: var(--border-border-base);
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-field"] {
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
flex: 1 1 auto;
min-width: 0;
padding: 4px 8px;
gap: 8px;
}
[data-component="inline-input-v2"][data-appearance="large"] [data-slot="inline-input-v2-field"] {
padding-top: 6px;
padding-bottom: 6px;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-value"] {
display: flex;
flex-direction: row;
align-items: center;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-input"] {
display: block;
width: 100%;
min-width: 0;
height: 100%;
padding: 0;
margin: 0;
border: 0;
background: transparent;
outline: none;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-input"]::placeholder {
color: var(--text-text-faint);
}
[data-component="inline-input-v2"][data-numeric] [data-slot="inline-input-v2-input"] {
font-variant-numeric: tabular-nums;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex: none;
width: 20px;
height: 20px;
padding: 2px;
gap: 3px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--icon-icon-muted);
cursor: pointer;
outline: none;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-hover);
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-pressed);
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:focus {
outline: none;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
}
[data-component="inline-input-v2"]:where([data-disabled]) [data-slot="inline-input-v2-icon-button"] {
cursor: not-allowed;
pointer-events: none;
}
[data-component="inline-input-v2"] [data-slot="inline-input-v2-icon-button"] [data-slot="icon-svg"] {
display: block;
flex: none;
color: currentColor;
}
[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-prefix-text"] {
color: var(--text-text-muted);
}
[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-input"] {
color: var(--state-fg-danger);
caret-color: var(--state-fg-danger);
}
[data-component="inline-input-v2"][data-invalid]:not([data-disabled]) [data-slot="inline-input-v2-input"]::placeholder {
color: var(--state-fg-danger);
opacity: 1;
}

View File

@@ -0,0 +1,141 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { Field as FieldV2 } from "./field-v2"
import { InlineInputV2 } from "./inline-input-v2"
const docs = `### Overview
Single-line field with an inline prefix label, vertical divider, and the same states as TextInput v2.
### API
- \`prefix\`: Inline label in the leading segment (required).
- \`labelWidth\`: Fixed prefix width (px number or CSS length). Omit for fit-content.
- Forwards native \`input\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, etc.).
- \`showCopyButton\`, \`copyLabel\`, \`onCopyClick\`: Optional trailing copy control.
- \`invalid\`: Error outline and danger text color.
- \`appearance\`: \`"base"\` (28px) or \`"large"\` (32px).
- \`numeric\`: Tabular numerals on prefix and value.
### States
- **Hover**, **Focus**, **Invalid**, **Disabled** — same as TextInput v2 on the outer shell.
### Field
Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story.
`
export default {
title: "UI V2/InlineInput",
id: "components-inline-input-v2",
component: InlineInputV2,
tags: ["autodocs"],
parameters: {
frameHeight: "400px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
prefix: "Label",
placeholder: "Text",
showCopyButton: true,
disabled: false,
invalid: false,
appearance: "base",
},
argTypes: {
prefix: {
control: "text",
},
labelWidth: {
control: "number",
},
appearance: {
control: "select",
options: ["base", "large"],
},
showCopyButton: {
control: "boolean",
},
disabled: {
control: "boolean",
},
invalid: {
control: "boolean",
},
placeholder: {
control: "text",
},
},
}
export const Playground = {}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("42")
return (
<div style={{ display: "grid", gap: "12px", width: "280px" }}>
<InlineInputV2
prefix="Amount"
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
placeholder="0.00"
numeric
/>
<div
style={{
"font-family": "var(--font-family-sans)",
"font-size": "12px",
color: "var(--text-text-faint)",
}}
>
Value: {value()}
</div>
</div>
)
},
}
export const Appearances = {
render: () => (
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
<InlineInputV2 prefix="Label" appearance="base" placeholder="Text" showCopyButton />
<InlineInputV2 prefix="Label" appearance="large" placeholder="Text" showCopyButton />
<InlineInputV2 prefix="Label" labelWidth={50} placeholder="Text" showCopyButton />
<InlineInputV2 prefix="Long label" placeholder="Text" showCopyButton />
</div>
),
}
export const Field = {
parameters: { frameHeight: "500px" },
render: () => (
<div style={{ display: "grid", gap: "24px", width: "280px" }}>
<FieldV2>
<FieldV2.Label tooltip="Additional context">Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<InlineInputV2 prefix="USD" placeholder="0.00" numeric showCopyButton />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
<FieldV2 invalid>
<FieldV2.Label>Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<InlineInputV2 prefix="USD" placeholder="0.00" defaultValue="Invalid" showCopyButton />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
</div>
),
}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
<InlineInputV2 prefix="Label" placeholder="Text" showCopyButton />
<InlineInputV2 prefix="Label" placeholder="Text" defaultValue="Hello" showCopyButton />
<InlineInputV2 prefix="Label" placeholder="Text" defaultValue="Invalid" invalid showCopyButton />
<InlineInputV2 prefix="Label" placeholder="Text" disabled showCopyButton />
</div>
),
}

View File

@@ -0,0 +1,90 @@
import { type ComponentProps, type JSX, Show, splitProps } from "solid-js"
import { Icon } from "./icon"
import "./inline-input-v2.css"
export interface InlineInputV2Props extends Omit<ComponentProps<"input">, "type" | "prefix"> {
/** Inline label shown before the field (prefix segment). */
prefix: JSX.Element
/** Fixed width for the prefix segment (px number or CSS length). Omit for fit-content. */
labelWidth?: number | string
/** Show the trailing copy action. */
showCopyButton?: boolean
/** Accessible label for the copy button. */
copyLabel?: string
onCopyClick?: (event: MouseEvent) => void
/** Apply tabular numerals to the prefix and field value. */
numeric?: boolean
/** Error styling for the field and value text. */
invalid?: boolean
/** `base` is 28px tall; `large` is 32px tall. */
appearance?: "base" | "large"
type?: ComponentProps<"input">["type"]
}
export function InlineInputV2(props: InlineInputV2Props) {
const [local, inputProps] = splitProps(props, [
"class",
"classList",
"prefix",
"labelWidth",
"showCopyButton",
"copyLabel",
"onCopyClick",
"numeric",
"invalid",
"appearance",
"disabled",
"style",
])
return (
<div
data-component="inline-input-v2"
data-disabled={local.disabled ? "" : undefined}
data-invalid={local.invalid ? "" : undefined}
data-numeric={local.numeric ? "" : undefined}
data-appearance={local.appearance ?? "base"}
data-label-width={local.labelWidth != null ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
style={{
...(typeof local.style === "object" && local.style != null ? local.style : {}),
...(local.labelWidth != null
? {
"--inline-input-v2-label-width":
typeof local.labelWidth === "number" ? `${local.labelWidth}px` : local.labelWidth,
}
: {}),
}}
>
<div data-slot="inline-input-v2-prefix">
<span data-slot="inline-input-v2-prefix-text">{local.prefix}</span>
</div>
<div data-slot="inline-input-v2-divider" aria-hidden="true" />
<div data-slot="inline-input-v2-field">
<div data-slot="inline-input-v2-value">
<input
{...inputProps}
type={inputProps.type ?? "text"}
disabled={local.disabled}
aria-invalid={local.invalid ? true : undefined}
data-slot="inline-input-v2-input"
/>
</div>
<Show when={local.showCopyButton}>
<button
type="button"
data-slot="inline-input-v2-icon-button"
aria-label={local.copyLabel ?? "Copy"}
disabled={local.disabled}
onClick={local.onCopyClick}
>
<Icon name="copy" />
</button>
</Show>
</div>
</div>
)
}

View File

@@ -0,0 +1,73 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="keybind-v2"] {
box-sizing: border-box;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
display: inline-flex;
flex-direction: row;
align-items: center;
padding: 0px;
gap: 2px;
}
[data-component="keybind-v2"] *,
[data-component="keybind-v2"] *::before,
[data-component="keybind-v2"] *::after {
box-sizing: border-box;
}
[data-component="keybind-v2"] [data-slot="keybind-v2-key"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0px;
gap: 4px;
width: 14px;
height: 14px;
border-radius: 2px;
flex: none;
flex-grow: 0;
}
[data-component="keybind-v2"][data-variant="neutral"] [data-slot="keybind-v2-key"] {
background: var(--background-bg-layer-03);
}
[data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-key"] {
background: transparent;
}
[data-component="keybind-v2"] [data-slot="keybind-v2-label"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 14px;
height: 14px;
padding: 0px;
flex: 1 1 auto;
align-self: stretch;
font-family: 'Inter', var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 100%;
text-align: center;
letter-spacing: 0.05px;
font-variation-settings: 'slnt' 0;
user-select: none;
}
[data-component="keybind-v2"][data-variant="neutral"] [data-slot="keybind-v2-label"] {
color: var(--text-text-muted);
}
[data-component="keybind-v2"][data-variant="ghost"] [data-slot="keybind-v2-label"] {
color: var(--text-text-faint);
}

View File

@@ -0,0 +1,82 @@
import { KeybindV2 } from "./keybind-v2"
const docs = `### Overview
Inline keybind indicator that renders one or more keyboard keys in a compact row.
### API
- \`keys\`: Array of key labels to display (e.g. \`["⌘", "K"]\`).
- \`variant\`: "neutral" (gray background) | "ghost" (no background).
- Inherits native div attributes.
### Variants
- **Neutral** — each key sits on a \`#D4D4D4\` pill with darker text.
- **Ghost** — keys render without a background, lighter text color.
`
export default {
title: "UI V2/Keybind",
id: "components-keybind-v2",
component: KeybindV2,
tags: ["autodocs"],
parameters: {
frameHeight: "200px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
keys: ["⌘"],
variant: "neutral",
},
argTypes: {
keys: {
control: "object",
},
variant: {
control: "select",
options: ["neutral", "ghost"],
},
},
}
export const Playground = {}
export const Variants = {
render: () => (
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<KeybindV2 keys={["⌘"]} variant="neutral" />
<KeybindV2 keys={["⌘"]} variant="ghost" />
</div>
),
}
export const MultipleKeys = {
render: () => (
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<KeybindV2 keys={["⌘", "K"]} variant="neutral" />
<KeybindV2 keys={["⌘", "K"]} variant="ghost" />
</div>
),
}
export const AllExamples = {
render: () => (
<div style={{ display: "flex", "flex-direction": "column", gap: "16px" }}>
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<span style={{ "font-size": "11px", color: "#808080", width: "50px" }}>Neutral</span>
<KeybindV2 keys={["⌘"]} variant="neutral" />
<KeybindV2 keys={["⌘", "K"]} variant="neutral" />
<KeybindV2 keys={["⌘", "⇧", "P"]} variant="neutral" />
</div>
<div style={{ display: "flex", gap: "24px", "align-items": "center" }}>
<span style={{ "font-size": "11px", color: "#808080", width: "50px" }}>Ghost</span>
<KeybindV2 keys={["⌘"]} variant="ghost" />
<KeybindV2 keys={["⌘", "K"]} variant="ghost" />
<KeybindV2 keys={["⌘", "⇧", "P"]} variant="ghost" />
</div>
</div>
),
}

View File

@@ -0,0 +1,30 @@
import { type ComponentProps, For, splitProps } from "solid-js"
import "./keybind-v2.css"
export interface KeybindV2Props extends ComponentProps<"div"> {
keys: string[]
variant?: "neutral" | "ghost"
}
export function KeybindV2(props: KeybindV2Props) {
const [local, rest] = splitProps(props, ["keys", "variant", "class", "classList"])
return (
<div
{...rest}
data-component="keybind-v2"
data-variant={local.variant || "neutral"}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<For each={local.keys}>
{(key) => (
<div data-slot="keybind-v2-key">
<span data-slot="keybind-v2-label">{key}</span>
</div>
)}
</For>
</div>
)
}

View File

@@ -0,0 +1,207 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="line-comment-v2"] {
box-sizing: border-box;
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
min-width: 0;
width: 100%;
max-width: 400px;
}
[data-component="line-comment-v2"] *,
[data-component="line-comment-v2"] *::before,
[data-component="line-comment-v2"] *::after {
box-sizing: border-box;
}
[data-component="line-comment-v2"] [data-slot="line-comment-v2-shell"] {
background: var(--background-bg-layer-01);
border-radius: 6px;
box-shadow: var(--elevation-raised);
}
/* --- Display (read) --- */
[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-shell"] {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 12px;
gap: 8px;
}
[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-column"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-text"] {
margin: 0;
width: 100%;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-meta"] {
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: 0.05px;
color: var(--text-text-faint);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="display"] [data-slot="line-comment-v2-tools"] {
display: flex;
flex-direction: row;
align-items: flex-start;
flex-shrink: 0;
}
[data-slot="line-comment-v2-overflow"] {
display: inline-flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 20px;
height: 20px;
padding: 2px;
gap: 3px;
margin: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--icon-icon-muted);
cursor: pointer;
outline: none;
}
[data-slot="line-comment-v2-overflow"]:focus {
outline: none;
}
[data-slot="line-comment-v2-overflow"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 2px;
}
[data-slot="line-comment-v2-overflow"]:is(:hover, [data-state="hover"]) {
background-color: var(--overlay-simple-overlay-hover);
}
[data-slot="line-comment-v2-overflow"]:is(:active, [data-state="pressed"]) {
background-color: var(--overlay-simple-overlay-pressed);
}
[data-slot="line-comment-v2-overflow"] svg {
display: block;
flex-shrink: 0;
}
/* --- Editor --- */
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-shell"] {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 12px;
gap: 12px;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
display: block;
width: 100%;
min-width: 0;
min-height: 80px;
padding: 8px;
margin: 0;
resize: vertical;
border: 1px solid var(--border-border-base);
border-radius: 6px;
background:
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
font-family: inherit;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 1.35;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
outline: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
color: var(--text-text-faint);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
border-color: var(--border-border-focus);
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: 0.05px;
color: var(--text-text-faint);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
flex-shrink: 0;
}

View File

@@ -0,0 +1,92 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import {
LineCommentEditorV2,
LineCommentV2,
LineCommentV2OverflowIcon,
} from "./line-comment-v2"
const docs = `### Overview
Line comment **display** and **editor** cards aligned with OpenCode line-comment specs (raised \`#FAFAFA\` surface, footer line context, \`ButtonV2\` neutral + contrast actions).
### Display
- \`LineCommentV2\`: column stack (body + meta) beside optional \`actions\` (overflow).
- Use \`LineCommentV2OverflowIcon\` inside a \`data-slot="line-comment-v2-overflow"\` button for the Figma dots control.
### Editor
- \`LineCommentEditorV2\`: optional \`heading\` above the textarea (default “Comment”), footer (selection meta + Cancel / Comment).
- \`Enter\` submits (Shift+Enter newline); \`Escape\` cancels. Controlled via \`value\` / \`onInput\`.
`
export default {
title: "UI V2/LineComment",
id: "components-line-comment-v2",
component: LineCommentV2,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Display = {
render: () => (
<div style={{ width: "400px" }}>
<LineCommentV2
comment="Consider guarding against empty arrays."
selection="Comment on line 40"
actions={
<button type="button" data-slot="line-comment-v2-overflow" aria-label="Comment actions">
<LineCommentV2OverflowIcon />
</button>
}
/>
</div>
),
}
export const DisplayWithoutActions = {
render: () => (
<div style={{ width: "400px" }}>
<LineCommentV2 comment="Consider guarding against empty arrays." selection="Comment on line 40" />
</div>
),
}
export const Editor = {
render: () => {
const [value, setValue] = createSignal("")
return (
<div style={{ width: "400px" }}>
<LineCommentEditorV2
value={value()}
onInput={setValue}
onCancel={() => setValue("")}
onSubmit={() => setValue("")}
selection="Comment on line 40"
/>
</div>
)
},
}
export const EditorFilled = {
render: () => {
const [value, setValue] = createSignal("Use a sentinel or early return when the list is empty.")
return (
<div style={{ width: "400px" }}>
<LineCommentEditorV2
value={value()}
onInput={setValue}
onCancel={() => setValue("")}
onSubmit={() => {}}
selection="Comment on line 40"
autofocus={false}
/>
</div>
)
},
}

View File

@@ -0,0 +1,164 @@
import { type ComponentProps, type JSX, Show, onMount, splitProps } from "solid-js"
import { ButtonV2 } from "./button-v2"
import "./line-comment-v2.css"
/** Horizontal “more” glyph for the display-card overflow control (Figma outline-dots). */
export function LineCommentV2OverflowIcon(props: ComponentProps<"svg">) {
return (
<svg
{...props}
width={props.width ?? 16}
height={props.height ?? 16}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden={props["aria-hidden"] ?? "true"}
>
<path d="M2.5 7.5H3.5V8.5H2.5V7.5Z" stroke="currentColor" />
<path d="M7.5 7.5H8.5V8.5H7.5V7.5Z" stroke="currentColor" />
<path d="M12.5 7.5H13.5V8.5H12.5V7.5Z" stroke="currentColor" />
</svg>
)
}
export interface LineCommentV2Props extends ComponentProps<"div"> {
/** Main comment body (text or rich content). */
comment: JSX.Element
/** Line / selection context (e.g. “Comment on line 40”). */
selection: JSX.Element
/** Typically an overflow menu trigger; use `LineCommentV2OverflowIcon` inside `line-comment-v2-overflow`. */
actions?: JSX.Element
}
export function LineCommentV2(props: LineCommentV2Props) {
const [local, rest] = splitProps(props, ["comment", "selection", "actions", "class", "classList"])
return (
<div
{...rest}
data-component="line-comment-v2"
data-variant="display"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="line-comment-v2-shell">
<div data-slot="line-comment-v2-column">
<div data-slot="line-comment-v2-text">{local.comment}</div>
<div data-slot="line-comment-v2-meta">{local.selection}</div>
</div>
<Show when={local.actions}>
{(actions) => <div data-slot="line-comment-v2-tools">{actions()}</div>}
</Show>
</div>
</div>
)
}
export interface LineCommentEditorV2Props
extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
/** Visible field label above the textarea (default: “Comment”). */
heading?: JSX.Element | string
value: string
onInput: (value: string) => void
onCancel: () => void
onSubmit: (value: string) => void
selection: JSX.Element
placeholder?: string
rows?: number
cancelLabel?: string
submitLabel?: string
autofocus?: boolean
}
export function LineCommentEditorV2(props: LineCommentEditorV2Props) {
let textareaRef: HTMLTextAreaElement | undefined
const [local, rest] = splitProps(props, [
"heading",
"value",
"onInput",
"onCancel",
"onSubmit",
"selection",
"placeholder",
"rows",
"cancelLabel",
"submitLabel",
"autofocus",
"class",
"classList",
])
const heading = () => local.heading ?? "Comment"
const canSubmit = () => local.value.trim().length > 0
const submit = () => {
const v = local.value.trim()
if (!v) return
local.onSubmit(v)
}
onMount(() => {
if (local.autofocus === false) return
requestAnimationFrame(() => textareaRef?.focus())
})
return (
<div
{...rest}
data-component="line-comment-v2"
data-variant="editor"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="line-comment-v2-shell">
<div data-slot="line-comment-v2-field">
<div data-slot="line-comment-v2-label">{heading()}</div>
<textarea
ref={(el) => {
textareaRef = el
}}
data-slot="line-comment-v2-textarea"
rows={local.rows ?? 3}
placeholder={local.placeholder ?? "Add context for this change"}
value={local.value}
onInput={(e) => local.onInput(e.currentTarget.value)}
onKeyDown={(e) => {
e.stopPropagation()
if (e.key === "Escape") {
e.preventDefault()
e.currentTarget.blur()
local.onCancel()
return
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submit()
}
}}
/>
</div>
<div data-slot="line-comment-v2-footer">
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
<div data-slot="line-comment-v2-footer-actions">
<ButtonV2 type="button" size="normal" variant="neutral" onClick={() => local.onCancel()}>
{local.cancelLabel ?? "Cancel"}
</ButtonV2>
<ButtonV2
type="button"
size="normal"
variant="contrast"
disabled={!canSubmit()}
onClick={submit}
>
{local.submitLabel ?? "Comment"}
</ButtonV2>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,186 @@
[data-component="menu-v2-content"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 2px;
min-width: 160px;
background: var(--background-bg-layer-01);
border-radius: 6px;
box-shadow: var(--elevation-floating);
outline: none;
font-family: var(--font-family-sans), 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
transform-origin: var(--kb-menu-content-transform-origin);
animation: menu-v2-in 120ms ease-out;
&:focus-visible {
outline: none;
}
}
[data-component="menu-v2-item"] {
--menu-v2-fg: var(--text-text-base);
--menu-v2-fg-muted: var(--text-text-faint);
--menu-v2-fg-subtle: var(--text-text-muted);
--menu-v2-icon: var(--icon-icon-base);
--menu-v2-accent: var(--text-text-accent);
--menu-v2-badge-bg: var(--background-bg-layer-02);
--menu-v2-badge-border: var(--border-border-base);
--menu-v2-hover: var(--overlay-simple-overlay-hover);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 12px;
background: transparent;
border-radius: 4px;
outline: none;
cursor: default;
user-select: none;
font-family: var(--font-family-sans), 'Inter', system-ui, sans-serif;
font-variation-settings: 'slnt' 0;
font-variant-numeric: tabular-nums;
color: var(--menu-v2-fg);
[data-slot="menu-v2-item-content"] {
display: flex;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
gap: 8px;
min-width: 0;
font-size: 13px;
font-weight: 440;
line-height: 100%;
letter-spacing: -0.04px;
color: var(--menu-v2-fg);
}
[data-slot="menu-v2-item-shortcut"] {
display: inline-flex;
align-items: center;
flex: none;
height: 11px;
font-size: 11px;
font-weight: 530;
line-height: 100%;
letter-spacing: 0.05px;
color: var(--menu-v2-fg-muted);
white-space: nowrap;
}
[data-slot="menu-v2-item-badge"] {
box-sizing: border-box;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex: none;
gap: 4px;
height: 16px;
padding: 0 4px;
background: var(--menu-v2-badge-bg);
border: 0.5px solid var(--menu-v2-badge-border);
border-radius: 2px;
font-size: 11px;
font-weight: 530;
line-height: 100%;
letter-spacing: 0.05px;
color: var(--menu-v2-fg-subtle);
white-space: nowrap;
}
[data-slot="menu-v2-item-indicator"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 16px;
height: 16px;
color: var(--menu-v2-icon);
}
[data-slot="menu-v2-item-indicator"]:not([data-checked]) > svg {
visibility: hidden;
}
[data-slot="menu-v2-item-chevron"] {
flex: none;
width: 16px;
height: 16px;
color: var(--menu-v2-fg-muted);
}
&[data-highlighted] {
background: var(--menu-v2-hover);
}
&[data-checked] [data-slot="menu-v2-item-content"] {
font-weight: 530;
color: var(--menu-v2-accent);
}
&[data-checked] [data-slot="menu-v2-item-indicator"] {
color: var(--menu-v2-accent);
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
}
[data-slot="menu-v2-separator"] {
height: 1px;
width: calc(100% + 4px);
margin: 2px -2px;
background: var(--border-border-muted);
border: none;
}
[data-slot="menu-v2-group-label"] {
box-sizing: border-box;
display: flex;
align-items: center;
height: 28px;
padding: 0 12px;
font-family: var(--font-family-sans), 'Inter', system-ui, sans-serif;
font-size: 11px;
font-weight: 530;
line-height: 100%;
letter-spacing: 0.05px;
color: var(--text-text-faint);
user-select: none;
font-variant-numeric: tabular-nums;
}
@keyframes menu-v2-in {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,216 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { MenuV2 } from "./menu-v2"
import { ButtonV2 } from "./button-v2"
import { Avatar } from "./avatar-v2"
import { Icon } from "./icon"
const docs = `### Overview
Composable menu primitive built on Kobalte's \`DropdownMenu\` and \`ContextMenu\`. The same item components (\`Item\`, \`CheckboxItem\`, \`RadioItem\`, \`SubTrigger\`) work inside either container.
### API
- \`MenuV2\` / \`MenuV2.Trigger\` / \`MenuV2.Portal\` / \`MenuV2.Content\` — dropdown root + popper plumbing.
- \`MenuV2.Context\` namespace mirrors the same shape for right-click menus.
- \`MenuV2.Item\` — supports a freeform \`children\` slot (avatar, icon, text — whatever) plus \`shortcut\` and \`badge\` props.
- \`MenuV2.CheckboxItem\` / \`MenuV2.RadioItem\` — same item shape; auto-render a check indicator that turns blue when selected.
- \`MenuV2.Sub\` / \`MenuV2.SubTrigger\` / \`MenuV2.SubContent\` — nested submenus; \`SubTrigger\` auto-renders the trailing chevron.
### Behavior
- Items expose Kobalte's data attributes — \`data-highlighted\`, \`data-checked\`, \`data-disabled\`.
- Blue selected state is reserved for \`CheckboxItem\` / \`RadioItem\` (the rest just highlight on hover).
- Chevron is only rendered on \`SubTrigger\`.
`
export default {
title: "UI V2/Menu",
id: "components-menu-v2",
component: MenuV2,
tags: ["autodocs"],
parameters: {
frameHeight: "360px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<MenuV2 gutter={6}>
<MenuV2.Trigger as={ButtonV2}>Open menu</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item>New file</MenuV2.Item>
<MenuV2.Item>Open file</MenuV2.Item>
<MenuV2.Item>Save</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item disabled>Print</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
),
}
export const Rich = {
render: () => (
<MenuV2 gutter={6}>
<MenuV2.Trigger as={ButtonV2}>Open rich menu</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "240px" }}>
<MenuV2.Item shortcut="⇧ D" badge="Label">
<Avatar size="small" kind="org" fallback="A" />
<Icon name="settings" size="small" />
Text
</MenuV2.Item>
<MenuV2.Item shortcut="⌘ N">
<Icon name="plus" size="small" />
New window
</MenuV2.Item>
<MenuV2.Item shortcut="⌘ S" badge="Beta">
<Icon name="save" size="small" />
Save as
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item disabled shortcut="⌘ P">
<Icon name="print" size="small" />
Print
</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
),
}
export const WithCheckbox = {
render: () => {
const [wrap, setWrap] = createSignal(true)
const [minimap, setMinimap] = createSignal(false)
const [ruler, setRuler] = createSignal(false)
return (
<MenuV2 gutter={6}>
<MenuV2.Trigger as={ButtonV2}>View</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "200px" }}>
<MenuV2.CheckboxItem checked={wrap()} onChange={setWrap} shortcut="⌥ Z">
Word wrap
</MenuV2.CheckboxItem>
<MenuV2.CheckboxItem checked={minimap()} onChange={setMinimap}>
Minimap
</MenuV2.CheckboxItem>
<MenuV2.CheckboxItem checked={ruler()} onChange={setRuler} disabled>
Ruler
</MenuV2.CheckboxItem>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
},
}
export const WithRadio = {
render: () => {
const [theme, setTheme] = createSignal("system")
return (
<MenuV2 gutter={6}>
<MenuV2.Trigger as={ButtonV2}>Theme</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "200px" }}>
<MenuV2.Group>
<MenuV2.GroupLabel>Appearance</MenuV2.GroupLabel>
<MenuV2.RadioGroup value={theme()} onChange={setTheme}>
<MenuV2.RadioItem value="light">Light</MenuV2.RadioItem>
<MenuV2.RadioItem value="dark">Dark</MenuV2.RadioItem>
<MenuV2.RadioItem value="system" badge="Auto">
System
</MenuV2.RadioItem>
</MenuV2.RadioGroup>
</MenuV2.Group>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
)
},
}
export const WithSubmenu = {
render: () => (
<MenuV2 gutter={6}>
<MenuV2.Trigger as={ButtonV2}>File</MenuV2.Trigger>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "200px" }}>
<MenuV2.Item shortcut="⌘ N">New file</MenuV2.Item>
<MenuV2.Item shortcut="⌘ O">Open file</MenuV2.Item>
<MenuV2.Sub gutter={0}>
<MenuV2.SubTrigger>Open recent</MenuV2.SubTrigger>
<MenuV2.Portal>
<MenuV2.SubContent>
<MenuV2.Item>project-alpha.tsx</MenuV2.Item>
<MenuV2.Item>project-beta.tsx</MenuV2.Item>
<MenuV2.Item>project-gamma.tsx</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item>Clear recent</MenuV2.Item>
</MenuV2.SubContent>
</MenuV2.Portal>
</MenuV2.Sub>
<MenuV2.Separator />
<MenuV2.Item shortcut="⌘ S">Save</MenuV2.Item>
<MenuV2.Item shortcut="⇧⌘ S">Save as</MenuV2.Item>
</MenuV2.Content>
</MenuV2.Portal>
</MenuV2>
),
}
export const Context = {
render: () => (
<MenuV2.Context gutter={6}>
<MenuV2.Context.Trigger>
<div
style={{
display: "flex",
"align-items": "center",
"justify-content": "center",
width: "320px",
height: "180px",
"border-radius": "8px",
border: "1px dashed rgba(0, 0, 0, 0.2)",
color: "#5c5c5c",
"font-size": "13px",
"font-family": "var(--font-family-sans)",
"user-select": "none",
}}
>
Right-click this area
</div>
</MenuV2.Context.Trigger>
<MenuV2.Context.Portal>
<MenuV2.Context.Content style={{ "min-width": "200px" }}>
<MenuV2.Item shortcut="⌘ C">
<Avatar size="small" kind="org" fallback="C" />
Copy
</MenuV2.Item>
<MenuV2.Item shortcut="⌘ X">
<Icon name="cut" size="small" />
Cut
</MenuV2.Item>
<MenuV2.Item shortcut="⌘ V">
<Icon name="paste" size="small" />
Paste
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item badge="New">
<Icon name="inspect" size="small" />
Inspect element
</MenuV2.Item>
<MenuV2.Item disabled>
<Icon name="trash" size="small" />
Delete
</MenuV2.Item>
</MenuV2.Context.Content>
</MenuV2.Context.Portal>
</MenuV2.Context>
),
}

View File

@@ -0,0 +1,240 @@
import { DropdownMenu } from "@kobalte/core/dropdown-menu"
import { ContextMenu } from "@kobalte/core/context-menu"
import { Show, splitProps, type Component, type ComponentProps, type JSX, type ParentProps } from "solid-js"
import "./menu-v2.css"
const ChevronRight: Component = () => (
<svg
data-slot="menu-v2-item-chevron"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M6 4L10 8L6 12V4Z" fill="currentColor" />
</svg>
)
const CheckMark: Component = () => (
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M3.53564 8.17857L6.39279 11.75L12.4642 4.25"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)
function ItemBody(
props: ParentProps<{
shortcut?: JSX.Element | string
badge?: JSX.Element | string
trailing?: JSX.Element
}>,
) {
return (
<>
<span data-slot="menu-v2-item-content">{props.children}</span>
<Show when={props.shortcut}>
{(shortcut) => <span data-slot="menu-v2-item-shortcut">{shortcut()}</span>}
</Show>
<Show when={props.badge}>
{(badge) => <span data-slot="menu-v2-item-badge">{badge()}</span>}
</Show>
{props.trailing}
</>
)
}
export interface MenuV2ItemProps extends ComponentProps<typeof DropdownMenu.Item> {
shortcut?: JSX.Element | string
badge?: JSX.Element | string
}
function MenuV2Item(props: ParentProps<MenuV2ItemProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"])
return (
<DropdownMenu.Item
{...r}
data-component="menu-v2-item"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<ItemBody shortcut={s.shortcut} badge={s.badge}>
{s.children}
</ItemBody>
</DropdownMenu.Item>
)
}
export interface MenuV2CheckboxItemProps extends ComponentProps<typeof DropdownMenu.CheckboxItem> {
shortcut?: JSX.Element | string
badge?: JSX.Element | string
}
function MenuV2CheckboxItem(props: ParentProps<MenuV2CheckboxItemProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"])
return (
<DropdownMenu.CheckboxItem
{...r}
data-component="menu-v2-item"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<ItemBody
shortcut={s.shortcut}
badge={s.badge}
trailing={
<DropdownMenu.ItemIndicator data-slot="menu-v2-item-indicator" forceMount>
<CheckMark />
</DropdownMenu.ItemIndicator>
}
>
{s.children}
</ItemBody>
</DropdownMenu.CheckboxItem>
)
}
export interface MenuV2RadioItemProps extends ComponentProps<typeof DropdownMenu.RadioItem> {
shortcut?: JSX.Element | string
badge?: JSX.Element | string
}
function MenuV2RadioItem(props: ParentProps<MenuV2RadioItemProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"])
return (
<DropdownMenu.RadioItem
{...r}
data-component="menu-v2-item"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<ItemBody
shortcut={s.shortcut}
badge={s.badge}
trailing={
<DropdownMenu.ItemIndicator data-slot="menu-v2-item-indicator" forceMount>
<CheckMark />
</DropdownMenu.ItemIndicator>
}
>
{s.children}
</ItemBody>
</DropdownMenu.RadioItem>
)
}
export interface MenuV2SubTriggerProps extends ComponentProps<typeof DropdownMenu.SubTrigger> {
shortcut?: JSX.Element | string
badge?: JSX.Element | string
}
function MenuV2SubTrigger(props: ParentProps<MenuV2SubTriggerProps>) {
const [s, r] = splitProps(props, ["class", "classList", "children", "shortcut", "badge"])
return (
<DropdownMenu.SubTrigger
{...r}
data-component="menu-v2-item"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
>
<ItemBody shortcut={s.shortcut} badge={s.badge} trailing={<ChevronRight />}>
{s.children}
</ItemBody>
</DropdownMenu.SubTrigger>
)
}
function MenuV2SubContent(props: ComponentProps<typeof DropdownMenu.SubContent>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<DropdownMenu.SubContent
{...r}
data-component="menu-v2-content"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function MenuV2GroupLabel(props: ComponentProps<typeof DropdownMenu.GroupLabel>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<DropdownMenu.GroupLabel
{...r}
data-slot="menu-v2-group-label"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function MenuV2Separator(props: ComponentProps<typeof DropdownMenu.Separator>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<DropdownMenu.Separator
{...r}
data-slot="menu-v2-separator"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function MenuV2Content(props: ComponentProps<typeof DropdownMenu.Content>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<DropdownMenu.Content
{...r}
data-component="menu-v2-content"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
function MenuV2Root(props: ComponentProps<typeof DropdownMenu>) {
return <DropdownMenu {...props} />
}
function MenuV2ContextRoot(props: ComponentProps<typeof ContextMenu>) {
return <ContextMenu {...props} />
}
function MenuV2ContextContent(props: ComponentProps<typeof ContextMenu.Content>) {
const [s, r] = splitProps(props, ["class", "classList"])
return (
<ContextMenu.Content
{...r}
data-component="menu-v2-content"
classList={{ ...s.classList, [s.class ?? ""]: !!s.class }}
/>
)
}
const MenuV2Context = Object.assign(MenuV2ContextRoot, {
Trigger: ContextMenu.Trigger,
Portal: ContextMenu.Portal,
Content: MenuV2ContextContent,
})
export const MenuV2 = Object.assign(MenuV2Root, {
Trigger: DropdownMenu.Trigger,
Portal: DropdownMenu.Portal,
Content: MenuV2Content,
Item: MenuV2Item,
CheckboxItem: MenuV2CheckboxItem,
RadioGroup: DropdownMenu.RadioGroup,
RadioItem: MenuV2RadioItem,
Group: DropdownMenu.Group,
GroupLabel: MenuV2GroupLabel,
Separator: MenuV2Separator,
Sub: DropdownMenu.Sub,
SubTrigger: MenuV2SubTrigger,
SubContent: MenuV2SubContent,
Context: MenuV2Context,
})

View File

@@ -0,0 +1,207 @@
[data-component="radio-v2"] {
display: flex;
flex-direction: column;
gap: 8px;
cursor: default;
[data-slot="radio-v2-label"] {
display: inline-flex;
align-items: center;
user-select: none;
color: var(--text-text-faint);
font-family: var(--font-family-sans);
font-size: 11px;
font-style: normal;
font-weight: 440;
line-height: 1;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
}
[data-slot="radio-v2-description"] {
color: var(--text-text-faint);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1.2;
letter-spacing: 0.05px;
}
[data-slot="radio-v2-items"] {
display: flex;
flex-direction: column;
gap: 12px;
}
[data-slot="radio-v2-error"] {
color: var(--state-fg-danger);
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="radio-v2-error"]:empty {
display: none;
}
}
[data-slot="radio-v2-item"] {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 0px;
gap: 8px;
&:where([data-disabled]) {
cursor: not-allowed;
}
[data-slot="radio-v2-item-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;
}
[data-slot="radio-v2-item-control-stack"] {
position: relative;
width: 16px;
height: 20px;
flex: none;
}
[data-slot="radio-v2-item-control"] {
box-sizing: border-box;
position: absolute;
width: 16px;
height: 16px;
flex: none;
flex-shrink: 0;
left: 0;
top: calc(50% - 16px / 2);
border-radius: 9999px;
border: none;
box-shadow: inset 0 0 0 0.5px var(--border-border-strong);
background:
linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
transition:
background 170ms ease-out,
opacity 170ms ease-out,
outline-color 170ms ease-out;
}
&:where(:not([data-readonly]))
[data-slot="radio-v2-item-input"]:focus-visible
~ [data-slot="radio-v2-item-control-stack"]
[data-slot="radio-v2-item-control"] {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
}
&:where(:hover):where(:not([data-disabled], [data-readonly])):where(:not([data-checked]))
[data-slot="radio-v2-item-control"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-6) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
&:where([data-disabled]) [data-slot="radio-v2-item-control"] {
opacity: 0.5;
}
&:where([data-checked]) [data-slot="radio-v2-item-control"] {
background:
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
var(--background-bg-accent);
}
&:where([data-checked]):where(:hover):where(:not([data-disabled], [data-readonly]))
[data-slot="radio-v2-item-control"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-contrast-hover), var(--overlay-simple-overlay-contrast-hover)),
linear-gradient(180deg, var(--alpha-light-20) 0%, var(--alpha-light-0) 100%),
var(--background-bg-accent);
}
&:where([data-checked][data-disabled]) [data-slot="radio-v2-item-control"] {
opacity: 0.5;
}
&:where([data-invalid]):where(:not([data-checked])) [data-slot="radio-v2-item-control"] {
background: var(--state-bg-danger);
box-shadow: inset 0 0 0 0.5px #b82d35;
}
[data-slot="radio-v2-item-indicator"] {
box-sizing: border-box;
position: absolute;
width: 6px;
height: 6px;
left: calc(50% - 6px / 2);
top: calc(50% - 6px / 2);
border-radius: 9999px;
background: var(--grey-300);
border: none;
box-shadow:
inset 0 0 0 0.5px var(--overlay-gradient-depth-overlay-depth-top),
0px 0.5px 0.5px rgba(0, 0, 0, 0.4);
}
[data-slot="radio-v2-item-text"] {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 0px;
gap: 6px;
}
[data-slot="radio-v2-item-label"] {
display: inline-flex;
user-select: none;
color: inherit;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
}
[data-slot="radio-v2-item-label-text"] {
display: inline-flex;
align-items: center;
user-select: none;
color: var(--text-text-base);
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
}
[data-slot="radio-v2-item-description"] {
color: var(--text-text-muted);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 440;
line-height: 1;
letter-spacing: 0.05px;
font-variant-numeric: tabular-nums;
user-select: none;
}
&:where([data-disabled]) [data-slot="radio-v2-item-label"],
&:where([data-disabled]) [data-slot="radio-v2-item-description"] {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,98 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { RadioGroupV2, RadioItemV2 } from "./radio-v2"
const docs = `### Overview
Single-select options using Kobalte RadioGroup.
### API
- \`RadioGroupV2\` forwards Kobalte RadioGroup props (\`value\`, \`defaultValue\`, \`onChange\`, \`name\`, \`required\`, \`validationState\`, \`disabled\`).
- \`RadioItemV2\` forwards Kobalte item props (\`value\`, \`disabled\`), and adds \`label\` and optional \`description\`.
### Behavior
- Controlled or uncontrolled via \`value\` / \`defaultValue\` on the group (items declare \`value\` only).
### Theming/tokens
- Uses \`data-component="radio-v2"\` and slot attributes.
`
export default {
title: "UI V2/Radio",
id: "components-radio-v2",
component: RadioGroupV2,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<RadioGroupV2 label="Notification frequency" defaultValue="daily" name="frequency">
<RadioItemV2 value="daily" label="Daily" description="Once per day at 9am." />
<RadioItemV2 value="weekly" label="Weekly" description="Every Monday morning." />
<RadioItemV2 value="never" label="Never" description="No notifications." />
</RadioGroupV2>
),
}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("weekly")
return (
<div style={{ display: "grid", gap: "12px" }}>
<RadioGroupV2
label="Controlled"
value={value()}
onChange={(v) => setValue(v)}
name="controlled-frequency"
>
<RadioItemV2 value="daily" label="Daily" />
<RadioItemV2 value="weekly" label="Weekly" />
<RadioItemV2 value="never" label="Never" />
</RadioGroupV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
Selected: {value()}
</div>
</div>
)
},
}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "20px" }}>
<RadioGroupV2 label="Default" defaultValue="a" name="state-default">
<RadioItemV2 value="a" label="Option A" />
<RadioItemV2 value="b" label="Option B" description="Has a description." />
</RadioGroupV2>
<RadioGroupV2 label="Disabled group" defaultValue="a" name="state-disabled" disabled>
<RadioItemV2 value="a" label="Option A" />
<RadioItemV2 value="b" label="Option B" />
</RadioGroupV2>
<RadioGroupV2 label="Disabled item" defaultValue="a" name="state-disabled-item">
<RadioItemV2 value="a" label="Enabled" />
<RadioItemV2 value="b" label="Disabled" disabled />
</RadioGroupV2>
<RadioGroupV2
label="Invalid"
description="Pick one option."
defaultValue="a"
name="state-invalid"
validationState="invalid"
required
>
<RadioItemV2 value="a" label="Option A" />
<RadioItemV2 value="b" label="Option B" />
</RadioGroupV2>
</div>
),
}

View File

@@ -0,0 +1,77 @@
import { RadioGroup as Kobalte } from "@kobalte/core/radio-group"
import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
import "./radio-v2.css"
export interface RadioGroupV2Props extends ParentProps<ComponentProps<typeof Kobalte>> {
label?: JSX.Element
description?: JSX.Element
hideLabel?: boolean
}
export function RadioGroupV2(props: RadioGroupV2Props) {
const [local, others] = splitProps(props, ["class", "classList", "children", "label", "description", "hideLabel"])
return (
<Kobalte
{...others}
data-component="radio-v2"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Show when={local.label}>
{(label) => (
<Kobalte.Label data-slot="radio-v2-label" classList={{ "sr-only": local.hideLabel }}>
{label()}
</Kobalte.Label>
)}
</Show>
<Show when={local.description}>
{(description) => (
<Kobalte.Description data-slot="radio-v2-description">{description()}</Kobalte.Description>
)}
</Show>
<div data-slot="radio-v2-items">{local.children}</div>
<Kobalte.ErrorMessage data-slot="radio-v2-error" />
</Kobalte>
)
}
export interface RadioItemV2Props extends ComponentProps<typeof Kobalte.Item> {
label: JSX.Element
description?: JSX.Element
hideLabel?: boolean
}
export function RadioItemV2(props: RadioItemV2Props) {
const [local, others] = splitProps(props, ["class", "classList", "label", "description", "hideLabel"])
return (
<Kobalte.Item
{...others}
data-slot="radio-v2-item"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Kobalte.ItemInput data-slot="radio-v2-item-input" />
<div data-slot="radio-v2-item-control-stack">
<Kobalte.ItemControl data-slot="radio-v2-item-control">
<Kobalte.ItemIndicator data-slot="radio-v2-item-indicator" />
</Kobalte.ItemControl>
</div>
<Kobalte.ItemLabel data-slot="radio-v2-item-label" classList={{ "sr-only": local.hideLabel }}>
<div data-slot="radio-v2-item-text">
<span data-slot="radio-v2-item-label-text">{local.label}</span>
<Show when={local.description}>
{(description) => (
<span data-slot="radio-v2-item-description">{description()}</span>
)}
</Show>
</div>
</Kobalte.ItemLabel>
</Kobalte.Item>
)
}

View File

@@ -0,0 +1,81 @@
[data-slot="segmented-control-v2"].segmented-control-v2--full-width {
width: 100%;
}
[data-slot="segmented-control-v2"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
padding: 0;
width: 232px;
max-width: 100%;
height: 28px;
background: var(--background-bg-layer-01);
border: none;
border-radius: 6px;
box-shadow: 0 0 0 0.5px var(--border-border-base);
flex: none;
}
[data-slot="segmented-control-v2-item"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 0 12px;
flex: 1 1 0;
min-width: 0;
height: 28px;
margin: 0;
border: none;
border-radius: 6px;
background: transparent;
box-shadow: none;
cursor: pointer;
font-family: var(--font-family-sans), var(--sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 100%;
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
color: var(--text-text-muted);
transition:
background-color 0.12s ease,
color 0.12s ease,
box-shadow 0.12s ease;
}
[data-slot="segmented-control-v2-item"]:where(:disabled) {
cursor: not-allowed;
opacity: 0.45;
}
[data-slot="segmented-control-v2-item"]:where(:focus-visible) {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
z-index: 2;
}
[data-slot="segmented-control-v2-item"]:where([data-pressed]) {
background: var(--background-bg-base);
color: var(--text-text-base);
box-shadow: 0 0 0 0.5px var(--border-border-strong);
z-index: 1;
}
[data-slot="segmented-control-v2-item-label"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 0;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}

View File

@@ -0,0 +1,107 @@
import { createSignal } from "solid-js"
import { SegmentedControlItemV2, SegmentedControlV2 } from "./segmented-control-v2"
const docs = `### Overview
Single-select segmented control with **custom state** and native \`<button type="button">\` segments.
### Accessibility (toggle group style)
- Root: \`role="group"\` — pass \`aria-label\` or \`aria-labelledby\` (standard div attributes).
- Segments: \`aria-pressed\` reflects selection; \`data-pressed\` is set for styling.
- **Arrow Left / Right** move focus between enabled segments; **Home** / **End** focus first / last enabled segment.
### API
- **SegmentedControlV2:** \`value?\`, \`defaultValue?\`, \`onChange?(value: string | null)\`, \`allowDeselect?\` (default \`false\`), \`disabled?\`, plus native div attributes (\`class\`, \`aria-*\`, \`ref\`, etc.).
- **SegmentedControlItemV2:** \`value\` (string), \`disabled?\`, \`children\` (label), plus other button attributes except \`type\`.
### Behavior
- With default \`allowDeselect={false}\`, clicking the active segment does nothing; selection is never cleared.
- With \`allowDeselect\`, clicking the active segment clears selection and \`onChange(null)\` runs.
### Theming
- \`data-slot="segmented-control-v2"\` on the track; items use \`data-slot="segmented-control-v2-item"\` and \`data-pressed\` when selected.
`
export default {
title: "UI V2/SegmentedControl",
id: "components-segmented-control-v2",
component: SegmentedControlV2,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
}
export const Basic = {
render: () => (
<SegmentedControlV2 defaultValue="a" aria-label="Demo segment control">
<SegmentedControlItemV2 value="a">Label</SegmentedControlItemV2>
<SegmentedControlItemV2 value="b">Label</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">Label</SegmentedControlItemV2>
<SegmentedControlItemV2 value="d">Label</SegmentedControlItemV2>
</SegmentedControlV2>
),
}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("b")
return (
<div style={{ display: "grid", gap: "12px", "justify-items": "start" }}>
<SegmentedControlV2 value={value()} onChange={setValue} aria-label="View mode">
<SegmentedControlItemV2 value="a">List</SegmentedControlItemV2>
<SegmentedControlItemV2 value="b">Grid</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">Board</SegmentedControlItemV2>
</SegmentedControlV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
Value: {value()}
</div>
</div>
)
},
}
export const AllowDeselect = {
render: () => {
const [value, setValue] = createSignal<string | null>("a")
return (
<div style={{ display: "grid", gap: "12px", "justify-items": "start" }}>
<SegmentedControlV2 value={value()} allowDeselect onChange={setValue} aria-label="Optional selection">
<SegmentedControlItemV2 value="a">A</SegmentedControlItemV2>
<SegmentedControlItemV2 value="b">B</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">C</SegmentedControlItemV2>
</SegmentedControlV2>
<div style={{ "font-family": "var(--font-family-sans)", "font-size": "12px", color: "#808080" }}>
Value: {value() === null ? "none" : value()}
</div>
</div>
)
},
}
export const WithDisabledItem = {
render: () => (
<SegmentedControlV2 defaultValue="a" aria-label="Segments with one disabled">
<SegmentedControlItemV2 value="a">One</SegmentedControlItemV2>
<SegmentedControlItemV2 value="b" disabled>
Two
</SegmentedControlItemV2>
<SegmentedControlItemV2 value="c">Three</SegmentedControlItemV2>
</SegmentedControlV2>
),
}
export const FullWidth = {
render: () => (
<div style={{ width: "320px" }}>
<SegmentedControlV2 defaultValue="x" class="segmented-control-v2--full-width" aria-label="Full width">
<SegmentedControlItemV2 value="x">A</SegmentedControlItemV2>
<SegmentedControlItemV2 value="y">B</SegmentedControlItemV2>
<SegmentedControlItemV2 value="z">C</SegmentedControlItemV2>
</SegmentedControlV2>
</div>
),
}

View File

@@ -0,0 +1,196 @@
import {
createContext,
createMemo,
createSignal,
mergeProps,
splitProps,
useContext,
type Accessor,
type JSX,
type ParentProps,
} from "solid-js"
import type { ComponentProps } from "solid-js"
import "./segmented-control-v2.css"
type OnChange = (value: string | null) => void
type SegmentedControlContextValue = {
selected: Accessor<string | null>
groupDisabled: Accessor<boolean>
select: (value: string) => void
clearIfAllowed: (value: string) => void
focusNext: (from: HTMLButtonElement, direction: 1 | -1) => void
}
const SegmentedControlContext = createContext<SegmentedControlContextValue>()
function useSegmentedControlContext() {
const ctx = useContext(SegmentedControlContext)
if (!ctx) throw new Error("SegmentedControlItemV2 must be used inside SegmentedControlV2")
return ctx
}
export type SegmentedControlV2Props = Omit<ComponentProps<"div">, "onChange"> &
ParentProps<{
/** Selected value when controlled (including `null` when empty). Omit key for uncontrolled. */
value?: string | null
/** Initial value when uncontrolled. */
defaultValue?: string
onChange?: OnChange
/** When true, clicking the active segment clears selection (`onChange(null)`). Default false. */
allowDeselect?: boolean
disabled?: boolean
}>
export function SegmentedControlV2(props: SegmentedControlV2Props) {
const isControlled = createMemo(() => Object.hasOwn(props as object, "value"))
const merged = mergeProps({ allowDeselect: false, disabled: false }, props)
const [local, rest] = splitProps(merged, [
"class",
"classList",
"children",
"value",
"defaultValue",
"onChange",
"allowDeselect",
"disabled",
"ref",
])
const [internal, setInternal] = createSignal<string | null>(local.defaultValue ?? null)
const selected = createMemo(() => (isControlled() ? local.value ?? null : internal()))
const setSelected = (next: string | null) => {
if (!isControlled()) setInternal(next)
local.onChange?.(next)
}
const select = (value: string) => {
setSelected(value)
}
const clearIfAllowed = (value: string) => {
if (!local.allowDeselect || selected() !== value) return
setSelected(null)
}
const focusNext = (from: HTMLButtonElement, direction: 1 | -1) => {
const root = from.closest(`[data-slot="segmented-control-v2"]`)
if (!root) return
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>(`button[data-slot="segmented-control-v2-item"]`)).filter(
(b) => !b.disabled,
)
const i = buttons.indexOf(from)
const next = buttons[i + direction]
next?.focus()
}
const ctx: SegmentedControlContextValue = {
selected,
groupDisabled: () => !!local.disabled,
select,
clearIfAllowed,
focusNext,
}
const assignRef = (el: HTMLDivElement | undefined) => {
const r = local.ref
if (typeof r === "function") (r as (el: HTMLDivElement | undefined) => void)(el)
else if (r != null && typeof r === "object" && "value" in r) (r as { value: HTMLDivElement | undefined }).value = el
}
return (
<SegmentedControlContext.Provider value={ctx}>
<div
{...rest}
ref={assignRef}
role="group"
data-component="segmented-control-v2"
data-slot="segmented-control-v2"
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
{local.children}
</div>
</SegmentedControlContext.Provider>
)
}
export type SegmentedControlItemV2Props = Omit<ComponentProps<"button">, "type" | "children"> &
ParentProps<{
value: string
children: JSX.Element
}>
function invokeButtonHandler<E extends Event>(
handler: JSX.EventHandlerUnion<HTMLButtonElement, E> | undefined,
e: E & { currentTarget: HTMLButtonElement },
) {
if (typeof handler === "function") (handler as (ev: typeof e) => void)(e)
}
export function SegmentedControlItemV2(props: SegmentedControlItemV2Props) {
const merged = mergeProps({ disabled: false }, props)
const [local, rest] = splitProps(merged, ["class", "classList", "children", "value", "disabled", "onClick", "onKeyDown"])
const ctx = useSegmentedControlContext()
const pressed = createMemo(() => ctx.selected() === local.value)
const disabled = createMemo(() => ctx.groupDisabled() || !!local.disabled)
const onClick: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
invokeButtonHandler(local.onClick, e)
if (e.defaultPrevented || disabled()) return
if (pressed()) ctx.clearIfAllowed(local.value)
else ctx.select(local.value)
}
const onKeyDown: JSX.EventHandlerUnion<HTMLButtonElement, KeyboardEvent> = (e) => {
invokeButtonHandler(local.onKeyDown, e)
if (e.defaultPrevented || disabled()) return
const t = e.currentTarget
if (e.key === "ArrowRight") {
e.preventDefault()
ctx.focusNext(t, 1)
} else if (e.key === "ArrowLeft") {
e.preventDefault()
ctx.focusNext(t, -1)
}
// accessibility stuff
else if (e.key === "Home") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
const first = root?.querySelector<HTMLButtonElement>(`button[data-slot="segmented-control-v2-item"]:not(:disabled)`)
first?.focus()
} else if (e.key === "End") {
e.preventDefault()
const root = t.closest(`[data-slot="segmented-control-v2"]`)
const buttons = root?.querySelectorAll<HTMLButtonElement>(`button[data-slot="segmented-control-v2-item"]:not(:disabled)`)
const last = buttons?.[buttons.length - 1]
last?.focus()
}
}
return (
<button
{...rest}
type="button"
data-slot="segmented-control-v2-item"
data-pressed={pressed() ? "" : undefined}
aria-pressed={pressed()}
disabled={disabled()}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
onClick={onClick}
onKeyDown={onKeyDown}
>
<span data-slot="segmented-control-v2-item-label">{local.children}</span>
</button>
)
}

View File

@@ -0,0 +1,194 @@
@import "./menu-v2.css";
/* Select dropdown: slide down from trigger (no scale-from-corner). */
[data-component="menu-v2-content"][data-slot="select-v2-content"] {
transform-origin: top center;
animation: select-v2-content-in 120ms ease-out;
}
[data-component="menu-v2-content"][data-slot="select-v2-content"][data-closed] {
animation: select-v2-content-out 80ms ease-in forwards;
}
@keyframes select-v2-content-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes select-v2-content-out {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-2px);
}
}
/* Trigger shell — aligned with text-input-v2 */
[data-component="select-v2"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0 6px 0 0;
gap: 8px;
width: 280px;
height: 28px;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
outline-offset: 0;
background:
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
box-shadow: var(--elevation-button-neutral);
flex: none;
align-self: stretch;
transition:
background 85ms ease-out,
outline-color 85ms ease-out,
box-shadow 85ms ease-out;
}
[data-component="select-v2"][data-appearance="large"] {
height: 32px;
padding: 0 8px 0 0;
}
[data-component="select-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within):not([data-expanded]) {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
[data-component="select-v2"]:where(:focus-within):not([data-disabled], [data-invalid]),
[data-component="select-v2"]:where([data-expanded]):not([data-disabled], [data-invalid]) {
outline-color: var(--border-border-focus);
box-shadow: none;
}
[data-component="select-v2"]:where([data-invalid]):not([data-disabled]) {
outline-color: var(--state-fg-danger);
box-shadow: none;
}
[data-component="select-v2"]:where([data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
[data-component="select-v2"] [data-slot="select-v2-value"] {
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
padding: 0;
min-width: 0;
flex: 1 1 auto;
min-height: 0;
cursor: default;
}
[data-component="select-v2"] [data-slot="select-v2-value-text"] {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
padding: 0 0 0 8px;
margin: 0;
border: 0;
background: transparent;
outline: none;
text-align: left;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
cursor: default;
}
[data-component="select-v2"] [data-slot="select-v2-value-text"][data-placeholder-shown] {
color: var(--text-text-faint);
}
[data-component="select-v2"][data-numeric] [data-slot="select-v2-value-text"] {
font-variant-numeric: tabular-nums;
}
[data-component="select-v2"]:where([data-invalid]):not([data-disabled]) [data-slot="select-v2-value-text"] {
color: var(--state-fg-danger);
}
[data-component="select-v2"] [data-slot="select-v2-chevron"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
align-self: center;
flex: none;
width: 20px;
height: 20px;
padding: 2px;
gap: 3px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--icon-icon-muted);
pointer-events: none;
}
[data-component="select-v2"] [data-slot="select-v2-chevron"] svg {
display: block;
flex: none;
transform: rotate(180deg);
transform-origin: 50% 50%;
}
[data-component="select-v2"][data-expanded] [data-slot="select-v2-chevron"] svg {
transform: rotate(0deg);
}
/* Listbox inside menu surface */
[data-component="menu-v2-content"][data-slot="select-v2-content"] [data-slot="select-v2-listbox"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
margin: 0;
padding: 0;
list-style: none;
min-width: 0;
width: 100%;
max-height: min(320px, 70vh);
overflow-y: auto;
outline: none;
}
/* Listbox uses data-selected; menu item CSS uses data-checked — mirror accent */
[data-slot="select-v2-listbox"] [data-component="menu-v2-item"][data-selected] [data-slot="menu-v2-item-content"] {
font-weight: 530;
color: var(--menu-v2-accent);
}
[data-slot="select-v2-listbox"] [data-component="menu-v2-item"][data-selected] [data-slot="menu-v2-item-indicator"] {
color: var(--menu-v2-accent);
}
[data-slot="select-v2-listbox"] [data-component="menu-v2-item"]:not([data-selected]) [data-slot="menu-v2-item-indicator"] svg {
visibility: hidden;
}

View File

@@ -0,0 +1,174 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { Field as FieldV2 } from "./field-v2"
import { SelectV2 } from "./select-v2"
const fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"]
type Region = "North" | "South" | "East" | "West"
const cities: { city: string; region: Region }[] = [
{ city: "Boston", region: "North" },
{ city: "Miami", region: "South" },
{ city: "Atlanta", region: "South" },
{ city: "Seattle", region: "West" },
{ city: "Denver", region: "West" },
]
const docs = `### Overview
Single-select built on Kobalte with a **TextInput v2** trigger surface and **Menu v2** list styling.
### API
- \`placeholder\`: Shown in the trigger when nothing is selected (same idea as text inputs).
- \`options\`, \`current\`, \`onSelect\`: controlled selection (\`current\` is the selected option object).
- \`value\` / \`label\`: accessors when options are not plain strings.
- \`groupBy\`: groups options; section headers use menu group label styling.
- \`appearance\`: \`base\` (28px) or \`large\` (32px).
- \`invalid\`, \`disabled\`, \`numeric\`: match text input conventions.
`
export default {
title: "UI V2/Select",
id: "components-select-v2",
component: SelectV2,
tags: ["autodocs"],
parameters: {
frameHeight: "420px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
placeholder: "Pick a fruit",
invalid: false,
disabled: false,
appearance: "base",
},
argTypes: {
placeholder: {
control: "text",
},
invalid: {
control: "boolean",
},
disabled: {
control: "boolean",
},
appearance: {
control: "select",
options: ["base", "large"],
},
},
}
export const Playground = {
render: (args) => {
const [current, setCurrent] = createSignal(undefined)
return (
<SelectV2
placeholder={args.placeholder}
invalid={args.invalid}
disabled={args.disabled}
appearance={args.appearance}
options={fruits}
current={current()}
onSelect={(v) => setCurrent(v === null ? undefined : v)}
/>
)
},
}
export const Large = {
render: (args) => {
const [current, setCurrent] = createSignal(undefined)
return (
<SelectV2
placeholder={args.placeholder}
invalid={args.invalid}
disabled={args.disabled}
appearance="large"
options={fruits}
current={current()}
onSelect={(v) => setCurrent(v === null ? undefined : v)}
/>
)
},
}
export const Grouped = {
render: (args) => {
const [current, setCurrent] = createSignal(undefined)
return (
<SelectV2<(typeof cities)[0]>
placeholder={args.placeholder}
invalid={args.invalid}
disabled={args.disabled}
appearance={args.appearance}
options={cities}
current={current()}
onSelect={(v) => setCurrent(v === null ? undefined : v)}
value={(x) => x.city}
label={(x) => x.city}
groupBy={(x) => x.region}
/>
)
},
}
export const Invalid = {
render: (args) => {
const [current, setCurrent] = createSignal(undefined)
return (
<SelectV2
placeholder={args.placeholder}
invalid
disabled={args.disabled}
appearance={args.appearance}
options={fruits}
current={current()}
onSelect={(v) => setCurrent(v === null ? undefined : v)}
/>
)
},
}
export const Disabled = {
render: (args) => (
<SelectV2
placeholder={args.placeholder}
invalid={args.invalid}
disabled
appearance={args.appearance}
options={fruits}
current="Cherry"
onSelect={() => {}}
/>
),
}
export const Field = {
parameters: { frameHeight: "500px" },
render: (args) => {
const [current, setCurrent] = createSignal(undefined)
return (
<div style={{ width: "280px" }}>
<FieldV2>
<FieldV2.Label tooltip="Choose one of the available options.">Fruit</FieldV2.Label>
<FieldV2.Prefix>Optional helper</FieldV2.Prefix>
<SelectV2
placeholder={args.placeholder}
invalid={args.invalid}
disabled={args.disabled}
appearance={args.appearance}
options={fruits}
current={current()}
onSelect={(v) => setCurrent(v === null ? undefined : v)}
/>
<FieldV2.Suffix>After selection</FieldV2.Suffix>
</FieldV2>
</div>
)
},
}

View File

@@ -0,0 +1,211 @@
import { Select as Kobalte } from "@kobalte/core/select"
import {
Show,
createMemo,
onCleanup,
splitProps,
type ComponentProps,
type JSX,
} from "solid-js"
import "./select-v2.css"
function groupOptions<T>(options: T[], groupBy?: (x: T) => string): { category: string; options: T[] }[] {
if (!groupBy) {
return [{ category: "", options }]
}
const map = new Map<string, T[]>()
for (const opt of options) {
const key = groupBy(opt)
const arr = map.get(key)
if (arr) arr.push(opt)
else map.set(key, [opt])
}
return [...map.entries()].map(([category, opts]) => ({ category, options: opts }))
}
const ChevronDown = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M11 9.5L8 6.5L5 9.5"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)
const CheckSmall = () => (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path
d="M3.53564 8.17857L6.39279 11.75L12.4642 4.25"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
)
export type SelectV2Props<T> = Omit<
ComponentProps<typeof Kobalte<T, { category: string; options: T[] }>>,
| "value"
| "onChange"
| "children"
| "options"
| "itemComponent"
| "sectionComponent"
| "defaultValue"
| "multiple"
> & {
placeholder?: string
options: T[]
/** Selected option (single selection). */
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onSelect?: (value: T | null) => void
onHighlight?: (value: T | undefined) => void | (() => void)
/** Match TextInput v2 height. */
appearance?: "base" | "large"
invalid?: boolean
numeric?: boolean
children?: (item: T) => JSX.Element
}
export function SelectV2<T>(props: SelectV2Props<T>) {
const [local, others] = splitProps(props, [
"class",
"classList",
"placeholder",
"options",
"current",
"value",
"label",
"groupBy",
"onSelect",
"onHighlight",
"onOpenChange",
"children",
"appearance",
"invalid",
"numeric",
"disabled",
])
const state: { key?: string; cleanup?: void | (() => void) } = {}
const stop = () => {
state.cleanup?.()
state.cleanup = undefined
state.key = undefined
}
const keyFor = (item: T) => (local.value ? local.value(item) : String(item as string))
const move = (item: T | undefined) => {
if (!local.onHighlight) return
if (!item) {
stop()
return
}
const key = keyFor(item)
if (state.key === key) return
state.cleanup?.()
state.cleanup = local.onHighlight(item)
state.key = key
}
onCleanup(stop)
const grouped = createMemo(() => groupOptions(local.options, local.groupBy))
return (
<Kobalte<T, { category: string; options: T[] }>
{...others}
multiple={false}
disabled={local.disabled}
data-component="select-v2-root"
gutter={6}
placement="bottom-start"
value={local.current}
options={grouped()}
optionValue={(x) => (local.value ? local.value(x) : String(x as string))}
optionTextValue={(x) => (local.label ? local.label(x) : String(x as string))}
optionGroupChildren="options"
placeholder={local.placeholder}
sectionComponent={(sectionProps) => (
<Kobalte.Section>
<Show when={sectionProps.section.rawValue.category}>
<div data-slot="menu-v2-group-label">{sectionProps.section.rawValue.category}</div>
</Show>
</Kobalte.Section>
)}
itemComponent={(itemProps) => (
<Kobalte.Item
{...itemProps}
data-component="menu-v2-item"
onPointerEnter={() => move(itemProps.item.rawValue)}
onPointerMove={() => move(itemProps.item.rawValue)}
onFocus={() => move(itemProps.item.rawValue)}
>
<Kobalte.ItemLabel data-slot="menu-v2-item-content" as="span">
{local.children
? local.children(itemProps.item.rawValue)
: local.label
? local.label(itemProps.item.rawValue)
: String(itemProps.item.rawValue as string)}
</Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="menu-v2-item-indicator" forceMount>
<CheckSmall />
</Kobalte.ItemIndicator>
</Kobalte.Item>
)}
onChange={(next) => {
const v = next == null ? null : (Array.isArray(next) ? (next[0] as T) ?? null : (next as T))
local.onSelect?.(v)
stop()
}}
onOpenChange={(open) => {
local.onOpenChange?.(open)
if (!open) stop()
}}
>
<Kobalte.Trigger
as="div"
data-component="select-v2"
data-appearance={local.appearance ?? "base"}
data-invalid={local.invalid ? "" : undefined}
data-numeric={local.numeric ? "" : undefined}
disabled={local.disabled}
data-disabled={local.disabled ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="select-v2-value">
<Kobalte.Value<T> data-slot="select-v2-value-text">
{(st) => {
const selected = st.selectedOption()
if (local.label && selected != null) return local.label(selected)
return selected != null ? (selected as string) : ""
}}
</Kobalte.Value>
</div>
<span data-slot="select-v2-chevron" aria-hidden="true">
<ChevronDown />
</span>
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
data-component="menu-v2-content"
data-slot="select-v2-content"
>
<Kobalte.Listbox data-slot="select-v2-listbox" />
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}

View File

@@ -0,0 +1,146 @@
[data-component="switch"] {
position: relative;
display: flex;
align-items: center;
gap: 8px;
cursor: default;
[data-slot="switch-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;
}
[data-slot="switch-control"] {
box-sizing: border-box;
display: inline-flex;
justify-content: flex-start;
align-items: center;
padding: 2px;
width: 24px;
height: 16px;
flex-shrink: 0;
border-radius: 4px;
border: none;
background:
linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-20) 100%),
var(--background-bg-layer-03);
box-shadow: var(--elevation-switch-off);
transition:
background 90ms ease-out,
opacity 90ms ease-out,
outline-color 90ms ease-out;
}
[data-slot="switch-thumb"] {
box-sizing: border-box;
width: 12px;
height: 12px;
transform: translateX(0);
border-radius: 2px;
border: 0.5px solid var(--overlay-gradient-depth-overlay-depth-top);
background:
linear-gradient(180deg, var(--overlay-gradient-depth-overlay-depth-top) 0%, var(--overlay-gradient-depth-overlay-depth-bot) 100%),
var(--grey-200);
box-shadow: var(--elevation-elements);
transition:
transform 90ms ease-out,
width 90ms ease-out,
border-radius 90ms,
background 90ms;
}
[data-slot="switch-label"] {
display: inline-flex;
align-items: center;
height: 16px;
user-select: none;
color: var(--text-text-faint);
font-family: var(--font-family-sans);
font-size: 11px;
font-style: normal;
font-weight: 440;
line-height: 16px;
letter-spacing: 0.05px;
font-variation-settings: "slnt" 0;
}
[data-slot="switch-error"] {
color: var(--state-fg-danger);
font-family: var(--font-family-sans);
font-size: 12px;
font-weight: var(--font-weight-regular);
line-height: var(--line-height-normal);
letter-spacing: var(--letter-spacing-normal);
}
[data-slot="switch-error"]:empty {
display: none;
}
&:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-20) 100%),
var(--background-bg-layer-03);
}
&:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] {
width: 13px;
border-radius: 3px;
}
&:not([data-readonly]) [data-slot="switch-input"]:focus-visible ~ [data-slot="switch-control"] {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
}
&[data-checked] [data-slot="switch-control"] {
background:
linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-10) 100%),
var(--background-bg-accent);
box-shadow: var(--elevation-switch-on);
}
&[data-checked] [data-slot="switch-thumb"] {
transform: translateX(8px);
border-radius: 2px;
background:
linear-gradient(180deg, var(--overlay-gradient-depth-overlay-depth-top) 0%, var(--overlay-gradient-depth-overlay-depth-bot) 100%),
var(--grey-300);
}
&[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-contrast-hover), var(--overlay-simple-overlay-contrast-hover)),
linear-gradient(180deg, var(--alpha-light-0) 0%, var(--alpha-light-10) 100%),
var(--background-bg-accent);
}
&[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-thumb"] {
transform: translateX(7px);
}
&[data-disabled] {
cursor: not-allowed;
}
&[data-disabled] [data-slot="switch-control"] {
opacity: 0.5;
}
&[data-invalid] [data-slot="switch-control"] {
border-color: var(--state-border-danger);
}
&[data-readonly] {
cursor: default;
pointer-events: none;
}
}

View File

@@ -0,0 +1,64 @@
// @ts-nocheck
import { Switch } from "./switch-v2"
const docs = `### Overview
Toggle control for binary settings.
Use in settings panels or forms.
### API
- Uses Kobalte Switch props (\`checked\`, \`defaultChecked\`, \`onChange\`).
- Optional: \`hideLabel\`.
- Children render as the label.
### Variants and states
- Checked/unchecked, disabled states.
### Behavior
- Controlled or uncontrolled usage via Kobalte props.
### Accessibility
- TODO: confirm aria attributes from Kobalte.
### Theming/tokens
- Uses \`data-component="switch"\` and slot attributes.
`
export default {
title: "UI V2/Switch",
id: "components-switch-v2",
component: Switch,
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: docs,
},
},
},
args: {
defaultChecked: true,
children: "Enable notifications",
},
}
export const Basic = {}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "12px" }}>
<Switch defaultChecked>Enabled</Switch>
<Switch>Disabled</Switch>
<Switch disabled>Disabled switch</Switch>
</div>
),
}
export const HiddenLabel = {
args: {
children: "Hidden label",
hideLabel: true,
defaultChecked: true,
},
}

View File

@@ -0,0 +1,28 @@
import { Switch as Kobalte } from "@kobalte/core/switch"
import { Show, splitProps } from "solid-js"
import type { ComponentProps, ParentProps } from "solid-js"
import "./switch-v2.css"
export interface SwitchProps extends ParentProps<ComponentProps<typeof Kobalte>> {
hideLabel?: boolean
}
export function Switch(props: SwitchProps) {
const [local, others] = splitProps(props, ["children", "class", "hideLabel"])
return (
<Kobalte {...others} class={local.class} data-component="switch">
<Kobalte.Input data-slot="switch-input" />
<Show when={local.children}>
{(label) => (
<Kobalte.Label data-slot="switch-label" classList={{ "sr-only": local.hideLabel }}>
{label()}
</Kobalte.Label>
)}
</Show>
<Kobalte.Control data-slot="switch-control">
<Kobalte.Thumb data-slot="switch-thumb" />
</Kobalte.Control>
<Kobalte.ErrorMessage data-slot="switch-error" />
</Kobalte>
)
}

View File

@@ -0,0 +1,216 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="tabs-v2"] {
width: 100%;
height: 100%;
display: flex;
overflow: clip;
font-family: var(--font-family-sans);
}
[data-component="tabs-v2"][data-orientation="horizontal"] {
flex-direction: column;
}
[data-component="tabs-v2"][data-orientation="vertical"] {
flex-direction: row;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-content"] {
flex: 1;
overflow: auto;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-list"] {
display: flex;
}
[data-component="tabs-v2"][data-orientation="horizontal"] [data-slot="tabs-v2-list"] {
width: 100%;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
[data-component="tabs-v2"][data-orientation="horizontal"] [data-slot="tabs-v2-list"]::-webkit-scrollbar {
display: none;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger-wrapper"] {
position: relative;
display: flex;
align-items: center;
flex-shrink: 0;
white-space: nowrap;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"] {
display: flex;
align-items: center;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
outline: none;
font-size: 13px;
font-weight: 440;
line-height: 100%;
letter-spacing: -0.04px;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger"]:focus-visible {
outline: none;
box-shadow: none;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-close-button"] {
width: 20px;
height: 20px;
margin-left: -5px;
flex: none;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-text-faint);
}
[data-component="tabs-v2"] [data-slot="tabs-v2-close-button"]:hover {
color: var(--text-text-muted);
}
[data-component="tabs-v2"] [data-component="icon-button"] {
margin: 0;
}
[data-component="tabs-v2"] [data-slot="tabs-v2-trigger-wrapper"]:disabled {
pointer-events: none;
color: var(--text-text-faint);
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-list"],
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-list"] {
gap: 6px;
padding-inline: 8px;
height: 32px;
align-items: center;
position: relative;
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-list"]::before,
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-list"]::before {
height: 1px;
content: "";
width: calc(100% + 16px);
background-color: var(--border-border-base);
position: absolute;
bottom: 0px;
left: -8px;
}
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-list"] {
padding-bottom: 8px;
align-items: start;
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"] {
height: 100%;
gap: 4px;
color: var(--text-text-muted);
border-bottom: 1px solid transparent;
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger"] {
height: 100%;
padding: 6px;
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not([data-selected]) {
color: var(--text-text-base);
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
border-bottom-color: var(--text-text-faint);
color: var(--text-text-base);
}
[data-component="tabs-v2"][data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:not(:has([data-selected])) {
color: var(--text-text-muted);
}
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"] {
height: 24px;
border-radius: 4px;
border: 0.5px solid transparent;
box-sizing: border-box;
color: var(--text-text-muted);
}
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger"] {
width: 100%;
height: 100%;
justify-content: center;
padding: 0 6px;
border: 0.5px solid transparent;
}
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) {
background-color: var(--background-bg-layer-01);
color: var(--text-text-base);
border: 0.5px solid var(--border-border-muted);
}
[data-component="tabs-v2"][data-variant="pill"][data-orientation="horizontal"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
background-color: var(--background-bg-layer-02);
color: var(--text-text-base);
border: 0.5px solid var(--border-border-muted);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-list"] {
flex-direction: column;
width: 240px;
min-width: 200px;
height: 100%;
padding: 12px;
gap: 4px;
overflow-y: auto;
border-right: 1px solid var(--border-border-base);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-section-title"] {
width: 100%;
padding-left: 4px;
color: var(--text-text-muted);
font-size: 12px;
font-weight: 500;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] {
width: 100%;
height: 28px;
border-radius: 4px;
border: 0.5px solid transparent;
box-sizing: border-box;
color: var(--text-text-muted);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger"] {
width: 100%;
height: 100%;
justify-content: flex-start;
gap: 6px;
padding: 0 6px;
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"]:hover:not(:disabled):not(:has([data-selected])) {
color: var(--text-text-base);
}
[data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) {
background-color: var(--background-bg-layer-02);
color: var(--text-text-base);
border: 0.5px solid var(--border-border-muted);
}

View File

@@ -0,0 +1,168 @@
import { Show } from "solid-js"
import * as mod from "./tabs-v2"
import type { TabsV2Props } from "./tabs-v2"
const docs = `
Tabbed navigation for switching between related panels. Compose \`TabsV2.List\` + \`TabsV2.Trigger\` + \`TabsV2.Content\`.
> Haven't used tokens since this is an independent repo, but that's an easy change.
`
export default {
title: "UI V2/Tabs",
id: "components-tabs-v2",
component: mod.TabsV2,
tags: ["autodocs"],
parameters: {
frameHeight: "240px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
argTypes: {
variant: {
control: "select",
options: ["normal", "pill", "settings"],
},
orientation: {
control: "select",
options: ["horizontal", "vertical"],
},
},
}
export const Settings = {
args: {
variant: "settings",
orientation: "vertical",
defaultValue: "general",
},
render: (props: TabsV2Props) => (
<mod.TabsV2 {...props}>
<mod.TabsV2.List>
<mod.TabsV2.Trigger value="general">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.22266 8.83398C7.43206 8.83422 8.44043 9.69281 8.67188 10.834H14.4453V11.834H8.6709C8.43902 12.9746 7.43167 13.8338 6.22266 13.834C5.01343 13.834 4.00535 12.9747 3.77344 11.834H1.55566V10.834H3.77246C4.00394 9.69266 5.01303 8.83398 6.22266 8.83398ZM6.22266 9.83398C5.39423 9.83398 4.72266 10.5056 4.72266 11.334C4.72292 12.1622 5.39439 12.834 6.22266 12.834C7.0507 12.8337 7.72239 12.162 7.72266 11.334C7.72266 10.5057 7.05086 9.83425 6.22266 9.83398Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.77832 2.16699C10.9876 2.16722 11.996 3.02594 12.2275 4.16699H14.4453V5.16699H12.2275C11.9958 6.30781 10.9875 7.16676 9.77832 7.16699C8.56894 7.16699 7.55987 6.30797 7.32812 5.16699H1.55566V4.16699H7.32812C7.55969 3.02578 8.56878 2.16699 9.77832 2.16699ZM9.77832 3.16699C8.94989 3.16699 8.27832 3.83856 8.27832 4.66699C8.27845 5.49531 8.94997 6.16699 9.77832 6.16699C10.6064 6.16673 11.2782 5.49514 11.2783 4.66699C11.2783 3.83873 10.6065 3.16726 9.77832 3.16699Z"
fill="currentColor"
/>
</svg>
General
</mod.TabsV2.Trigger>
<mod.TabsV2.Trigger value="appearance">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.22266 8.83398C7.43206 8.83422 8.44043 9.69281 8.67188 10.834H14.4453V11.834H8.6709C8.43902 12.9746 7.43167 13.8338 6.22266 13.834C5.01343 13.834 4.00535 12.9747 3.77344 11.834H1.55566V10.834H3.77246C4.00394 9.69266 5.01303 8.83398 6.22266 8.83398ZM6.22266 9.83398C5.39423 9.83398 4.72266 10.5056 4.72266 11.334C4.72292 12.1622 5.39439 12.834 6.22266 12.834C7.0507 12.8337 7.72239 12.162 7.72266 11.334C7.72266 10.5057 7.05086 9.83425 6.22266 9.83398Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.77832 2.16699C10.9876 2.16722 11.996 3.02594 12.2275 4.16699H14.4453V5.16699H12.2275C11.9958 6.30781 10.9875 7.16676 9.77832 7.16699C8.56894 7.16699 7.55987 6.30797 7.32812 5.16699H1.55566V4.16699H7.32812C7.55969 3.02578 8.56878 2.16699 9.77832 2.16699ZM9.77832 3.16699C8.94989 3.16699 8.27832 3.83856 8.27832 4.66699C8.27845 5.49531 8.94997 6.16699 9.77832 6.16699C10.6064 6.16673 11.2782 5.49514 11.2783 4.66699C11.2783 3.83873 10.6065 3.16726 9.77832 3.16699Z"
fill="currentColor"
/>
</svg>
Appearance
</mod.TabsV2.Trigger>
</mod.TabsV2.List>
<mod.TabsV2.Content value="general">
<p class="text-[12px] text-[#5c5c5c] mx-4 my-3.5">General settings</p>
</mod.TabsV2.Content>
<mod.TabsV2.Content value="appearance">
<p class="text-[12px] text-[#5c5c5c] mx-4 my-3.5">Appearance settings</p>
</mod.TabsV2.Content>
</mod.TabsV2>
),
}
export const Normal = {
args: {
variant: "normal",
orientation: "horizontal",
defaultValue: "first",
},
render: (props: TabsV2Props) => (
<mod.TabsV2 {...props}>
<mod.TabsV2.List>
<mod.TabsV2.Trigger value="first">First</mod.TabsV2.Trigger>
<mod.TabsV2.Trigger value="second">Second</mod.TabsV2.Trigger>
</mod.TabsV2.List>
<mod.TabsV2.Content value="first">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Normal content</p>
</mod.TabsV2.Content>
<mod.TabsV2.Content value="second">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Some more alt content</p>
</mod.TabsV2.Content>
</mod.TabsV2>
),
}
export const Pill = {
args: {
variant: "pill",
orientation: "horizontal",
defaultValue: "first",
},
render: (props: TabsV2Props) => (
<mod.TabsV2 {...props}>
<mod.TabsV2.List>
<mod.TabsV2.Trigger value="first">First</mod.TabsV2.Trigger>
<mod.TabsV2.Trigger value="second">Second</mod.TabsV2.Trigger>
<mod.TabsV2.Trigger value="third">
Closable
<mod.TabsV2.CloseButton onClick={() => console.log("Close tab-3")} />
</mod.TabsV2.Trigger>
</mod.TabsV2.List>
<mod.TabsV2.Content value="first">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Normal content</p>
</mod.TabsV2.Content>
<mod.TabsV2.Content value="second">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Some more alt content</p>
</mod.TabsV2.Content>
<mod.TabsV2.Content value="third">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Closable content</p>
</mod.TabsV2.Content>
</mod.TabsV2>
),
}
export const Closable = {
args: {
variant: "normal",
orientation: "horizontal",
defaultValue: "tab-1",
},
render: (props: TabsV2Props) => (
<mod.TabsV2 {...props}>
<mod.TabsV2.List>
<mod.TabsV2.Trigger value="tab-1">
Tab 1
<Show when={true}>
<mod.TabsV2.CloseButton onClick={() => console.log("Close tab-1")} />
</Show>
</mod.TabsV2.Trigger>
<mod.TabsV2.Trigger value="tab-2">Tab 2</mod.TabsV2.Trigger>
</mod.TabsV2.List>
<mod.TabsV2.Content value="tab-1">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Closable content</p>
</mod.TabsV2.Content>
<mod.TabsV2.Content value="tab-2">
<p class="text-[12px] text-[#5c5c5c] mx-3.5 my-2">Standard content</p>
</mod.TabsV2.Content>
</mod.TabsV2>
),
}

View File

@@ -0,0 +1,151 @@
import { Tabs as Kobalte } from "@kobalte/core/tabs"
import { Show, splitProps, type JSX } from "solid-js"
import type { ComponentProps, ParentProps, Component } from "solid-js"
import "./tabs-v2.css"
export interface TabsV2Props extends ComponentProps<typeof Kobalte> {
variant?: "normal" | "pill" | "settings"
orientation?: "horizontal" | "vertical"
}
export interface TabsV2ListProps extends ComponentProps<typeof Kobalte.List> {}
export interface TabsV2TriggerProps extends ComponentProps<typeof Kobalte.Trigger> {
onMiddleClick?: () => void
/** Optional subtext shown beside the primary content (muted style) */
subtext?: JSX.Element | string
}
export interface TabsV2CloseButtonProps extends ComponentProps<"div"> {}
export interface TabsV2ContentProps extends ComponentProps<typeof Kobalte.Content> {}
function TabsV2Root(props: TabsV2Props) {
const [split, rest] = splitProps(props, ["class", "classList", "variant", "orientation"])
return (
<Kobalte
{...rest}
orientation={split.orientation}
data-component="tabs-v2"
data-variant={split.variant || "normal"}
data-orientation={split.orientation || "horizontal"}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function TabsV2List(props: TabsV2ListProps) {
const [split, rest] = splitProps(props, ["class", "classList"])
return (
<Kobalte.List
{...rest}
data-slot="tabs-v2-list"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
/>
)
}
function TabsV2Trigger(props: ParentProps<TabsV2TriggerProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children", "onMiddleClick", "subtext"])
return (
<div
data-slot="tabs-v2-trigger-wrapper"
data-value={props.value}
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
onMouseDown={(e) => {
if (e.button === 1 && split.onMiddleClick) {
e.preventDefault()
}
}}
onAuxClick={(e) => {
if (e.button === 1 && split.onMiddleClick) {
e.preventDefault()
split.onMiddleClick()
}
}}
>
<Kobalte.Trigger
{...rest}
data-slot="tabs-v2-trigger"
data-value={props.value}
>
<span class="inline-flex items-center gap-2" data-slot="tabs-v2-trigger-content">
{split.children}
<Show when={split.subtext}>
{(subtext) => (
<span data-slot="tabs-v2-subtext" class="ml-2 text-xs text-text-weak">
{subtext()}
</span>
)}
</Show>
</span>
</Kobalte.Trigger>
</div>
)
}
function TabsV2CloseButton(props: TabsV2CloseButtonProps) {
const [split, rest] = splitProps(props, ["class", "classList", "onClick"])
return (
<div
role="button"
tabindex={0}
aria-label="Close tab"
data-slot="tabs-v2-close-button"
{...rest}
classList={{
[split.class ?? ""]: !!split.class,
...split.classList,
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
if (typeof split.onClick === "function") {
split.onClick(e)
}
}}
onMouseDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10.8889 3.11108L3.11108 10.8889" stroke="currentColor" stroke-linejoin="round" />
<path d="M3.11108 3.11108L10.8889 10.8889" stroke="currentColor" stroke-linejoin="round" />
</svg>
</div>
)
}
function TabsV2Content(props: ParentProps<TabsV2ContentProps>) {
const [split, rest] = splitProps(props, ["class", "classList", "children"])
return (
<Kobalte.Content
{...rest}
data-slot="tabs-v2-content"
classList={{
...split.classList,
[split.class ?? ""]: !!split.class,
}}
>
{split.children}
</Kobalte.Content>
)
}
const TabsV2SectionTitle: Component<ParentProps> = (props) => {
return <div data-slot="tabs-v2-section-title">{props.children}</div>
}
export const TabsV2 = Object.assign(TabsV2Root, {
List: TabsV2List,
Trigger: TabsV2Trigger,
CloseButton: TabsV2CloseButton,
Content: TabsV2Content,
SectionTitle: TabsV2SectionTitle,
})

View File

@@ -0,0 +1,146 @@
[data-component="text-input-v2"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0 8px 0 0;
gap: 8px;
width: 280px;
height: 28px;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
outline-offset: 0;
background:
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
box-shadow: var(--elevation-button-neutral);
flex: none;
align-self: stretch;
transition:
background 85ms ease-out,
outline-color 85ms ease-out,
box-shadow 85ms ease-out;
}
[data-component="text-input-v2"][data-appearance="large"] {
height: 32px;
}
[data-component="text-input-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
[data-component="text-input-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
outline-color: var(--border-border-focus);
box-shadow: none;
}
[data-component="text-input-v2"]:where([data-invalid]):not([data-disabled]) {
outline-color: var(--state-fg-danger);
box-shadow: none;
}
[data-component="text-input-v2"]:where([data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-value"] {
display: flex;
flex-direction: row;
align-items: center;
align-self: stretch;
padding: 0;
min-width: 0;
flex: 1 1 auto;
min-height: 0;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-input"] {
display: block;
width: 100%;
min-width: 0;
height: 100%;
padding: 0 0 0 8px;
margin: 0;
border: 0;
background: transparent;
outline: none;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-input"]::placeholder {
color: var(--text-text-faint);
}
[data-component="text-input-v2"][data-numeric] [data-slot="text-input-v2-input"] {
font-variant-numeric: tabular-nums;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"] {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
align-self: center;
flex: none;
width: 20px;
height: 20px;
padding: 2px;
gap: 3px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--icon-icon-muted);
cursor: pointer;
outline: none;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:is(:hover, [data-state="hover"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-hover);
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--overlay-simple-overlay-pressed);
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:focus {
outline: none;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 1px;
}
[data-component="text-input-v2"]:where([data-disabled]) [data-slot="text-input-v2-icon-button"] {
cursor: not-allowed;
pointer-events: none;
}
[data-component="text-input-v2"] [data-slot="text-input-v2-icon-button"] [data-slot="icon-svg"] {
display: block;
flex: none;
color: currentColor;
}
[data-component="text-input-v2"][data-invalid]:not([data-disabled]) [data-slot="text-input-v2-input"] {
color: var(--state-fg-danger);
caret-color: var(--state-fg-danger);
}
[data-component="text-input-v2"][data-invalid]:not([data-disabled]) [data-slot="text-input-v2-input"]::placeholder {
color: var(--state-fg-danger);
opacity: 1;
}

View File

@@ -0,0 +1,145 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { Field as FieldV2 } from "./field-v2"
import { TextInputV2 } from "./text-input-v2"
const docs = `### Overview
Compact single-line text field with neutral elevation, optional trailing copy action, and theme tokens.
### API
- Forwards native \`input\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, \`name\`, \`type\`, etc.).
- \`showCopyButton\`: Renders the trailing outline-copy control.
- \`copyLabel\`: Accessible name for the copy button (default: "Copy").
- \`onCopyClick\`: Handler for the copy button.
- \`invalid\`: Error outline and danger text color.
- \`appearance\`: \`"base"\` (28px) or \`"large"\` (32px).
### States
- **Hover**: neutral overlay on the raised surface.
- **Focus** (\`:focus-within\`): focus border, elevation removed.
- **Invalid**: danger border and text.
- **Disabled**: 50% opacity.
- Uses \`data-component="text-input-v2"\` with \`--background-bg-base\`, \`--elevation-button-neutral\`, \`--text-text-faint\` (placeholder), and \`--icon-icon-muted\` (copy icon).
### Field
Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story.
`
export default {
title: "UI V2/TextInput",
id: "components-text-input-v2",
component: TextInputV2,
tags: ["autodocs"],
parameters: {
frameHeight: "300px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
placeholder: "Placeholder",
showCopyButton: false,
disabled: false,
invalid: false,
appearance: "base",
},
argTypes: {
appearance: {
control: "select",
options: ["base", "large"],
},
showCopyButton: {
control: "boolean",
},
disabled: {
control: "boolean",
},
invalid: {
control: "boolean",
},
placeholder: {
control: "text",
},
},
}
export const Playground = {}
export const WithCopyButton = {
args: {
placeholder: "api.example.com/v1",
defaultValue: "https://api.example.com/v1",
showCopyButton: true,
copyLabel: "Copy URL",
},
}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("Controlled value")
return (
<div style={{ display: "grid", gap: "12px" }}>
<TextInputV2
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
placeholder="Type here…"
/>
<div
style={{
"font-family": "var(--font-family-sans)",
"font-size": "12px",
color: "var(--text-text-faint)",
}}
>
Value: {value()}
</div>
</div>
)
},
}
export const Appearances = {
render: () => (
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
<TextInputV2 appearance="base" placeholder="Base (28px)" defaultValue="Base" />
<TextInputV2 appearance="large" placeholder="Large (32px)" defaultValue="Large" />
<TextInputV2 appearance="large" placeholder="Large with copy" defaultValue="copy-me" showCopyButton />
</div>
),
}
export const Field = {
parameters: { frameHeight: "500px" },
render: () => (
<div style={{ display: "grid", gap: "24px", width: "280px" }}>
<FieldV2>
<FieldV2.Label tooltip="Additional context">Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<TextInputV2 placeholder="Text" showCopyButton />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
<FieldV2 invalid>
<FieldV2.Label>Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<TextInputV2 placeholder="Text" defaultValue="Invalid" showCopyButton />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
</div>
),
}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
<TextInputV2 placeholder="Default" />
<TextInputV2 placeholder="With value" defaultValue="Hello world" />
<TextInputV2 placeholder="With copy" defaultValue="copy-me" showCopyButton />
<TextInputV2 placeholder="Invalid" defaultValue="Invalid value" invalid showCopyButton />
<TextInputV2 placeholder="Disabled" disabled />
<TextInputV2 placeholder="Disabled with value" defaultValue="Read only" disabled showCopyButton />
</div>
),
}

View File

@@ -0,0 +1,67 @@
import { type ComponentProps, Show, splitProps } from "solid-js"
import { Icon } from "./icon"
import "./text-input-v2.css"
export interface TextInputV2Props extends Omit<ComponentProps<"input">, "type"> {
/** Show the trailing copy action. */
showCopyButton?: boolean
/** Accessible label for the copy button. */
copyLabel?: string
onCopyClick?: (event: MouseEvent) => void
/** Apply tabular numerals to the field value. */
numeric?: boolean
/** Error styling for the field and value text. */
invalid?: boolean
/** `base` is 28px tall; `large` is 32px tall. */
appearance?: "base" | "large"
type?: ComponentProps<"input">["type"]
}
export function TextInputV2(props: TextInputV2Props) {
const [local, inputProps] = splitProps(props, [
"class",
"classList",
"showCopyButton",
"copyLabel",
"onCopyClick",
"numeric",
"invalid",
"appearance",
"disabled",
])
return (
<div
data-component="text-input-v2"
data-disabled={local.disabled ? "" : undefined}
data-invalid={local.invalid ? "" : undefined}
data-numeric={local.numeric ? "" : undefined}
data-appearance={local.appearance ?? "base"}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<div data-slot="text-input-v2-value">
<input
{...inputProps}
type={inputProps.type ?? "text"}
disabled={local.disabled}
aria-invalid={local.invalid ? true : undefined}
data-slot="text-input-v2-input"
/>
</div>
<Show when={local.showCopyButton}>
<button
type="button"
data-slot="text-input-v2-icon-button"
aria-label={local.copyLabel ?? "Copy"}
disabled={local.disabled}
onClick={local.onCopyClick}
>
<Icon name="copy" />
</button>
</Show>
</div>
)
}

View File

@@ -0,0 +1,125 @@
* {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: geometricPrecision;
}
[data-component="text-shimmer-v2"] {
--_step: 45ms;
--_duration: 1200ms;
--_swap: 220ms;
--_index: 0;
--_angle: 90deg;
--_spread: 5.2ch;
--_size: 360%;
--_base-color: var(--text-text-muted);
--_peak-color: var(--text-text-base);
--_sweep: linear-gradient(
var(--_angle),
transparent calc(50% - var(--_spread)),
var(--_peak-color) 50%,
transparent calc(50% + var(--_spread))
);
--_base: linear-gradient(var(--_base-color), var(--_base-color));
display: inline-flex;
align-items: baseline;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
user-select: none;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-char"] {
display: inline-grid;
white-space: pre;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-base"],
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"] {
grid-area: 1 / 1;
white-space: pre;
transition: opacity var(--_swap) ease-out;
font: inherit;
letter-spacing: inherit;
line-height: inherit;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-base"] {
color: inherit;
opacity: 1;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"] {
color: var(--_base-color);
opacity: 0;
}
[data-component="text-shimmer-v2"][data-active="true"] [data-slot="text-shimmer-v2-shimmer"] {
opacity: 1;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"][data-run="true"] {
animation-name: text-shimmer-v2-sweep;
animation-duration: var(--_duration);
animation-iteration-count: infinite;
animation-timing-function: linear;
animation-fill-mode: both;
animation-delay: calc(var(--_step) * var(--_index) * -1);
will-change: background-position;
}
@keyframes text-shimmer-v2-sweep {
0% {
background-position:
100% 0,
0 0;
}
100% {
background-position:
0% 0,
0 0;
}
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"] {
color: transparent;
-webkit-text-fill-color: transparent;
background-image: var(--_sweep), var(--_base);
background-size:
var(--_size) 100%,
100% 100%;
background-position:
100% 0,
0 0;
background-repeat: no-repeat;
-webkit-background-clip: text;
background-clip: text;
}
[data-component="text-shimmer-v2"][data-active="true"] [data-slot="text-shimmer-v2-base"] {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-base"],
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"] {
transition-duration: 0ms;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-shimmer"] {
animation: none !important;
color: inherit;
-webkit-text-fill-color: currentColor;
background-image: none;
}
[data-component="text-shimmer-v2"] [data-slot="text-shimmer-v2-base"] {
opacity: 1 !important;
}
}

View File

@@ -0,0 +1,61 @@
import { TextShimmerV2 } from "./text-shimmer-v2"
const docs = `### Overview
Animated shimmer effect for loading text placeholders.
### API
- Required: \`text\` string.
- Optional: \`as\`, \`active\`, \`offset\`, \`class\`.
### Behavior
- Uses a moving gradient sweep clipped to text.
- \`offset\` lets multiple shimmers run out-of-phase.
### Accessibility
- Uses \`aria-label\` with the full text.
### Theming
- Uses \`data-component="text-shimmer-v2"\` and CSS custom properties for timing and colors.
`
export default {
title: "UI V2/TextShimmer",
id: "components-text-shimmer-v2",
component: TextShimmerV2,
tags: ["autodocs"],
parameters: {
frameBackground: "#fff",
layout: "padded",
docs: {
description: {
component: docs,
},
},
},
}
export const Active = {
render: () => (
<span style={{ "font-size": "13px", "font-weight": "440", "font-family": "Inter, system-ui, sans-serif" }}>
<TextShimmerV2 text="Loading..." active={true} />
</span>
),
}
export const Inactive = {
render: () => (
<span style={{ "font-size": "13px", "font-weight": "440", "font-family": "Inter, system-ui, sans-serif" }}>
<TextShimmerV2 text="Static text" active={false} />
</span>
),
}
export const WithOffset = {
render: () => (
<div style={{ display: "flex", "flex-direction": "column", gap: "8px", "font-size": "13px", "font-weight": "440", "font-family": "Inter, system-ui, sans-serif" }}>
<TextShimmerV2 text="First line" active={true} offset={0} />
<TextShimmerV2 text="Second line" active={true} offset={5} />
<TextShimmerV2 text="Third line" active={true} offset={10} />
</div>
),
}

View File

@@ -0,0 +1,63 @@
import { createEffect, createMemo, createSignal, onCleanup, type ValidComponent } from "solid-js"
import { Dynamic } from "solid-js/web"
import "./text-shimmer-v2.css"
export const TextShimmerV2 = <T extends ValidComponent = "span">(props: {
text: string
class?: string
as?: T
active?: boolean
offset?: number
}) => {
const text = createMemo(() => props.text ?? "")
const active = createMemo(() => props.active ?? true)
const offset = createMemo(() => props.offset ?? 0)
const [run, setRun] = createSignal(active())
const swap = 220
let timer: ReturnType<typeof setTimeout> | undefined
createEffect(() => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
if (active()) {
setRun(true)
return
}
timer = setTimeout(() => {
timer = undefined
setRun(false)
}, swap)
})
onCleanup(() => {
if (!timer) return
clearTimeout(timer)
})
return (
<Dynamic
component={props.as ?? "span"}
data-component="text-shimmer-v2"
data-active={active() ? "true" : "false"}
class={props.class}
aria-label={text()}
style={{
"--_swap": `${swap}ms`,
"--_index": `${offset()}`,
}}
>
<span data-slot="text-shimmer-v2-char">
<span data-slot="text-shimmer-v2-base" aria-hidden="true">
{text()}
</span>
<span data-slot="text-shimmer-v2-shimmer" data-run={run() ? "true" : "false"} aria-hidden="true">
{text()}
</span>
</span>
</Dynamic>
)
}

View File

@@ -0,0 +1,81 @@
[data-component="textarea-v2"] {
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 0 2px 2px 0;
width: 280px;
min-height: 80px;
border: 0;
border-radius: 6px;
outline: 1px solid transparent;
outline-offset: 0;
background:
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
box-shadow: var(--elevation-button-neutral);
flex: none;
align-self: stretch;
transition:
background 85ms ease-out,
outline-color 85ms ease-out,
box-shadow 85ms ease-out;
}
[data-component="textarea-v2"]:where(:hover):not([data-disabled], [data-invalid]):not(:focus-within) {
background:
linear-gradient(0deg, var(--overlay-simple-overlay-hover), var(--overlay-simple-overlay-hover)),
linear-gradient(180deg, var(--alpha-light-2) 0%, var(--alpha-light-0) 100%),
var(--background-bg-base);
}
[data-component="textarea-v2"]:where(:focus-within):not([data-disabled], [data-invalid]) {
outline-color: var(--border-border-focus);
box-shadow: none;
}
[data-component="textarea-v2"]:where([data-invalid]):not([data-disabled]) {
outline-color: var(--state-fg-danger);
box-shadow: none;
}
[data-component="textarea-v2"]:where([data-disabled]) {
opacity: 0.5;
cursor: not-allowed;
}
[data-component="textarea-v2"] [data-slot="textarea-v2-textarea"] {
display: block;
width: 100%;
min-width: 0;
min-height: 80px;
height: 100%;
padding: 8px;
margin: 0;
border: 0;
background: transparent;
outline: none;
resize: vertical;
font-family: var(--font-family-sans);
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1.35;
letter-spacing: -0.04px;
color: var(--text-text-base);
font-variation-settings: "slnt" 0;
}
[data-component="textarea-v2"] [data-slot="textarea-v2-textarea"]::placeholder {
color: var(--text-text-faint);
}
[data-component="textarea-v2"][data-invalid]:not([data-disabled]) [data-slot="textarea-v2-textarea"] {
color: var(--state-fg-danger);
caret-color: var(--state-fg-danger);
}
[data-component="textarea-v2"][data-invalid]:not([data-disabled]) [data-slot="textarea-v2-textarea"]::placeholder {
color: var(--state-fg-danger);
opacity: 1;
}

View File

@@ -0,0 +1,115 @@
// @ts-nocheck
import { createSignal } from "solid-js"
import { Field as FieldV2 } from "./field-v2"
import { TextareaV2 } from "./textarea-v2"
const docs = `### Overview
Multiline text field with the same neutral elevation, states, and tokens as TextInput v2.
### API
- Forwards native \`textarea\` props (\`value\`, \`defaultValue\`, \`placeholder\`, \`disabled\`, \`name\`, \`rows\`, etc.).
- \`invalid\`: Error outline and danger text color.
### States
- **Hover**: neutral overlay on the raised surface.
- **Focus** (\`:focus-within\`): focus outline, elevation removed.
- **Invalid**: danger outline and text.
- **Disabled**: 50% opacity.
### Field
Compose with \`Field\` for label, helper prefix/suffix, and tooltip — see the **Field** story.
`
export default {
title: "UI V2/Textarea",
id: "components-textarea-v2",
component: TextareaV2,
tags: ["autodocs"],
parameters: {
frameHeight: "400px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
args: {
placeholder: "Placeholder",
disabled: false,
invalid: false,
rows: 3,
},
argTypes: {
disabled: {
control: "boolean",
},
invalid: {
control: "boolean",
},
placeholder: {
control: "text",
},
rows: {
control: { type: "number", min: 1, max: 12 },
},
},
}
export const Playground = {}
export const Controlled = {
render: () => {
const [value, setValue] = createSignal("Controlled value")
return (
<div style={{ display: "grid", gap: "12px", width: "280px" }}>
<TextareaV2
value={value()}
onInput={(e) => setValue(e.currentTarget.value)}
placeholder="Type here…"
/>
<div
style={{
"font-family": "var(--font-family-sans)",
"font-size": "12px",
color: "var(--text-text-faint)",
}}
>
Value: {value()}
</div>
</div>
)
},
}
export const Field = {
parameters: { frameHeight: "500px" },
render: () => (
<div style={{ display: "grid", gap: "24px", width: "280px" }}>
<FieldV2>
<FieldV2.Label tooltip="Additional context">Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<TextareaV2 placeholder="Text" />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
<FieldV2 invalid>
<FieldV2.Label>Label</FieldV2.Label>
<FieldV2.Prefix>Prefix</FieldV2.Prefix>
<TextareaV2 placeholder="Text" defaultValue="Invalid value" />
<FieldV2.Suffix>Suffix</FieldV2.Suffix>
</FieldV2>
</div>
),
}
export const States = {
render: () => (
<div style={{ display: "grid", gap: "20px", width: "280px" }}>
<TextareaV2 placeholder="Default" />
<TextareaV2 placeholder="With value" defaultValue="Hello world" />
<TextareaV2 placeholder="Invalid" defaultValue="Invalid value" invalid />
<TextareaV2 placeholder="Disabled" disabled />
<TextareaV2 placeholder="Disabled with value" defaultValue="Read only" disabled />
</div>
),
}

View File

@@ -0,0 +1,37 @@
import { type ComponentProps, splitProps } from "solid-js"
import "./textarea-v2.css"
export interface TextareaV2Props extends ComponentProps<"textarea"> {
/** Error styling for the field and value text. */
invalid?: boolean
}
export function TextareaV2(props: TextareaV2Props) {
const [local, textareaProps] = splitProps(props, [
"class",
"classList",
"invalid",
"disabled",
"rows",
])
return (
<div
data-component="textarea-v2"
data-disabled={local.disabled ? "" : undefined}
data-invalid={local.invalid ? "" : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<textarea
{...textareaProps}
rows={local.rows ?? 3}
disabled={local.disabled}
aria-invalid={local.invalid ? true : undefined}
data-slot="textarea-v2-textarea"
/>
</div>
)
}

View File

@@ -0,0 +1,203 @@
[data-component="toast-v2-region"] {
position: fixed;
bottom: 48px;
right: 32px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 12px;
max-width: min(320px, calc(100vw - 64px));
max-height: none;
width: 100%;
overflow: visible;
pointer-events: none;
[data-slot="toast-v2-list"] {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
max-height: none;
overflow-y: visible;
overflow-x: visible;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
[data-component="toast-v2"] {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
width: 320px;
padding: 12px;
max-height: min(420px, calc(100dvh - 96px));
overflow: hidden;
pointer-events: auto;
transition: transform 140ms ease-out;
border-radius: 8px;
color: var(--text-text-base);
background: var(--background-bg-layer-01);
box-shadow: var(--elevation-floating);
&[data-opened] {
animation: toastV2PopIn 140ms ease-out;
}
&[data-closed] {
animation: toastV2PopOut 100ms ease-in forwards;
}
[data-slot="toast-v2-header"] {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
}
[data-slot="toast-v2-icon"] {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
min-width: 16px;
min-height: 20px;
[data-component="icon"] {
color: var(--text-text-base);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
> * {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
}
[data-slot="toast-v2-content"] {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
[data-slot="toast-v2-title"] {
color: var(--text-text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: "Inter Variable";
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-description"] {
color: var(--text-text-muted);
text-wrap-style: pretty;
overflow-wrap: anywhere;
word-break: break-word;
font-family: "Inter Variable";
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-actions"] {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-left: 28px;
}
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
min-height: 24px;
font-family: "Inter Variable";
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
}
[data-slot="toast-v2-close-button"] {
flex-shrink: 0;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
display: inline-flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
display: block;
}
}
}
@keyframes toastV2PopIn {
from {
opacity: 0.8;
transform: translateY(6px);
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes toastV2PopOut {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(6px);
opacity: 0.8;
}
}

View File

@@ -0,0 +1,209 @@
import * as mod from "./toast-v2";
import { ButtonV2 } from "./button-v2";
const docs = `### Overview
Toast notifications with optional icons, actions, and progress.
Use brief titles/descriptions; limit actions to 1-2.
### API
- Use \`showToastV2\` or \`showPromiseToastV2\` to trigger toasts.
- Render \`ToastV2.Region\` once per page.
- \`ToastV2\` subcomponents compose the structure.
### Styling and states
- Single toast style; provide any custom icon element via \`icon\`.
- Optional actions and persistent toasts.
### Behavior
- Toasts render in a portal and auto-dismiss unless persistent.
### Accessibility
- TODO: confirm aria-live behavior from Kobalte Toast.
### Theming/tokens
- Uses \`data-component="toast-v2"\` and slot data attributes.
`;
export default {
title: "UI V2/Toast",
id: "components-toast-v2",
component: mod.ToastV2,
tags: ["autodocs"],
parameters: {
frameHeight: "320px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
};
export const AllExamples = {
render: () => (
<div style={{ display: "grid", gap: "12px" }}>
<mod.ToastV2.Region />
<ButtonV2
class="w-fit"
variant="neutral"
onClick={() =>
mod.showToastV2({
title: "Download started...",
description: "23% · 2 min left",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M13.5554 10.4453V13.5564H11.7777H4.22211C3.23989 13.5564 2.44434 13.5564 2.44434 13.5564V10.4453"
stroke="var(--icon-icon-base)"
/>
<path
d="M4.88867 6L7.99978 9.11111L11.1109 6"
stroke="var(--icon-icon-base)"
/>
<path d="M8 9.11198V2.44531" stroke="var(--icon-icon-base)" />
</svg>
),
actions: [
{
label: "Run in background",
variant: "primary",
onClick: "dismiss",
},
{ label: "Cancel", variant: "secondary", onClick: "dismiss" },
],
})
}
>
Show download toast
</ButtonV2>
<ButtonV2
class="w-fit"
variant="neutral"
onClick={() =>
mod.showToastV2({
title: "Saved",
description: "Your changes are stored",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.00011 14.4436C11.5593 14.4436 14.4446 11.5583 14.4446 7.99913C14.4446 4.43996 11.5593 1.55469 8.00011 1.55469C4.44094 1.55469 1.55566 4.43996 1.55566 7.99913C1.55566 11.5583 4.44094 14.4436 8.00011 14.4436Z"
stroke="#198B43"
/>
<path
d="M5.11133 8.22135L7.11133 10.4436L10.8891 5.55469"
stroke="#198B43"
/>
</svg>
),
})
}
>
Show saved toast
</ButtonV2>
<ButtonV2
class="w-fit"
variant="neutral"
onClick={() =>
mod.showToastV2({
title: "Saving...",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect x="8.75" y="5.25" width="2" height="2" fill="#3A3A3A" />
<rect x="8.75" y="8.75" width="2" height="2" fill="#3A3A3A" />
<rect x="8.75" y="12.25" width="2" height="2" fill="#3A3A3A" />
<rect x="5.25" y="12.25" width="2" height="2" fill="#3A3A3A" />
<rect
opacity="0.3"
x="5.25"
y="1.75"
width="2"
height="2"
fill="#3A3A3A"
/>
<rect
opacity="0.3"
x="5.25"
y="5.25"
width="2"
height="2"
fill="#3A3A3A"
/>
<rect
opacity="0.3"
x="5.25"
y="8.75"
width="2"
height="2"
fill="#3A3A3A"
/>
<rect
opacity="0.3"
x="8.75"
y="1.75"
width="2"
height="2"
fill="#3A3A3A"
/>
</svg>
),
persistent: true,
})
}
>
Show saving toast
</ButtonV2>
<ButtonV2
class="w-fit"
variant="neutral"
onClick={() =>
mod.showToastV2({
title: "Unsaved changes",
description: "You have made 4 edits...",
icon: (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 6.33334V8.99392M7.78099 10.9934H8.23448M8 2L1.5 13H14.5L8 2Z"
stroke="#CB9F34"
stroke-linecap="square"
/>
</svg>
),
actions: [
{ label: "Save changes", variant: "primary", onClick: "dismiss" },
{ label: "Cancel", variant: "secondary", onClick: "dismiss" },
],
})
}
>
Show unsaved changes toast
</ButtonV2>
</div>
),
};

View File

@@ -0,0 +1,148 @@
import { Toast as Kobalte, toaster } from "@kobalte/core/toast"
import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast"
import type { ComponentProps, JSX } from "solid-js"
import { Show, children } from "solid-js"
import { Portal } from "solid-js/web"
import { ButtonV2 } from "./button-v2"
import "./toast-v2.css"
export interface ToastV2RegionProps extends ComponentProps<typeof Kobalte.Region> {}
function ToastV2Region(props: ToastV2RegionProps) {
return (
<Portal>
<Kobalte.Region data-component="toast-v2-region" {...props}>
<Kobalte.List data-slot="toast-v2-list" />
</Kobalte.Region>
</Portal>
)
}
export interface ToastV2RootComponentProps extends ToastRootProps {
class?: string
classList?: ComponentProps<"li">["classList"]
children?: JSX.Element
}
function ToastV2Root(props: ToastV2RootComponentProps) {
return (
<Kobalte
data-component="toast-v2"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
{...props}
/>
)
}
function ToastV2Icon(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-icon" {...props} />
}
function ToastV2Content(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-content" {...props} />
}
function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) {
return <Kobalte.Title data-slot="toast-v2-title" {...props} />
}
function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) {
return <Kobalte.Description data-slot="toast-v2-description" {...props} />
}
function ToastV2Actions(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-actions" {...props} />
}
function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
return (
<Kobalte.CloseButton
data-slot="toast-v2-close-button"
aria-label="Dismiss"
{...props}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4.25 11.75L11.75 4.25" stroke="#FAFAFA" />
<path d="M11.75 11.75L4.25 4.25" stroke="#FAFAFA" />
</svg>
</Kobalte.CloseButton>
)
}
export const ToastV2 = Object.assign(ToastV2Root, {
Region: ToastV2Region,
Icon: ToastV2Icon,
Content: ToastV2Content,
Title: ToastV2Title,
Description: ToastV2Description,
Actions: ToastV2Actions,
CloseButton: ToastV2CloseButton,
})
export { toaster as toasterV2 }
export interface ToastV2Action {
label: string
variant?: "primary" | "secondary"
onClick: "dismiss" | (() => void)
}
export interface ToastV2Options {
title?: string
description?: string
icon?: JSX.Element
duration?: number
persistent?: boolean
actions?: ToastV2Action[]
}
export function showToastV2(options: ToastV2Options | string) {
const opts = typeof options === "string" ? { description: options } : options
const resolvedIcon = children(() => opts.icon)
return toaster.show((props) => (
<ToastV2 toastId={props.toastId} duration={opts.duration} persistent={opts.persistent}>
<div data-slot="toast-v2-header">
<Show when={resolvedIcon()}>
<ToastV2.Icon>{resolvedIcon()}</ToastV2.Icon>
</Show>
<ToastV2.Content>
<Show when={opts.title}>
<ToastV2.Title>{opts.title}</ToastV2.Title>
</Show>
<Show when={opts.description}>
<ToastV2.Description>{opts.description}</ToastV2.Description>
</Show>
</ToastV2.Content>
<ToastV2.CloseButton />
</div>
<Show when={opts.actions?.length}>
<ToastV2.Actions>
{opts.actions!.map((action) => (
<ButtonV2
variant={action.variant === "secondary" ? "ghost" : "neutral"}
size="small"
data-action-variant={action.variant ?? "primary"}
onClick={() => {
if (typeof action.onClick === "function") {
action.onClick()
}
toaster.dismiss(props.toastId)
}}
>
{action.label}
</ButtonV2>
))}
</ToastV2.Actions>
</Show>
</ToastV2>
))
}
export interface ToastV2PromiseOptions<T, U = unknown> {
loading?: JSX.Element
success?: (data: T) => JSX.Element
error?: (error: U) => JSX.Element
}

View File

@@ -0,0 +1,201 @@
[data-component="tool-error-card"] {
--tec-border: var(--state-fg-danger);
--tec-title: var(--text-text-base);
--tec-sep: var(--text-text-muted);
--tec-subtitle: var(--text-text-muted);
--tec-suffix: var(--text-text-faint);
--tec-chevron: var(--text-text-faint);
--tec-icon: var(--icon-icon-base);
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 0;
width: 100%;
padding: 0 0 0 10px;
gap: 8px;
border-left: 2px solid var(--tec-border);
font-family: var(--font-family-sans), var(--sans), system-ui, sans-serif;
font-variant-numeric: tabular-nums;
[data-slot="tool-error-card-trigger"] {
box-sizing: border-box;
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
width: 100%;
min-width: 0;
min-height: 24px;
padding: 2px 0;
margin: 0;
text-align: left;
color: inherit;
outline: none;
border-radius: 2px;
}
[data-slot="tool-error-card-trigger"]:focus-visible {
outline: 2px solid var(--border-border-focus);
outline-offset: 2px;
}
[data-slot="tool-error-card-icon-wrap"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 16px;
height: 20px;
box-sizing: border-box;
color: var(--tec-icon);
}
[data-slot="tool-error-card-ban"] {
display: block;
flex-shrink: 0;
}
[data-slot="tool-error-card-loader"] {
display: block;
flex-shrink: 0;
width: 16px;
height: 16px;
animation: tool-error-card-spin 0.65s linear infinite;
transform-origin: 50% 50%;
}
[data-slot="tool-error-card-main"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
min-width: 0;
min-height: 20px;
flex: 1 1 auto;
}
[data-slot="tool-error-card-labels"] {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 6px;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
[data-slot="tool-error-card-title"] {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 100%;
height: 20px;
font-size: 13px;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--tec-title);
user-select: none;
}
[data-slot="tool-error-card-sep"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
height: 20px;
font-size: 11px;
font-weight: 530;
line-height: 20px;
letter-spacing: 0.05px;
color: var(--tec-sep);
user-select: none;
}
[data-slot="tool-error-card-subtitle"] {
display: flex;
align-items: center;
min-width: 0;
height: 20px;
flex: 0 1 auto;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--tec-subtitle);
user-select: none;
}
a[data-slot="tool-error-card-subtitle"] {
display: flex;
align-items: center;
min-width: 0;
height: 20px;
flex: 0 1 auto;
flex-shrink: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
user-select: none;
}
[data-slot="tool-error-card-chevron-wrap"] {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 14px;
height: 20px;
color: var(--tec-chevron);
user-select: none;
}
[data-slot="tool-error-card-chevron"] {
display: block;
flex-shrink: 0;
width: 14px;
height: 14px;
color: var(--tec-chevron);
transition: transform 0.15s ease-out;
}
&[data-expanded] [data-slot="tool-error-card-chevron"] {
transform: rotate(90deg);
}
[data-slot="tool-error-card-content"] {
box-sizing: border-box;
min-width: 0;
}
[data-slot="tool-error-card-suffix"] {
padding: 0 0 0 24px;
font-size: 13px;
font-weight: 440;
line-height: 1;
letter-spacing: -0.04px;
color: var(--tec-suffix);
white-space: pre-wrap;
word-break: break-word;
}
}
@keyframes tool-error-card-spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,91 @@
import { createSignal } from "solid-js"
import { ButtonV2 } from "./button-v2"
import { ToolErrorCardV2, type ToolErrorCardV2Props } from "./tool-error-card-v2"
const docs = `### Overview
Compact tool error row with optional expandable detail, aligned to the OpenCode design system spec.
### API
- \`ToolErrorCardV2\` wraps Kobalte \`Collapsible\` directly. Pass \`open\`, \`defaultOpen\`, and \`onOpenChange\` like any disclosure (controlled when \`open\` is defined).
- Without a non-empty \`suffix\`, the card is not expandable (\`disabled\` on the collapsible root).
### Theming
- Uses \`data-component="tool-error-card"\` and slot attributes; colors are CSS variables on the root (\`--tec-*\`).
`
export default {
title: "UI V2/ToolErrorCard",
id: "components-tool-error-card-v2",
component: ToolErrorCardV2,
tags: ["autodocs"],
parameters: {
frameBackground: "#fff",
layout: "padded",
docs: {
description: {
component: docs,
},
},
},
}
export const Default = {
args: {
title: "Read",
subtitle: "Permission denied",
suffix: "The tool could not access the requested path.",
defaultOpen: false,
} satisfies ToolErrorCardV2Props,
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
}
export const Loading = {
args: {
title: "Read",
subtitle: "Working",
suffix: "Details appear when the tool finishes.",
loading: true,
defaultOpen: false,
} satisfies ToolErrorCardV2Props,
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
}
export const SubtitleLink = {
args: {
title: "Task",
subtitle: "View logs",
subtitleHref: "https://example.com",
suffix: "Subagent exited with code 1.",
defaultOpen: false,
} satisfies ToolErrorCardV2Props,
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
}
export const NoSuffixDisabled = {
args: {
title: "List",
subtitle: "No detail",
defaultOpen: false,
} satisfies ToolErrorCardV2Props,
render: (args: ToolErrorCardV2Props) => <ToolErrorCardV2 {...args} />,
}
export const Controlled = {
render: () => {
const [open, setOpen] = createSignal(false)
return (
<div style={{ display: "flex", "flex-direction": "column", gap: "24px", "max-width": "420px" }}>
<ButtonV2 type="button" classList={{ "w-fit": true }} onClick={() => setOpen((o) => !o)}>
Toggle from outside: {open() ? "Open" : "Closed"}
</ButtonV2>
<ToolErrorCardV2
title="Grep"
subtitle="Timeout"
suffix="Operation exceeded 30s."
open={open()}
onOpenChange={setOpen}
/>
</div>
)
},
}

View File

@@ -0,0 +1,176 @@
import { Collapsible } from "@kobalte/core/collapsible"
import {
type ComponentProps,
type JSX,
Show,
createMemo,
splitProps,
} from "solid-js"
import "./tool-error-card-v2.css"
function BanIcon() {
return (
<svg
data-slot="tool-error-card-ban"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M3.44283 12.5575L12.5495 3.45081M14.4446 8.00011C14.4446 11.5593 11.5593 14.4446 8.00011 14.4446C4.44094 14.4446 1.55566 11.5593 1.55566 8.00011C1.55566 4.44094 4.44094 1.55566 8.00011 1.55566C11.5593 1.55566 14.4446 4.44094 14.4446 8.00011Z"
stroke="currentColor"
/>
</svg>
)
}
/** duo-progress-25: faint track ring + ~25% solid arc (Figma OpenCode DS) */
function LoaderIcon() {
const r = 5.9
return (
<svg
data-slot="tool-error-card-loader"
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g transform="translate(8 8)">
<circle
r={r}
fill="none"
stroke="var(--icon-icon-base)"
stroke-width="1"
stroke-opacity="0.3"
transform="rotate(-90)"
/>
<circle
r={r}
fill="none"
stroke="var(--icon-icon-base)"
stroke-width="1"
pathLength="100"
stroke-dasharray="25 75"
transform="rotate(-90)"
/>
</g>
</svg>
)
}
function ChevronIcon() {
return (
<svg
data-slot="tool-error-card-chevron"
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path
d="M5.90795 9.62425C5.61628 9.81865 5.25 9.57825 5.25 9.19235V4.80837C5.25 4.42247 5.61628 4.18204 5.90795 4.37648L9.1959 6.56846C9.48535 6.7614 9.48535 7.2393 9.1959 7.43224L5.90795 9.62425Z"
fill="currentColor"
/>
</svg>
)
}
export interface ToolErrorCardV2Props extends Omit<ComponentProps<"div">, "children" | "title"> {
title: JSX.Element | string
subtitle: JSX.Element | string
suffix?: JSX.Element | string
loading?: boolean
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
/** When set, subtitle renders as a link (clicks do not toggle expand). */
subtitleHref?: string
}
export function ToolErrorCardV2(props: ToolErrorCardV2Props) {
const [local, rest] = splitProps(props, [
"title",
"subtitle",
"suffix",
"loading",
"open",
"defaultOpen",
"onOpenChange",
"subtitleHref",
"class",
"classList",
])
const hasSuffix = createMemo(() => {
const s = local.suffix
if (s == null) return false
if (typeof s === "string") return s.length > 0
return true
})
return (
<Collapsible
{...rest}
data-component="tool-error-card"
open={local.open}
defaultOpen={local.defaultOpen}
onOpenChange={local.onOpenChange}
disabled={!hasSuffix()}
aria-busy={local.loading ? true : undefined}
classList={{
...local.classList,
[local.class ?? ""]: !!local.class,
}}
>
<Collapsible.Trigger
as="div"
role="button"
data-slot="tool-error-card-trigger"
>
<span data-slot="tool-error-card-icon-wrap">
<Show when={local.loading} fallback={<BanIcon />}>
<LoaderIcon />
</Show>
</span>
<div data-slot="tool-error-card-main">
<div data-slot="tool-error-card-labels">
<span data-slot="tool-error-card-title">{local.title}</span>
<span data-slot="tool-error-card-sep" aria-hidden="true">
·
</span>
<Show
when={local.subtitleHref}
fallback={<span data-slot="tool-error-card-subtitle">{local.subtitle}</span>}
>
<a
data-slot="tool-error-card-subtitle"
href={local.subtitleHref!}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
{local.subtitle}
</a>
</Show>
<Show when={hasSuffix()}>
<span data-slot="tool-error-card-chevron-wrap">
<ChevronIcon />
</span>
</Show>
</div>
</div>
</Collapsible.Trigger>
<Show when={hasSuffix()}>
<Collapsible.Content data-slot="tool-error-card-content">
<div data-slot="tool-error-card-suffix">{local.suffix}</div>
</Collapsible.Content>
</Show>
</Collapsible>
)
}

View File

@@ -0,0 +1,54 @@
[data-component="tooltip-v2"] {
box-sizing: border-box;
display: inline-flex;
flex-direction: row;
align-items: center;
padding: 5px 6px;
gap: 6px;
background: var(--background-bg-layer-01);
box-shadow: var(--elevation-floating);
border-radius: 4px;
font-family: "Inter Variable";
font-style: normal;
font-weight: 530;
font-size: 11px;
line-height: 12px;
letter-spacing: 0.05px;
color: var(--text-text-base);
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
user-select: none;
pointer-events: none;
animation: tooltipV2In 120ms ease-out;
transform-origin: var(--kb-tooltip-content-transform-origin);
&[data-closed] {
animation: tooltipV2Out 80ms ease-in forwards;
}
}
@keyframes tooltipV2In {
from {
opacity: 0;
transform: scale(0.96);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes tooltipV2Out {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.96);
}
}

View File

@@ -0,0 +1,85 @@
import { TooltipV2 } from "./tooltip-v2"
import { KeybindV2 } from "./keybind-v2"
const docs = `### Overview
Floating tooltip built on Kobalte's tooltip primitive with v2 styling.
### API
- \`value\`: Content rendered inside the floating tooltip.
- \`children\`: The trigger element that activates the tooltip on hover/focus.
- \`placement\`: Kobalte placement string (e.g. "top", "bottom", "left", "right").
- \`inactive\`: When true, renders only the trigger without tooltip behavior.
- \`forceOpen\`: Forces the tooltip to stay open.
- Inherits Kobalte Tooltip root props.
`
export default {
title: "UI V2/Tooltip",
id: "components-tooltip-v2",
component: TooltipV2,
tags: ["autodocs"],
parameters: {
frameHeight: "300px",
frameBackground: "#fff",
docs: {
description: {
component: docs,
},
},
},
}
export const Simple = {
render: () => (
<div style={{ padding: "80px", display: "flex", "justify-content": "center" }}>
<TooltipV2 value="Tooltip Text">
<span>Hover me</span>
</TooltipV2>
</div>
),
}
export const WithKeybind = {
render: () => (
<div style={{ padding: "80px", display: "flex", "justify-content": "center" }}>
<TooltipV2
value={
<>
Tooltip Text
<KeybindV2 keys={["⌘", "⌘"]} variant="neutral" />
</>
}
>
<span>Hover me</span>
</TooltipV2>
</div>
),
}
export const Path = {
render: () => (
<div style={{ padding: "80px", display: "flex", "justify-content": "center" }}>
<TooltipV2 value={<>Components <span style={{ color: "var(--text-text-faint)" }}>/</span> Tooltip</>}>
<span>Hover me</span>
</TooltipV2>
</div>
),
}
export const TitleDescription = {
render: () => (
<div style={{ padding: "80px", display: "flex", "justify-content": "center" }}>
<TooltipV2
value={
<>
<span>Title</span>
<span style={{ color: "var(--text-text-faint)" }}>·</span>
<span style={{ color: "var(--text-text-faint)" }}>Description</span>
</>
}
>
<span>Hover me</span>
</TooltipV2>
</div>
),
}

View File

@@ -0,0 +1,146 @@
import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip"
import { createEffect, Match, onCleanup, splitProps, Switch, type JSX } from "solid-js"
import type { ComponentProps } from "solid-js"
import { createStore } from "solid-js/store"
import "./tooltip-v2.css"
export interface TooltipV2Props extends ComponentProps<typeof KobalteTooltip> {
value: JSX.Element
class?: string
contentClass?: string
contentStyle?: JSX.CSSProperties
inactive?: boolean
forceOpen?: boolean
}
export function TooltipV2(props: TooltipV2Props) {
let ref: HTMLDivElement | undefined
const [state, setState] = createStore({
open: false,
block: false,
expand: false,
})
const [local, others] = splitProps(props, [
"children",
"class",
"contentClass",
"contentStyle",
"inactive",
"forceOpen",
"ignoreSafeArea",
"value",
])
const close = () => setState("open", false)
const inside = () => {
const active = document.activeElement
if (!ref || !active) return false
return ref.contains(active)
}
const drop = (expand = state.expand) => {
if (expand) return
if (ref?.matches(":hover")) return
if (inside()) return
setState("block", false)
}
const sync = () => {
const expand = !!ref?.querySelector('[aria-expanded="true"], [data-expanded]')
setState("expand", expand)
if (expand) {
setState("block", true)
close()
return
}
drop(expand)
}
const arm = () => {
setState("block", true)
close()
}
const leave = () => {
if (!inside()) close()
drop()
}
createEffect(() => {
if (!ref) return
sync()
const obs = new MutationObserver(sync)
obs.observe(ref, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["aria-expanded", "data-expanded"],
})
onCleanup(() => obs.disconnect())
})
let justClickedTrigger = false
return (
<Switch>
<Match when={local.inactive}>{local.children}</Match>
<Match when={true}>
<KobalteTooltip
gutter={4}
{...others}
openDelay={0}
closeDelay={0}
ignoreSafeArea={local.ignoreSafeArea ?? true}
open={local.forceOpen || state.open}
onOpenChange={(open) => {
if (local.forceOpen) return
if (state.block && open) return
if (justClickedTrigger) {
justClickedTrigger = false
return
}
setState("open", open)
}}
>
<KobalteTooltip.Trigger
ref={ref}
as="div"
data-component="tooltip-v2-trigger"
class={local.class}
onPointerDownCapture={arm}
onKeyDownCapture={(event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
arm()
}}
onPointerLeave={leave}
onFocusOut={() => requestAnimationFrame(() => drop())}
>
{local.children}
</KobalteTooltip.Trigger>
<KobalteTooltip.Portal>
<KobalteTooltip.Content
ref={(el) => {
const theme = ref?.closest("[data-theme]")?.getAttribute("data-theme")
if (theme) el.setAttribute("data-theme", theme)
}}
data-component="tooltip-v2"
data-placement={props.placement}
data-force-open={local.forceOpen}
class={local.contentClass}
style={local.contentStyle}
onPointerDownOutside={(e) => {
if (ref === e.target || (e.target instanceof Node && ref?.contains(e.target))) {
justClickedTrigger = true
}
e.preventDefault()
}}
>
{local.value}
</KobalteTooltip.Content>
</KobalteTooltip.Portal>
</KobalteTooltip>
</Match>
</Switch>
)
}

View File

@@ -0,0 +1,171 @@
@layer theme {
:root {
/* ── Grey ── */
--v2-grey-100: #FFFFFFFF;
--v2-grey-200: #FAFAFAFF;
--v2-grey-300: #EEEEEEFF;
--v2-grey-400: #D4D4D4FF;
--v2-grey-500: #AEAEAEFF;
--v2-grey-600: #808080FF;
--v2-grey-700: #5C5C5CFF;
--v2-grey-800: #3A3A3AFF;
--v2-grey-900: #242424FF;
--v2-grey-1000: #161616FF;
--v2-grey-1100: #080808FF;
--v2-grey-1200: #000000FF;
/* ── Alpha Dark ── */
--v2-alpha-dark-100: #000000FF;
--v2-alpha-dark-90: #000000E5;
--v2-alpha-dark-80: #000000CC;
--v2-alpha-dark-70: #000000B2;
--v2-alpha-dark-60: #00000099;
--v2-alpha-dark-50: #00000080;
--v2-alpha-dark-40: #00000066;
--v2-alpha-dark-30: #0000004D;
--v2-alpha-dark-24: #0000003D;
--v2-alpha-dark-20: #00000033;
--v2-alpha-dark-16: #00000029;
--v2-alpha-dark-14: #00000024;
--v2-alpha-dark-12: #0000001F;
--v2-alpha-dark-10: #0000001A;
--v2-alpha-dark-8: #00000014;
--v2-alpha-dark-6: #0000000F;
--v2-alpha-dark-4: #0000000A;
--v2-alpha-dark-2: #00000005;
--v2-alpha-dark-0: #00000000;
/* ── Alpha Light ── */
--v2-alpha-light-100: #FFFFFFFF;
--v2-alpha-light-90: #FFFFFFE5;
--v2-alpha-light-80: #FFFFFFCC;
--v2-alpha-light-70: #FFFFFFB2;
--v2-alpha-light-60: #FFFFFF99;
--v2-alpha-light-50: #FFFFFF80;
--v2-alpha-light-40: #FFFFFF66;
--v2-alpha-light-30: #FFFFFF4D;
--v2-alpha-light-24: #FFFFFF3D;
--v2-alpha-light-20: #FFFFFF33;
--v2-alpha-light-16: #FFFFFF29;
--v2-alpha-light-14: #FFFFFF24;
--v2-alpha-light-12: #FFFFFF1F;
--v2-alpha-light-10: #FFFFFF1A;
--v2-alpha-light-8: #FFFFFF14;
--v2-alpha-light-6: #FFFFFF0F;
--v2-alpha-light-4: #FFFFFF0A;
--v2-alpha-light-2: #FFFFFF05;
--v2-alpha-light-0: #FFFFFF00;
/* ── Red ── */
--v2-red-100: #FCECEBFF;
--v2-red-200: #F6D5D3FF;
--v2-red-300: #F2BBB7FF;
--v2-red-400: #F29B96FF;
--v2-red-500: #F17471FF;
--v2-red-600: #F1484FFF;
--v2-red-700: #D92E3CFF;
--v2-red-800: #B82D35FF;
--v2-red-900: #97252BFF;
--v2-red-1000: #7A1F23FF;
--v2-red-1100: #5F1A1CFF;
--v2-red-1200: #461516FF;
/* ── Orange ── */
--v2-orange-100: #FDF2EDFF;
--v2-orange-200: #FFE7DCFF;
--v2-orange-300: #FFD8C6FF;
--v2-orange-400: #FFC1A4FF;
--v2-orange-500: #FFA478FF;
--v2-orange-600: #FF8648FF;
--v2-orange-700: #EE7330FF;
--v2-orange-800: #D16427FF;
--v2-orange-900: #B35624FF;
--v2-orange-1000: #954C27FF;
--v2-orange-1100: #723D22FF;
--v2-orange-1200: #5A2C14FF;
/* ── Yellow ── */
--v2-yellow-100: #FEFAECFF;
--v2-yellow-200: #FCEFD0FF;
--v2-yellow-300: #F7E5B5FF;
--v2-yellow-400: #F3DA9BFF;
--v2-yellow-500: #F2CF76FF;
--v2-yellow-600: #F6C251FF;
--v2-yellow-700: #E7AF36FF;
--v2-yellow-800: #CB9F34FF;
--v2-yellow-900: #AC8833FF;
--v2-yellow-1000: #8E7231FF;
--v2-yellow-1100: #68552BFF;
--v2-yellow-1200: #4B4025FF;
/* ── Green ── */
--v2-green-100: #E7F9EAFF;
--v2-green-200: #D0F0D5FF;
--v2-green-300: #B8E9C1FF;
--v2-green-400: #96E3A6FF;
--v2-green-500: #6BD586FF;
--v2-green-600: #49C970FF;
--v2-green-700: #2EAF5AFF;
--v2-green-800: #198B43FF;
--v2-green-900: #1D783CFF;
--v2-green-1000: #196130FF;
--v2-green-1100: #164C26FF;
--v2-green-1200: #14361DFF;
/* ── Cyan ── */
--v2-cyan-100: #E2F7FBFF;
--v2-cyan-200: #C4EDF4FF;
--v2-cyan-300: #A3E4EFFF;
--v2-cyan-400: #65D9EBFF;
--v2-cyan-500: #00C5DFFF;
--v2-cyan-600: #00ABCFFF;
--v2-cyan-700: #0096B8FF;
--v2-cyan-800: #007D9BFF;
--v2-cyan-900: #006C85FF;
--v2-cyan-1000: #005A6EFF;
--v2-cyan-1100: #004756FF;
--v2-cyan-1200: #00353FFF;
/* ── Blue ── */
--v2-blue-100: #ECF1FEFF;
--v2-blue-200: #D7E2FCFF;
--v2-blue-300: #C3D4FDFF;
--v2-blue-400: #A2BCFFFF;
--v2-blue-500: #7698FDFF;
--v2-blue-600: #3B5CF6FF;
--v2-blue-700: #3250DFFF;
--v2-blue-800: #2C47C8FF;
--v2-blue-900: #263FA9FF;
--v2-blue-1000: #22388FFF;
--v2-blue-1100: #1C2E70FF;
--v2-blue-1200: #1B2852FF;
/* ── Purple ── */
--v2-purple-100: #EBECFEFF;
--v2-purple-200: #D5D5FCFF;
--v2-purple-300: #B9B8F5FF;
--v2-purple-400: #9E99F7FF;
--v2-purple-500: #8271F8FF;
--v2-purple-600: #7152F4FF;
--v2-purple-700: #623BE2FF;
--v2-purple-800: #5230C2FF;
--v2-purple-900: #442AA1FF;
--v2-purple-1000: #361F83FF;
--v2-purple-1100: #2B1B6AFF;
--v2-purple-1200: #221358FF;
/* ── Pink ── */
--v2-pink-100: #FDECF3FF;
--v2-pink-200: #F7D5E4FF;
--v2-pink-300: #FABCD8FF;
--v2-pink-400: #F799C6FF;
--v2-pink-500: #F26CB2FF;
--v2-pink-600: #F64AABFF;
--v2-pink-700: #E4429EFF;
--v2-pink-800: #C83D8BFF;
--v2-pink-900: #AA3576FF;
--v2-pink-1000: #8C2D61FF;
--v2-pink-1100: #6F284FFF;
--v2-pink-1200: #5C1D3FFF;
}
}

View File

@@ -0,0 +1,2 @@
@import "./colors.css";
@import "./theme.css";

View File

@@ -0,0 +1,374 @@
@layer theme {
:root {
color-scheme: light;
/* ── Background ── */
--v2-background-bg-base: var(--v2-grey-100);
--v2-background-bg-deep: var(--v2-grey-200);
--v2-background-bg-layer-01: var(--v2-grey-200);
--v2-background-bg-layer-02: var(--v2-grey-300);
--v2-background-bg-layer-03: var(--v2-grey-400);
--v2-background-bg-inverse: var(--v2-grey-1000);
--v2-background-bg-contrast: var(--v2-grey-900);
--v2-background-bg-button-neutral: var(--v2-grey-100);
--v2-background-bg-accent: var(--v2-blue-600);
/* ── Text ── */
--v2-text-text-base: var(--v2-grey-1000);
--v2-text-text-muted: var(--v2-grey-700);
--v2-text-text-faint: var(--v2-grey-600);
--v2-text-text-inverse: var(--v2-grey-100);
--v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700);
/* ── Icon ── */
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-inverse: var(--v2-grey-100);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-600);
--v2-icon-icon-accent-hover: var(--v2-blue-700);
/* ── Border ── */
--v2-border-border-muted: var(--v2-alpha-dark-8);
--v2-border-border-base: var(--v2-alpha-dark-10);
--v2-border-border-strong: var(--v2-alpha-dark-20);
--v2-border-border-inverse: var(--v2-grey-1000);
--v2-border-border-focus: var(--v2-blue-500);
/* ── Overlay ── */
--v2-overlay-simple-overlay-hover: var(--v2-alpha-dark-4);
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-dark-8);
--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-light-12);
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-light-24);
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-dark-40);
--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
--v2-overlay-simple-tab-active-scrim: #fafafa00;
--v2-overlay-simple-tab-hover-scrim: #eeeeee00;
--v2-overlay-simple-tab-scrim: #fafafa00;
/* ── State ── */
--v2-state-bg-success: var(--v2-green-100);
--v2-state-fg-success: var(--v2-green-800);
--v2-state-border-success: var(--v2-green-300);
--v2-state-bg-warning: var(--v2-yellow-100);
--v2-state-fg-warning: var(--v2-yellow-800);
--v2-state-border-warning: var(--v2-yellow-300);
--v2-state-bg-danger: var(--v2-red-100);
--v2-state-fg-danger: var(--v2-red-800);
--v2-state-border-danger: var(--v2-red-300);
--v2-state-bg-info: var(--v2-blue-100);
--v2-state-fg-info: var(--v2-blue-800);
--v2-state-border-info: var(--v2-blue-300);
/* ── Elevation ── */
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-floating:
0px 8px 16px 0px var(--v2-alpha-dark-4), 0px 4px 8px 0px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-overlay:
0px 16px 32px 0px var(--v2-alpha-dark-4), 0px 8px 16px 0px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-button-neutral:
0px 1px 1.5px 0px var(--v2-alpha-dark-10), 0px 0px 0px 0.5px var(--v2-alpha-dark-14),
0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-button-contrast:
0px 1px 1.5px 0px var(--v2-alpha-dark-20), 0px 0px 0px 0.5px var(--v2-grey-800),
inset 0px 1px 2px 0px var(--v2-alpha-light-14), inset 0px -1px 2px 0px var(--v2-alpha-dark-6),
0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-elements: 0px 0.5px 0.5px 0px var(--v2-alpha-dark-40);
--v2-elevation-switch-off:
inset 0px 1px 1px 0px var(--v2-alpha-dark-8), inset 0px 0.5px 0.5px 0px var(--v2-alpha-dark-8),
inset 0px 0px 0px 0.5px var(--v2-alpha-dark-10);
--v2-elevation-switch-on:
inset 0px 2px 2px 0px var(--v2-alpha-dark-10), inset 0px 1px 1px 0px var(--v2-alpha-dark-10),
inset 0px 0px 0px 0.5px var(--v2-alpha-dark-20);
/* ── Illustration ── */
--v2-illustration-illustration-layer-01: var(--v2-grey-300);
--v2-illustration-illustration-layer-02: var(--v2-grey-400);
--v2-illustration-illustration-layer-03: var(--v2-grey-500);
}
/* OS preference fallback (no JS needed) */
/*@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--v2-background-bg-base: var(--v2-grey-1000);
--v2-background-bg-deep: var(--v2-grey-1100);
--v2-background-bg-layer-01: var(--v2-grey-900);
--v2-background-bg-layer-02: var(--v2-grey-800);
--v2-background-bg-layer-03: var(--v2-grey-700);
--v2-background-bg-inverse: var(--v2-grey-100);
--v2-background-bg-contrast: var(--v2-grey-700);
--v2-background-bg-button-neutral: var(--v2-alpha-light-6);
--v2-background-bg-accent: var(--v2-blue-600);
--v2-text-text-base: var(--v2-grey-200);
--v2-text-text-muted: var(--v2-grey-500);
--v2-text-text-faint: var(--v2-grey-600);
--v2-text-text-inverse: var(--v2-grey-1000);
--v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300);
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
--v2-icon-icon-accent-hover: var(--v2-blue-300);
--v2-border-border-muted: var(--v2-alpha-light-8);
--v2-border-border-base: var(--v2-alpha-light-10);
--v2-border-border-strong: var(--v2-alpha-light-20);
--v2-border-border-inverse: var(--v2-grey-100);
--v2-border-border-focus: var(--v2-blue-500);
--v2-overlay-simple-overlay-hover: var(--v2-alpha-light-6);
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-light-10);
--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-dark-24);
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-dark-40);
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-light-30);
--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
--v2-overlay-simple-tab-active-scrim: #24242400;
--v2-overlay-simple-tab-hover-scrim: #3A3A3A00;
--v2-overlay-simple-tab-scrim: #08080800;
--v2-state-bg-success: var(--v2-green-1200);
--v2-state-fg-success: var(--v2-green-500);
--v2-state-border-success: var(--v2-green-900);
--v2-state-bg-warning: var(--v2-yellow-1200);
--v2-state-fg-warning: var(--v2-yellow-500);
--v2-state-border-warning: var(--v2-yellow-900);
--v2-state-bg-danger: var(--v2-red-1200);
--v2-state-fg-danger: var(--v2-red-500);
--v2-state-border-danger: var(--v2-red-900);
--v2-state-bg-info: var(--v2-blue-1200);
--v2-state-fg-info: var(--v2-blue-500);
--v2-state-border-info: var(--v2-blue-900);
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-30),
0px 1px 2px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16),
0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-floating:
0px 8px 16px 0px var(--v2-alpha-dark-30),
0px 4px 8px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16),
0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-overlay:
0px 16px 32px 0px var(--v2-alpha-dark-30),
0px 8px 16px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16),
0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-button-neutral:
0px 1px 2px 0px var(--v2-alpha-dark-40),
0px 0px 0px 0.5px var(--v2-alpha-light-20),
0px -0.5px 0px 0px var(--v2-alpha-light-10);
--v2-elevation-button-contrast:
0px 1px 2px 0px var(--v2-alpha-dark-40),
0px 0px 0px 0.5px var(--v2-alpha-light-40),
inset 0px 0px 0px 0px var(--v2-alpha-light-0),
inset 0px 0px 0px 0px var(--v2-alpha-light-0),
0px -0.5px 0px 0px var(--v2-alpha-light-30);
--v2-elevation-elements:
0px 0.5px 0.5px 0px var(--v2-alpha-dark-40);
--v2-elevation-switch-off:
inset 0px -0.5px 0px 0px var(--v2-alpha-light-10),
inset 0px 0px 0px 0px var(--v2-alpha-light-0),
inset 0px 0px 0px 0.5px var(--v2-alpha-light-16);
--v2-elevation-switch-on:
inset 0px -0.5px 0px 0px var(--v2-alpha-light-10),
inset 0px 0px 0px 0px var(--v2-alpha-light-0),
inset 0px 0px 0px 0.5px var(--v2-alpha-light-16);
--v2-illustration-illustration-layer-01: var(--v2-grey-900);
--v2-illustration-illustration-layer-02: var(--v2-grey-800);
--v2-illustration-illustration-layer-03: var(--v2-grey-700);
}
}
*/
/* Explicit light mode via data attribute (overrides OS dark preference) */
[data-color-scheme="light"] {
color-scheme: light;
--v2-background-bg-base: var(--v2-grey-100);
--v2-background-bg-deep: var(--v2-grey-200);
--v2-background-bg-layer-01: var(--v2-grey-200);
--v2-background-bg-layer-02: var(--v2-grey-300);
--v2-background-bg-layer-03: var(--v2-grey-400);
--v2-background-bg-inverse: var(--v2-grey-1000);
--v2-background-bg-contrast: var(--v2-grey-900);
--v2-background-bg-button-neutral: var(--v2-grey-100);
--v2-background-bg-accent: var(--v2-blue-600);
--v2-text-text-base: var(--v2-grey-1000);
--v2-text-text-muted: var(--v2-grey-700);
--v2-text-text-faint: var(--v2-grey-600);
--v2-text-text-inverse: var(--v2-grey-100);
--v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-600);
--v2-text-text-accent-hover: var(--v2-blue-700);
--v2-icon-icon-base: var(--v2-grey-800);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-inverse: var(--v2-grey-100);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-600);
--v2-icon-icon-accent-hover: var(--v2-blue-700);
--v2-border-border-muted: var(--v2-alpha-dark-8);
--v2-border-border-base: var(--v2-alpha-dark-10);
--v2-border-border-strong: var(--v2-alpha-dark-20);
--v2-border-border-inverse: var(--v2-grey-1000);
--v2-border-border-focus: var(--v2-blue-500);
--v2-overlay-simple-overlay-hover: var(--v2-alpha-dark-4);
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-dark-8);
--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-light-12);
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-light-24);
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-dark-40);
--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
--v2-overlay-simple-tab-active-scrim: #fafafa00;
--v2-overlay-simple-tab-hover-scrim: #eeeeee00;
--v2-overlay-simple-tab-scrim: #fafafa00;
--v2-state-bg-success: var(--v2-green-100);
--v2-state-fg-success: var(--v2-green-800);
--v2-state-border-success: var(--v2-green-300);
--v2-state-bg-warning: var(--v2-yellow-100);
--v2-state-fg-warning: var(--v2-yellow-800);
--v2-state-border-warning: var(--v2-yellow-300);
--v2-state-bg-danger: var(--v2-red-100);
--v2-state-fg-danger: var(--v2-red-800);
--v2-state-border-danger: var(--v2-red-300);
--v2-state-bg-info: var(--v2-blue-100);
--v2-state-fg-info: var(--v2-blue-800);
--v2-state-border-info: var(--v2-blue-300);
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-4), 0px 1px 2px -1px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-floating:
0px 8px 16px 0px var(--v2-alpha-dark-4), 0px 4px 8px 0px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-overlay:
0px 16px 32px 0px var(--v2-alpha-dark-4), 0px 8px 16px 0px var(--v2-alpha-dark-8),
0px 0px 0px 0.5px var(--v2-alpha-dark-12), 0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-button-neutral:
0px 1px 1.5px 0px var(--v2-alpha-dark-10), 0px 0px 0px 0.5px var(--v2-alpha-dark-14),
0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-button-contrast:
0px 1px 1.5px 0px var(--v2-alpha-dark-20), 0px 0px 0px 0.5px var(--v2-grey-800),
inset 0px 1px 2px 0px var(--v2-alpha-light-14), inset 0px -1px 2px 0px var(--v2-alpha-dark-6),
0px 0px 0px 0px var(--v2-alpha-dark-0);
--v2-elevation-elements: 0px 0.5px 0.5px 0px var(--v2-alpha-dark-40);
--v2-elevation-switch-off:
inset 0px 1px 1px 0px var(--v2-alpha-dark-8), inset 0px 0.5px 0.5px 0px var(--v2-alpha-dark-8),
inset 0px 0px 0px 0.5px var(--v2-alpha-dark-10);
--v2-elevation-switch-on:
inset 0px 2px 2px 0px var(--v2-alpha-dark-10), inset 0px 1px 1px 0px var(--v2-alpha-dark-10),
inset 0px 0px 0px 0.5px var(--v2-alpha-dark-20);
--v2-illustration-illustration-layer-01: var(--v2-grey-300);
--v2-illustration-illustration-layer-02: var(--v2-grey-400);
--v2-illustration-illustration-layer-03: var(--v2-grey-500);
}
/* Explicit dark mode via data attribute (Storybook toggle, runtime JS) */
[data-color-scheme="dark"] {
color-scheme: dark;
--v2-background-bg-base: var(--v2-grey-1000);
--v2-background-bg-deep: var(--v2-grey-1100);
--v2-background-bg-layer-01: var(--v2-grey-900);
--v2-background-bg-layer-02: var(--v2-grey-800);
--v2-background-bg-layer-03: var(--v2-grey-700);
--v2-background-bg-inverse: var(--v2-grey-100);
--v2-background-bg-contrast: var(--v2-grey-700);
--v2-background-bg-button-neutral: var(--v2-alpha-light-6);
--v2-background-bg-accent: var(--v2-blue-600);
--v2-text-text-base: var(--v2-grey-200);
--v2-text-text-muted: var(--v2-grey-500);
--v2-text-text-faint: var(--v2-grey-600);
--v2-text-text-inverse: var(--v2-grey-1000);
--v2-text-text-contrast: var(--v2-grey-100);
--v2-text-text-accent: var(--v2-blue-400);
--v2-text-text-accent-hover: var(--v2-blue-300);
--v2-icon-icon-base: var(--v2-grey-400);
--v2-icon-icon-muted: var(--v2-grey-600);
--v2-icon-icon-inverse: var(--v2-grey-1000);
--v2-icon-icon-contrast: var(--v2-grey-200);
--v2-icon-icon-accent: var(--v2-blue-400);
--v2-icon-icon-accent-hover: var(--v2-blue-300);
--v2-border-border-muted: var(--v2-alpha-light-8);
--v2-border-border-base: var(--v2-alpha-light-10);
--v2-border-border-strong: var(--v2-alpha-light-20);
--v2-border-border-inverse: var(--v2-grey-100);
--v2-border-border-focus: var(--v2-blue-500);
--v2-overlay-simple-overlay-hover: var(--v2-alpha-light-6);
--v2-overlay-simple-overlay-pressed: var(--v2-alpha-light-10);
--v2-overlay-simple-overlay-contrast-hover: var(--v2-alpha-dark-24);
--v2-overlay-simple-overlay-contrast-pressed: var(--v2-alpha-dark-40);
--v2-overlay-simple-overlay-scrim: var(--v2-alpha-light-30);
--v2-overlay-gradient-depth-overlay-depth-top: var(--v2-alpha-light-100);
--v2-overlay-gradient-depth-overlay-depth-bot: var(--v2-alpha-light-0);
--v2-overlay-simple-tab-active-scrim: #24242400;
--v2-overlay-simple-tab-hover-scrim: #3a3a3a00;
--v2-overlay-simple-tab-scrim: #08080800;
--v2-state-bg-success: var(--v2-green-1200);
--v2-state-fg-success: var(--v2-green-500);
--v2-state-border-success: var(--v2-green-900);
--v2-state-bg-warning: var(--v2-yellow-1200);
--v2-state-fg-warning: var(--v2-yellow-500);
--v2-state-border-warning: var(--v2-yellow-900);
--v2-state-bg-danger: var(--v2-red-1200);
--v2-state-fg-danger: var(--v2-red-500);
--v2-state-border-danger: var(--v2-red-900);
--v2-state-bg-info: var(--v2-blue-1200);
--v2-state-fg-info: var(--v2-blue-500);
--v2-state-border-info: var(--v2-blue-900);
--v2-elevation-raised:
0px 2px 4px 0px var(--v2-alpha-dark-30), 0px 1px 2px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-floating:
0px 8px 16px 0px var(--v2-alpha-dark-30), 0px 4px 8px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-overlay:
0px 16px 32px 0px var(--v2-alpha-dark-30), 0px 8px 16px 0px var(--v2-alpha-dark-30),
0px 0px 0px 0.5px var(--v2-alpha-light-16), 0px -0.5px 0px 0px var(--v2-alpha-light-6);
--v2-elevation-button-neutral:
0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-20),
0px -0.5px 0px 0px var(--v2-alpha-light-10);
--v2-elevation-button-contrast:
0px 1px 2px 0px var(--v2-alpha-dark-40), 0px 0px 0px 0.5px var(--v2-alpha-light-40),
inset 0px 0px 0px 0px var(--v2-alpha-light-0), inset 0px 0px 0px 0px var(--v2-alpha-light-0),
0px -0.5px 0px 0px var(--v2-alpha-light-30);
--v2-elevation-elements: 0px 0.5px 0.5px 0px var(--v2-alpha-dark-40);
--v2-elevation-switch-off:
inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0),
inset 0px 0px 0px 0.5px var(--v2-alpha-light-16);
--v2-elevation-switch-on:
inset 0px -0.5px 0px 0px var(--v2-alpha-light-10), inset 0px 0px 0px 0px var(--v2-alpha-light-0),
inset 0px 0px 0px 0.5px var(--v2-alpha-light-16);
--v2-illustration-illustration-layer-01: var(--v2-grey-900);
--v2-illustration-illustration-layer-02: var(--v2-grey-800);
--v2-illustration-illustration-layer-03: var(--v2-grey-700);
}
}