mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-20 19:06:22 +00:00
app: Initial tabs impl (#28436)
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
596
packages/app/src/context/directory-sync.ts
Normal file
596
packages/app/src/context/directory-sync.ts
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
141
packages/ui/src/v2/components/accordion-v2.css
Normal file
141
packages/ui/src/v2/components/accordion-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
175
packages/ui/src/v2/components/accordion-v2.stories.tsx
Normal file
175
packages/ui/src/v2/components/accordion-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
96
packages/ui/src/v2/components/accordion-v2.tsx
Normal file
96
packages/ui/src/v2/components/accordion-v2.tsx
Normal 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,
|
||||
})
|
||||
71
packages/ui/src/v2/components/avatar-v2.css
Normal file
71
packages/ui/src/v2/components/avatar-v2.css
Normal 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;
|
||||
}
|
||||
85
packages/ui/src/v2/components/avatar-v2.stories.tsx
Normal file
85
packages/ui/src/v2/components/avatar-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
59
packages/ui/src/v2/components/avatar-v2.tsx
Normal file
59
packages/ui/src/v2/components/avatar-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
packages/ui/src/v2/components/badge-v2.css
Normal file
28
packages/ui/src/v2/components/badge-v2.css
Normal 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);
|
||||
}
|
||||
54
packages/ui/src/v2/components/badge-v2.stories.tsx
Normal file
54
packages/ui/src/v2/components/badge-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
20
packages/ui/src/v2/components/badge-v2.tsx
Normal file
20
packages/ui/src/v2/components/badge-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
164
packages/ui/src/v2/components/basic-tool-v2.css
Normal file
164
packages/ui/src/v2/components/basic-tool-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
135
packages/ui/src/v2/components/basic-tool-v2.stories.tsx
Normal file
135
packages/ui/src/v2/components/basic-tool-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
160
packages/ui/src/v2/components/basic-tool-v2.tsx
Normal file
160
packages/ui/src/v2/components/basic-tool-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
139
packages/ui/src/v2/components/button-v2.css
Normal file
139
packages/ui/src/v2/components/button-v2.css
Normal 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;
|
||||
}
|
||||
166
packages/ui/src/v2/components/button-v2.stories.tsx
Normal file
166
packages/ui/src/v2/components/button-v2.stories.tsx
Normal 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>
|
||||
);
|
||||
},
|
||||
};
|
||||
35
packages/ui/src/v2/components/button-v2.tsx
Normal file
35
packages/ui/src/v2/components/button-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
187
packages/ui/src/v2/components/checkbox-v2.css
Normal file
187
packages/ui/src/v2/components/checkbox-v2.css
Normal 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;
|
||||
}
|
||||
98
packages/ui/src/v2/components/checkbox-v2.stories.tsx
Normal file
98
packages/ui/src/v2/components/checkbox-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
67
packages/ui/src/v2/components/checkbox-v2.tsx
Normal file
67
packages/ui/src/v2/components/checkbox-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
packages/ui/src/v2/components/dialog-v2.css
Normal file
150
packages/ui/src/v2/components/dialog-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
170
packages/ui/src/v2/components/dialog-v2.stories.tsx
Normal file
170
packages/ui/src/v2/components/dialog-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
83
packages/ui/src/v2/components/dialog-v2.tsx
Normal file
83
packages/ui/src/v2/components/dialog-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
packages/ui/src/v2/components/diff-changes-v2.css
Normal file
25
packages/ui/src/v2/components/diff-changes-v2.css
Normal 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);
|
||||
}
|
||||
}
|
||||
60
packages/ui/src/v2/components/diff-changes-v2.stories.tsx
Normal file
60
packages/ui/src/v2/components/diff-changes-v2.stories.tsx
Normal 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 },
|
||||
},
|
||||
}
|
||||
28
packages/ui/src/v2/components/diff-changes-v2.tsx
Normal file
28
packages/ui/src/v2/components/diff-changes-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
packages/ui/src/v2/components/field-v2.css
Normal file
96
packages/ui/src/v2/components/field-v2.css
Normal 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;
|
||||
}
|
||||
140
packages/ui/src/v2/components/field-v2.stories.tsx
Normal file
140
packages/ui/src/v2/components/field-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
275
packages/ui/src/v2/components/field-v2.tsx
Normal file
275
packages/ui/src/v2/components/field-v2.tsx
Normal 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
|
||||
146
packages/ui/src/v2/components/icon-button-v2.css
Normal file
146
packages/ui/src/v2/components/icon-button-v2.css
Normal 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;
|
||||
}
|
||||
105
packages/ui/src/v2/components/icon-button-v2.stories.tsx
Normal file
105
packages/ui/src/v2/components/icon-button-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
37
packages/ui/src/v2/components/icon-button-v2.tsx
Normal file
37
packages/ui/src/v2/components/icon-button-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
29
packages/ui/src/v2/components/icon.tsx
Normal file
29
packages/ui/src/v2/components/icon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
220
packages/ui/src/v2/components/inline-input-v2.css
Normal file
220
packages/ui/src/v2/components/inline-input-v2.css
Normal 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;
|
||||
}
|
||||
141
packages/ui/src/v2/components/inline-input-v2.stories.tsx
Normal file
141
packages/ui/src/v2/components/inline-input-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
90
packages/ui/src/v2/components/inline-input-v2.tsx
Normal file
90
packages/ui/src/v2/components/inline-input-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
packages/ui/src/v2/components/keybind-v2.css
Normal file
73
packages/ui/src/v2/components/keybind-v2.css
Normal 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);
|
||||
}
|
||||
82
packages/ui/src/v2/components/keybind-v2.stories.tsx
Normal file
82
packages/ui/src/v2/components/keybind-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
30
packages/ui/src/v2/components/keybind-v2.tsx
Normal file
30
packages/ui/src/v2/components/keybind-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
207
packages/ui/src/v2/components/line-comment-v2.css
Normal file
207
packages/ui/src/v2/components/line-comment-v2.css
Normal 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;
|
||||
}
|
||||
92
packages/ui/src/v2/components/line-comment-v2.stories.tsx
Normal file
92
packages/ui/src/v2/components/line-comment-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
164
packages/ui/src/v2/components/line-comment-v2.tsx
Normal file
164
packages/ui/src/v2/components/line-comment-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
packages/ui/src/v2/components/menu-v2.css
Normal file
186
packages/ui/src/v2/components/menu-v2.css
Normal 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);
|
||||
}
|
||||
}
|
||||
216
packages/ui/src/v2/components/menu-v2.stories.tsx
Normal file
216
packages/ui/src/v2/components/menu-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
240
packages/ui/src/v2/components/menu-v2.tsx
Normal file
240
packages/ui/src/v2/components/menu-v2.tsx
Normal 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,
|
||||
})
|
||||
207
packages/ui/src/v2/components/radio-v2.css
Normal file
207
packages/ui/src/v2/components/radio-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
98
packages/ui/src/v2/components/radio-v2.stories.tsx
Normal file
98
packages/ui/src/v2/components/radio-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
|
||||
77
packages/ui/src/v2/components/radio-v2.tsx
Normal file
77
packages/ui/src/v2/components/radio-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
81
packages/ui/src/v2/components/segmented-control-v2.css
Normal file
81
packages/ui/src/v2/components/segmented-control-v2.css
Normal 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;
|
||||
}
|
||||
107
packages/ui/src/v2/components/segmented-control-v2.stories.tsx
Normal file
107
packages/ui/src/v2/components/segmented-control-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
196
packages/ui/src/v2/components/segmented-control-v2.tsx
Normal file
196
packages/ui/src/v2/components/segmented-control-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
packages/ui/src/v2/components/select-v2.css
Normal file
194
packages/ui/src/v2/components/select-v2.css
Normal 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;
|
||||
}
|
||||
174
packages/ui/src/v2/components/select-v2.stories.tsx
Normal file
174
packages/ui/src/v2/components/select-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
211
packages/ui/src/v2/components/select-v2.tsx
Normal file
211
packages/ui/src/v2/components/select-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
146
packages/ui/src/v2/components/switch-v2.css
Normal file
146
packages/ui/src/v2/components/switch-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
64
packages/ui/src/v2/components/switch-v2.stories.tsx
Normal file
64
packages/ui/src/v2/components/switch-v2.stories.tsx
Normal 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,
|
||||
},
|
||||
}
|
||||
28
packages/ui/src/v2/components/switch-v2.tsx
Normal file
28
packages/ui/src/v2/components/switch-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
216
packages/ui/src/v2/components/tabs-v2.css
Normal file
216
packages/ui/src/v2/components/tabs-v2.css
Normal 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);
|
||||
}
|
||||
168
packages/ui/src/v2/components/tabs-v2.stories.tsx
Normal file
168
packages/ui/src/v2/components/tabs-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
151
packages/ui/src/v2/components/tabs-v2.tsx
Normal file
151
packages/ui/src/v2/components/tabs-v2.tsx
Normal 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,
|
||||
})
|
||||
146
packages/ui/src/v2/components/text-input-v2.css
Normal file
146
packages/ui/src/v2/components/text-input-v2.css
Normal 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;
|
||||
}
|
||||
145
packages/ui/src/v2/components/text-input-v2.stories.tsx
Normal file
145
packages/ui/src/v2/components/text-input-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
67
packages/ui/src/v2/components/text-input-v2.tsx
Normal file
67
packages/ui/src/v2/components/text-input-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
125
packages/ui/src/v2/components/text-shimmer-v2.css
Normal file
125
packages/ui/src/v2/components/text-shimmer-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
61
packages/ui/src/v2/components/text-shimmer-v2.stories.tsx
Normal file
61
packages/ui/src/v2/components/text-shimmer-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
63
packages/ui/src/v2/components/text-shimmer-v2.tsx
Normal file
63
packages/ui/src/v2/components/text-shimmer-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
packages/ui/src/v2/components/textarea-v2.css
Normal file
81
packages/ui/src/v2/components/textarea-v2.css
Normal 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;
|
||||
}
|
||||
115
packages/ui/src/v2/components/textarea-v2.stories.tsx
Normal file
115
packages/ui/src/v2/components/textarea-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
37
packages/ui/src/v2/components/textarea-v2.tsx
Normal file
37
packages/ui/src/v2/components/textarea-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
203
packages/ui/src/v2/components/toast-v2.css
Normal file
203
packages/ui/src/v2/components/toast-v2.css
Normal 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;
|
||||
}
|
||||
}
|
||||
209
packages/ui/src/v2/components/toast-v2.stories.tsx
Normal file
209
packages/ui/src/v2/components/toast-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
};
|
||||
148
packages/ui/src/v2/components/toast-v2.tsx
Normal file
148
packages/ui/src/v2/components/toast-v2.tsx
Normal 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
|
||||
}
|
||||
201
packages/ui/src/v2/components/tool-error-card-v2.css
Normal file
201
packages/ui/src/v2/components/tool-error-card-v2.css
Normal 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);
|
||||
}
|
||||
}
|
||||
91
packages/ui/src/v2/components/tool-error-card-v2.stories.tsx
Normal file
91
packages/ui/src/v2/components/tool-error-card-v2.stories.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
}
|
||||
176
packages/ui/src/v2/components/tool-error-card-v2.tsx
Normal file
176
packages/ui/src/v2/components/tool-error-card-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
packages/ui/src/v2/components/tooltip-v2.css
Normal file
54
packages/ui/src/v2/components/tooltip-v2.css
Normal 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);
|
||||
}
|
||||
}
|
||||
85
packages/ui/src/v2/components/tooltip-v2.stories.tsx
Normal file
85
packages/ui/src/v2/components/tooltip-v2.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
146
packages/ui/src/v2/components/tooltip-v2.tsx
Normal file
146
packages/ui/src/v2/components/tooltip-v2.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
171
packages/ui/src/v2/styles/colors.css
Normal file
171
packages/ui/src/v2/styles/colors.css
Normal 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;
|
||||
}
|
||||
}
|
||||
2
packages/ui/src/v2/styles/tailwind.css
Normal file
2
packages/ui/src/v2/styles/tailwind.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "./colors.css";
|
||||
@import "./theme.css";
|
||||
374
packages/ui/src/v2/styles/theme.css
Normal file
374
packages/ui/src/v2/styles/theme.css
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user