diff options
| author | Adam <[email protected]> | 2025-12-09 03:45:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-09 06:12:09 -0600 |
| commit | 0a357be160d672791bb99a36fc7d7c1299d5493d (patch) | |
| tree | 5c5c3ee1360a0adf40b1e47691fb2ef9ef2408aa | |
| parent | d29205e67787d49de9dd4c271ec8e4e7848d81ad (diff) | |
| download | opencode-0a357be160d672791bb99a36fc7d7c1299d5493d.tar.gz opencode-0a357be160d672791bb99a36fc7d7c1299d5493d.zip | |
wip(desktop): progress
| -rw-r--r-- | packages/desktop/src/DesktopInterface.tsx | 16 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 7 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/src/pages/home.tsx | 66 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 337 | ||||
| -rw-r--r-- | packages/tauri/package.json | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/avatar.css | 35 | ||||
| -rw-r--r-- | packages/ui/src/components/avatar.tsx | 28 | ||||
| -rw-r--r-- | packages/ui/src/components/dropdown-menu.css | 119 | ||||
| -rw-r--r-- | packages/ui/src/components/dropdown-menu.tsx | 308 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 2 | ||||
| -rw-r--r-- | packages/ui/src/components/select.tsx | 12 | ||||
| -rw-r--r-- | packages/ui/src/styles/index.css | 2 |
13 files changed, 784 insertions, 171 deletions
diff --git a/packages/desktop/src/DesktopInterface.tsx b/packages/desktop/src/DesktopInterface.tsx index 1979308e4..31d52863d 100644 --- a/packages/desktop/src/DesktopInterface.tsx +++ b/packages/desktop/src/DesktopInterface.tsx @@ -2,19 +2,18 @@ import "@/index.css" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" -import { Favicon } from "@opencode-ai/ui/favicon" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DiffComponentProvider } from "@opencode-ai/ui/context/diff" import { Diff } from "@opencode-ai/ui/diff" -import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync" +import { GlobalSyncProvider } from "./context/global-sync" import Layout from "@/pages/layout" +import Home from "@/pages/home" import DirectoryLayout from "@/pages/directory-layout" import Session from "@/pages/session" import { LayoutProvider } from "./context/layout" import { GlobalSDKProvider } from "./context/global-sdk" import { SessionProvider } from "./context/session" -import { base64Encode } from "@opencode-ai/util/encode" -import { createMemo, Show } from "solid-js" +import { Show } from "solid-js" const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1" const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096" @@ -35,14 +34,7 @@ export function DesktopInterface() { <MetaProvider> <Font /> <Router root={Layout}> - <Route - path="/" - component={() => { - const globalSync = useGlobalSync() - const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree)) - return <Navigate href={`${slug()}/session`} /> - }} - /> + <Route path="/" component={Home} /> <Route path="/:dir" component={DirectoryLayout}> <Route path="/" component={() => <Navigate href="session" />} /> <Route diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 9f795cded..188db1e69 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -51,7 +51,6 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple init: () => { const [globalStore, setGlobalStore] = createStore<{ ready: boolean - defaultProject?: Project // TODO: remove this when we can select projects projects: Project[] children: Record<string, State> }>({ @@ -165,11 +164,11 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple sdk.client.project.list().then((x) => setGlobalStore( "projects", - x.data!.filter((x) => !x.worktree.includes("opencode-test")), + x + .data!.filter((x) => !x.worktree.includes("opencode-test") && x.vcs) + .sort((a, b) => b.time.created - a.time.created), ), ), - // TODO: remove this when we can select projects - sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)), ]).then(() => setGlobalStore("ready", true)) return { diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index ca736e84e..1b43cf511 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -2,17 +2,15 @@ import { createStore } from "solid-js/store" import { createMemo } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" -import { useGlobalSync } from "./global-sync" export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { - const globalSync = useGlobalSync() const [store, setStore] = makePersisted( createStore({ - projects: [] as { directory: string; expanded: boolean }[], + projects: [] as { directory: string; expanded: boolean; lastSession?: string }[], sidebar: { - opened: true, + opened: false, width: 280, }, terminal: { @@ -24,17 +22,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }), { - name: "____default-layout", + name: "default-layout.v4", }, ) return { projects: { - list: createMemo(() => - globalSync.data.defaultProject - ? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects] - : store.projects, - ), + list: createMemo(() => store.projects), open(directory: string) { if (store.projects.find((x) => x.directory === directory)) return setStore("projects", (x) => [...x, { directory, expanded: true }]) @@ -48,6 +42,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( collapse(directory: string) { setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x))) }, + lastSession(directory: string) { + return store.projects.find((x) => x.directory === directory)?.lastSession + }, + setLastSession(directory: string, session: string | undefined) { + setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, lastSession: session } : x))) + }, }, sidebar: { opened: createMemo(() => store.sidebar.opened), diff --git a/packages/desktop/src/pages/home.tsx b/packages/desktop/src/pages/home.tsx index c35d5754e..469e2b7f7 100644 --- a/packages/desktop/src/pages/home.tsx +++ b/packages/desktop/src/pages/home.tsx @@ -1,21 +1,63 @@ import { useGlobalSync } from "@/context/global-sync" -import { base64Encode } from "@opencode-ai/util/encode" -import { For } from "solid-js" -import { A } from "@solidjs/router" +import { For, Match, Switch } from "solid-js" import { Button } from "@opencode-ai/ui/button" -import { getFilename } from "@opencode-ai/util/path" +import { Logo } from "@opencode-ai/ui/logo" +import { useLayout } from "@/context/layout" +import { useNavigate } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/util/encode" +import { Icon } from "@opencode-ai/ui/icon" export default function Home() { + const navigate = useNavigate() const sync = useGlobalSync() + const layout = useLayout() + + function openProject(directory: string) { + layout.projects.open(directory) + navigate(`/${base64Encode(directory)}`) + } + return ( - <div class="flex flex-col gap-3"> - <For each={sync.data.projects}> - {(project) => ( - <Button as={A} href={base64Encode(project.worktree)}> - {getFilename(project.worktree)} - </Button> - )} - </For> + <div class="mx-auto mt-55"> + <Logo class="w-xl opacity-12" /> + <Switch> + <Match when={sync.data.projects.length > 0}> + <div class="mt-20 w-full flex flex-col gap-4"> + <div class="flex gap-2 items-center justify-between pl-3"> + <div class="text-14-medium text-text-strong">Recent projects</div> + <Button icon="folder-add-left" size="normal" class="pl-2 pr-3"> + Open project + </Button> + </div> + <ol class="flex flex-col gap-2"> + <For each={sync.data.projects.slice(0, 5)}> + {(project) => ( + <Button + size="large" + variant="ghost" + class="text-14-mono text-left justify-between px-3" + onClick={() => openProject(project.worktree)} + > + {project.worktree} + <div class="text-14-regular text-text-weak">10m ago</div> + </Button> + )} + </For> + </ol> + </div> + </Match> + <Match when={true}> + <div class="mt-30 mx-auto flex flex-col items-center gap-3"> + <Icon name="folder-add-left" size="large" /> + <div class="flex flex-col gap-1 items-center justify-center"> + <div class="text-14-medium text-text-strong">No recent projects</div> + <div class="text-12-regular text-text-weak">Get started by opening a local project</div> + </div> + <div /> + <Button class="px-3">Open project</Button> + </div> + </Match> + </Switch> </div> ) } diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 166ee7beb..03eeedfb1 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -1,10 +1,11 @@ -import { createMemo, For, ParentProps, Show } from "solid-js" +import { createEffect, createMemo, For, Match, ParentProps, Show, Switch } from "solid-js" import { DateTime } from "luxon" import { A, useNavigate, useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useGlobalSync } from "@/context/global-sync" import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { Mark } from "@opencode-ai/ui/logo" +import { Avatar } from "@opencode-ai/ui/avatar" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" @@ -14,6 +15,7 @@ import { Collapsible } from "@opencode-ai/ui/collapsible" import { DiffChanges } from "@opencode-ai/ui/diff-changes" import { getFilename } from "@opencode-ai/util/path" import { Select } from "@opencode-ai/ui/select" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Session } from "@opencode-ai/sdk/v2/client" export default function Layout(props: ParentProps) { @@ -25,15 +27,30 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? []) const currentSession = createMemo(() => sessions().find((s) => s.id === params.id)) + function navigateToProject(directory: string | undefined) { + if (!directory) return + navigate(`/${base64Encode(directory)}`) + } + function navigateToSession(session: Session | undefined) { if (!session) return navigate(`/${params.dir}/session/${session?.id}`) } + function closeProject(directory: string) { + layout.projects.close(directory) + navigate("/") + } + const handleOpenProject = async () => { // layout.projects.open(dir.) } + // createEffect(() => { + // if (!params.dir) return + // layout.projects.setLastSession(base64Decode(params.dir), params.id) + // }) + return ( <div class="relative h-screen flex flex-col"> <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region> @@ -50,61 +67,72 @@ export default function Layout(props: ParentProps) { <Mark class="shrink-0" /> </A> <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full"> - <div class="flex items-center gap-3"> - <div class="flex items-center gap-2"> - <Select - options={layout.projects.list().map((project) => getFilename(project.directory))} - current={getFilename(currentDirectory())} - class="text-14-regular text-text-base" - variant="ghost" - /> - <div class="text-text-weaker">/</div> - <Select - options={sessions()} - current={currentSession()} - placeholder="Select session" - label={(x) => x.title} - value={(x) => x.id} - onSelect={navigateToSession} - class="text-14-regular text-text-base max-w-md" - variant="ghost" - /> - </div> - <Button as={A} href={`/${params.dir}/session`} icon="plus-small"> - New session - </Button> - </div> - <div class="flex items-center gap-4"> - <Tooltip - class="shrink-0" - value={ - <div class="flex items-center gap-2"> - <span>Toggle terminal</span> - <span class="text-icon-base text-12-medium">Ctrl `</span> - </div> - } - > - <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" - /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> + <Show when={params.dir && layout.projects.list().length > 0}> + <div class="flex items-center gap-3"> + <div class="flex items-center gap-2"> + <Select + options={layout.projects.list().map((project) => getFilename(project.directory))} + current={getFilename(currentDirectory())} + onSelect={(x) => (x ? navigateToProject(x) : undefined)} + class="text-14-regular text-text-base" + variant="ghost" + > + {/* @ts-ignore */} + {(i) => ( + <div class="flex items-center gap-2"> + <Icon name="folder" size="small" /> + <div class="text-text-strong">{i}</div> + </div> + )} + </Select> + <div class="text-text-weaker">/</div> + <Select + options={sessions()} + current={currentSession()} + placeholder="Select session" + label={(x) => x.title} + value={(x) => x.id} + onSelect={navigateToSession} + class="text-14-regular text-text-base max-w-md" + variant="ghost" + /> + </div> + <Button as={A} href={`/${params.dir}/session`} icon="plus-small"> + New session </Button> - </Tooltip> - </div> + </div> + <div class="flex items-center gap-4"> + <Tooltip + class="shrink-0" + value={ + <div class="flex items-center gap-2"> + <span>Toggle terminal</span> + <span class="text-icon-base text-12-medium">Ctrl `</span> + </div> + } + > + <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}> + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + class="group-hover/terminal-toggle:hidden" + /> + <Icon + size="small" + name="layout-bottom-partial" + class="hidden group-hover/terminal-toggle:inline-block" + /> + <Icon + size="small" + name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + class="hidden group-active/terminal-toggle:inline-block" + /> + </div> + </Button> + </Tooltip> + </div> + </Show> </div> </header> <div class="h-[calc(100vh-3rem)] flex"> @@ -159,84 +187,135 @@ export default function Layout(props: ParentProps) { </Show> </Button> </Tooltip> - <div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0"> - <div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar"> - <For each={layout.projects.list()}> - {(project) => { - const [store] = globalSync.child(project.directory) - const slug = createMemo(() => base64Encode(project.directory)) - return ( - <Collapsible variant="ghost" defaultOpen class="gap-2"> - <Button - as={"div"} - variant="ghost" - class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch" - > - <Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate"> - {getFilename(project.directory)} - </Collapsible.Trigger> - <IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" /> - </Button> - <Collapsible.Content> - <nav class="w-full flex flex-col gap-1.5"> - <For each={store.session}> - {(session) => { - const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) - return ( - <A - data-active={session.id === params.id} - href={`${slug()}/session/${session.id}`} - class="group/session focus:outline-none cursor-default" - > - <Tooltip placement="right" value={session.title}> - <div - class="w-full px-2 py-1 rounded-md + <div class="size-full min-w-8 flex flex-col gap-2 grow min-h-0 overflow-y-auto no-scrollbar"> + <For each={layout.projects.list()}> + {(project) => { + const [store] = globalSync.child(project.directory) + const slug = createMemo(() => base64Encode(project.directory)) + const name = createMemo(() => getFilename(project.directory)) + return ( + <Switch> + <Match when={layout.sidebar.opened()}> + <Collapsible variant="ghost" defaultOpen class="gap-2"> + <Button + as={"div"} + variant="ghost" + class="group/session flex items-center justify-between gap-3 w-full px-1 self-stretch h-auto border-none" + > + <Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none"> + <div class="size-6 shrink-0"> + <Avatar + fallback={name()} + background="var(--surface-info-base)" + class="size-full group-hover/session:hidden" + /> + <Icon + name="chevron-right" + size="large" + class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50" + /> + </div> + <span class="truncate text-14-medium text-text-strong">{name()}</span> + </Collapsible.Trigger> + <div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible"> + <DropdownMenu> + <DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" /> + <DropdownMenu.Portal> + <DropdownMenu.Content> + <DropdownMenu.Item onSelect={() => closeProject(project.directory)}> + <DropdownMenu.ItemLabel>Close Project</DropdownMenu.ItemLabel> + </DropdownMenu.Item> + {/* <DropdownMenu.Separator /> */} + {/* <DropdownMenu.Item> */} + {/* <DropdownMenu.ItemLabel>Action 2</DropdownMenu.ItemLabel> */} + {/* </DropdownMenu.Item> */} + </DropdownMenu.Content> + </DropdownMenu.Portal> + </DropdownMenu> + <Tooltip placement="bottom" value="New session"> + <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" /> + </Tooltip> + </div> + </Button> + <Collapsible.Content> + <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5"> + <For each={store.session}> + {(session) => { + const updated = createMemo(() => DateTime.fromMillis(session.time.updated)) + return ( + <A + data-active={session.id === params.id} + href={`${slug()}/session/${session.id}`} + class="group/session focus:outline-none cursor-default" + > + <Tooltip placement="right" value={session.title}> + <div + class="w-full pl-4 pr-2 py-1 rounded-md group-data-[active=true]/session:bg-surface-raised-base-hover group-hover/session:bg-surface-raised-base-hover group-focus/session:bg-surface-raised-base-hover" - > - <div class="flex items-center self-stretch gap-6 justify-between"> - <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> - {session.title} - </span> - <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> - {Math.abs(updated().diffNow().as("seconds")) < 60 - ? "Now" - : updated() - .toRelative({ style: "short", unit: ["days", "hours", "minutes"] }) - ?.replace(" ago", "") - ?.replace(/ days?/, "d") - ?.replace(" min.", "m") - ?.replace(" hr.", "h")} - </span> - </div> - <div class="hidden _flex justify-between items-center self-stretch"> - <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> - <Show when={session.summary}> - {(summary) => <DiffChanges changes={summary()} />} - </Show> + > + <div class="flex items-center self-stretch gap-6 justify-between"> + <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate"> + {session.title} + </span> + <span class="text-12-regular text-text-weak text-right whitespace-nowrap"> + {Math.abs(updated().diffNow().as("seconds")) < 60 + ? "Now" + : updated() + .toRelative({ + style: "short", + unit: ["days", "hours", "minutes"], + }) + ?.replace(" ago", "") + ?.replace(/ days?/, "d") + ?.replace(" min.", "m") + ?.replace(" hr.", "h")} + </span> + </div> + <div class="hidden _flex justify-between items-center self-stretch"> + <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span> + <Show when={session.summary}> + {(summary) => <DiffChanges changes={summary()} />} + </Show> + </div> </div> - </div> - </Tooltip> - </A> - ) - }} - </For> - </nav> - {/* <Show when={sync.session.more()}> */} - {/* <button */} - {/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */} - {/* onClick={() => sync.session.fetch()} */} - {/* > */} - {/* Show more */} - {/* </button> */} - {/* </Show> */} - </Collapsible.Content> - </Collapsible> - ) - }} - </For> - </div> + </Tooltip> + </A> + ) + }} + </For> + </nav> + {/* <Show when={sync.session.more()}> */} + {/* <button */} + {/* class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */} + {/* onClick={() => sync.session.fetch()} */} + {/* > */} + {/* Show more */} + {/* </button> */} + {/* </Show> */} + </Collapsible.Content> + </Collapsible> + </Match> + <Match when={true}> + <Tooltip placement="right" value={project.directory}> + <Button + as={A} + href={`${slug()}/session`} + variant="ghost" + size="large" + class="flex items-center justify-center p-0 aspect-square border-none" + > + <div class="size-6 shrink-0 inset-0"> + <Avatar fallback={name()} background="var(--surface-info-base)" class="size-full" /> + </div> + </Button> + </Tooltip> + </Match> + </Switch> + ) + }} + </For> </div> </div> <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3"> diff --git a/packages/tauri/package.json b/packages/tauri/package.json index 70b9e7cc8..30dec36ff 100644 --- a/packages/tauri/package.json +++ b/packages/tauri/package.json @@ -9,7 +9,8 @@ "dev": "vite", "build": "bun run typecheck && vite build", "preview": "vite preview", - "tauri": "tauri" + "tauri": "tauri", + "typecheck": "tsgo --noEmit" }, "dependencies": { "@opencode-ai/desktop": "workspace:*", diff --git a/packages/ui/src/components/avatar.css b/packages/ui/src/components/avatar.css new file mode 100644 index 000000000..bc87f3bd8 --- /dev/null +++ b/packages/ui/src/components/avatar.css @@ -0,0 +1,35 @@ +[data-component="avatar"] { + --avatar-bg: var(--color-surface-info-base); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: var(--radius-sm); + border: 1px solid var(--color-border-weak-base); + font-family: var(--font-mono); + font-weight: 500; + text-transform: uppercase; + background-color: var(--avatar-bg); + color: oklch(from var(--avatar-bg) calc(l * 0.72) calc(c * 8) h); +} + +[data-component="avatar"][data-size="small"] { + width: 1.25rem; + height: 1.25rem; + font-size: 0.75rem; + line-height: 1; +} + +[data-component="avatar"][data-size="normal"] { + width: 1.5rem; + height: 1.5rem; + font-size: 1.125rem; + line-height: 1.5rem; +} + +[data-component="avatar"][data-size="large"] { + width: 2rem; + height: 2rem; + font-size: 1.25rem; + line-height: 2rem; +} diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx new file mode 100644 index 000000000..183a15b9b --- /dev/null +++ b/packages/ui/src/components/avatar.tsx @@ -0,0 +1,28 @@ +import { type ComponentProps, splitProps, Show } from "solid-js" + +export interface AvatarProps extends ComponentProps<"div"> { + fallback: string + background?: string + size?: "small" | "normal" | "large" +} + +export function Avatar(props: AvatarProps) { + const [split, rest] = splitProps(props, ["fallback", "background", "size", "class", "classList", "style"]) + return ( + <div + {...rest} + data-component="avatar" + data-size={split.size || "normal"} + classList={{ + ...(split.classList ?? {}), + [split.class ?? ""]: !!split.class, + }} + style={{ + ...(typeof split.style === "object" ? split.style : {}), + ...(split.background ? { "--avatar-bg": split.background } : {}), + }} + > + <Show when={split.fallback}>{split.fallback[0]}</Show> + </div> + ) +} diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css new file mode 100644 index 000000000..710ff2ddc --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.css @@ -0,0 +1,119 @@ +[data-component="dropdown-menu-content"], +[data-component="dropdown-menu-sub-content"] { + min-width: 8rem; + overflow: hidden; + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background-color: var(--surface-raised-stronger-non-alpha); + padding: 4px; + box-shadow: var(--shadow-md); + z-index: 50; + transform-origin: var(--kb-menu-content-transform-origin); + + &[data-closed] { + animation: dropdown-menu-close 0.15s ease-out; + } + + &[data-expanded] { + animation: dropdown-menu-open 0.15s ease-out; + } +} + +[data-component="dropdown-menu-content"], +[data-component="dropdown-menu-sub-content"] { + [data-slot="dropdown-menu-item"], + [data-slot="dropdown-menu-checkbox-item"], + [data-slot="dropdown-menu-radio-item"], + [data-slot="dropdown-menu-sub-trigger"] { + position: relative; + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + border-radius: var(--radius-sm); + cursor: default; + user-select: none; + outline: none; + + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-strong); + + &[data-highlighted] { + background: var(--surface-raised-base-hover); + } + + &[data-disabled] { + color: var(--text-weak); + pointer-events: none; + } + } + + [data-slot="dropdown-menu-sub-trigger"] { + &[data-expanded] { + background: var(--surface-raised-base-hover); + } + } + + [data-slot="dropdown-menu-item-indicator"] { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + } + + [data-slot="dropdown-menu-item-label"] { + flex: 1; + } + + [data-slot="dropdown-menu-item-description"] { + font-size: var(--font-size-x-small); + color: var(--text-weak); + } + + [data-slot="dropdown-menu-separator"] { + height: 1px; + margin: 4px -4px; + border-top-color: var(--border-weak-base); + } + + [data-slot="dropdown-menu-group-label"] { + padding: 4px 8px; + font-family: var(--font-family-sans); + font-size: var(--font-size-x-small); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + color: var(--text-weak); + } + + [data-slot="dropdown-menu-arrow"] { + fill: var(--surface-raised-stronger-non-alpha); + } +} + +@keyframes dropdown-menu-open { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes dropdown-menu-close { + from { + opacity: 1; + transform: scale(1); + } + to { + opacity: 0; + transform: scale(0.96); + } +} diff --git a/packages/ui/src/components/dropdown-menu.tsx b/packages/ui/src/components/dropdown-menu.tsx new file mode 100644 index 000000000..efb2b45ca --- /dev/null +++ b/packages/ui/src/components/dropdown-menu.tsx @@ -0,0 +1,308 @@ +import { DropdownMenu as Kobalte } from "@kobalte/core/dropdown-menu" +import { splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface DropdownMenuProps extends ComponentProps<typeof Kobalte> {} +export interface DropdownMenuTriggerProps extends ComponentProps<typeof Kobalte.Trigger> {} +export interface DropdownMenuIconProps extends ComponentProps<typeof Kobalte.Icon> {} +export interface DropdownMenuPortalProps extends ComponentProps<typeof Kobalte.Portal> {} +export interface DropdownMenuContentProps extends ComponentProps<typeof Kobalte.Content> {} +export interface DropdownMenuArrowProps extends ComponentProps<typeof Kobalte.Arrow> {} +export interface DropdownMenuSeparatorProps extends ComponentProps<typeof Kobalte.Separator> {} +export interface DropdownMenuGroupProps extends ComponentProps<typeof Kobalte.Group> {} +export interface DropdownMenuGroupLabelProps extends ComponentProps<typeof Kobalte.GroupLabel> {} +export interface DropdownMenuItemProps extends ComponentProps<typeof Kobalte.Item> {} +export interface DropdownMenuItemLabelProps extends ComponentProps<typeof Kobalte.ItemLabel> {} +export interface DropdownMenuItemDescriptionProps extends ComponentProps<typeof Kobalte.ItemDescription> {} +export interface DropdownMenuItemIndicatorProps extends ComponentProps<typeof Kobalte.ItemIndicator> {} +export interface DropdownMenuRadioGroupProps extends ComponentProps<typeof Kobalte.RadioGroup> {} +export interface DropdownMenuRadioItemProps extends ComponentProps<typeof Kobalte.RadioItem> {} +export interface DropdownMenuCheckboxItemProps extends ComponentProps<typeof Kobalte.CheckboxItem> {} +export interface DropdownMenuSubProps extends ComponentProps<typeof Kobalte.Sub> {} +export interface DropdownMenuSubTriggerProps extends ComponentProps<typeof Kobalte.SubTrigger> {} +export interface DropdownMenuSubContentProps extends ComponentProps<typeof Kobalte.SubContent> {} + +function DropdownMenuRoot(props: DropdownMenuProps) { + return <Kobalte {...props} data-component="dropdown-menu" /> +} + +function DropdownMenuTrigger(props: ParentProps<DropdownMenuTriggerProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.Trigger + {...rest} + data-slot="dropdown-menu-trigger" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.Trigger> + ) +} + +function DropdownMenuIcon(props: ParentProps<DropdownMenuIconProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.Icon + {...rest} + data-slot="dropdown-menu-icon" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.Icon> + ) +} + +function DropdownMenuPortal(props: DropdownMenuPortalProps) { + return <Kobalte.Portal {...props} /> +} + +function DropdownMenuContent(props: ParentProps<DropdownMenuContentProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.Content + {...rest} + data-component="dropdown-menu-content" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.Content> + ) +} + +function DropdownMenuArrow(props: DropdownMenuArrowProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + <Kobalte.Arrow + {...rest} + data-slot="dropdown-menu-arrow" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + /> + ) +} + +function DropdownMenuSeparator(props: DropdownMenuSeparatorProps) { + const [local, rest] = splitProps(props, ["class", "classList"]) + return ( + <Kobalte.Separator + {...rest} + data-slot="dropdown-menu-separator" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + /> + ) +} + +function DropdownMenuGroup(props: ParentProps<DropdownMenuGroupProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.Group + {...rest} + data-slot="dropdown-menu-group" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.Group> + ) +} + +function DropdownMenuGroupLabel(props: ParentProps<DropdownMenuGroupLabelProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.GroupLabel + {...rest} + data-slot="dropdown-menu-group-label" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.GroupLabel> + ) +} + +function DropdownMenuItem(props: ParentProps<DropdownMenuItemProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.Item + {...rest} + data-slot="dropdown-menu-item" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.Item> + ) +} + +function DropdownMenuItemLabel(props: ParentProps<DropdownMenuItemLabelProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.ItemLabel + {...rest} + data-slot="dropdown-menu-item-label" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.ItemLabel> + ) +} + +function DropdownMenuItemDescription(props: ParentProps<DropdownMenuItemDescriptionProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.ItemDescription + {...rest} + data-slot="dropdown-menu-item-description" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.ItemDescription> + ) +} + +function DropdownMenuItemIndicator(props: ParentProps<DropdownMenuItemIndicatorProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.ItemIndicator + {...rest} + data-slot="dropdown-menu-item-indicator" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.ItemIndicator> + ) +} + +function DropdownMenuRadioGroup(props: ParentProps<DropdownMenuRadioGroupProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.RadioGroup + {...rest} + data-slot="dropdown-menu-radio-group" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.RadioGroup> + ) +} + +function DropdownMenuRadioItem(props: ParentProps<DropdownMenuRadioItemProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.RadioItem + {...rest} + data-slot="dropdown-menu-radio-item" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.RadioItem> + ) +} + +function DropdownMenuCheckboxItem(props: ParentProps<DropdownMenuCheckboxItemProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.CheckboxItem + {...rest} + data-slot="dropdown-menu-checkbox-item" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.CheckboxItem> + ) +} + +function DropdownMenuSub(props: DropdownMenuSubProps) { + return <Kobalte.Sub {...props} /> +} + +function DropdownMenuSubTrigger(props: ParentProps<DropdownMenuSubTriggerProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.SubTrigger + {...rest} + data-slot="dropdown-menu-sub-trigger" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.SubTrigger> + ) +} + +function DropdownMenuSubContent(props: ParentProps<DropdownMenuSubContentProps>) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + <Kobalte.SubContent + {...rest} + data-component="dropdown-menu-sub-content" + classList={{ + ...(local.classList ?? {}), + [local.class ?? ""]: !!local.class, + }} + > + {local.children} + </Kobalte.SubContent> + ) +} + +export const DropdownMenu = Object.assign(DropdownMenuRoot, { + Trigger: DropdownMenuTrigger, + Icon: DropdownMenuIcon, + Portal: DropdownMenuPortal, + Content: DropdownMenuContent, + Arrow: DropdownMenuArrow, + Separator: DropdownMenuSeparator, + Group: DropdownMenuGroup, + GroupLabel: DropdownMenuGroupLabel, + Item: DropdownMenuItem, + ItemLabel: DropdownMenuItemLabel, + ItemDescription: DropdownMenuItemDescription, + ItemIndicator: DropdownMenuItemIndicator, + RadioGroup: DropdownMenuRadioGroup, + RadioItem: DropdownMenuRadioItem, + CheckboxItem: DropdownMenuCheckboxItem, + Sub: DropdownMenuSub, + SubTrigger: DropdownMenuSubTrigger, + SubContent: DropdownMenuSubContent, +}) diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 9e4f00a0d..8c83b41ce 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -133,6 +133,7 @@ const newIcons = { "magnifying-glass": `<path d="M15.8332 15.8337L13.0819 13.0824M14.6143 9.39088C14.6143 12.2759 12.2755 14.6148 9.39039 14.6148C6.50532 14.6148 4.1665 12.2759 4.1665 9.39088C4.1665 6.5058 6.50532 4.16699 9.39039 4.16699C12.2755 4.16699 14.6143 6.5058 14.6143 9.39088Z" stroke="currentColor" stroke-linecap="square"/>`, "plus-small": `<path d="M9.99984 5.41699V10.0003M9.99984 10.0003V14.5837M9.99984 10.0003H5.4165M9.99984 10.0003H14.5832" stroke="currentColor" stroke-linecap="square"/>`, "chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`, + "chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`, "arrow-up": `<path fill-rule="evenodd" clip-rule="evenodd" d="M9.99991 2.24121L16.0921 8.33343L15.2083 9.21731L10.6249 4.63397V17.5001H9.37492V4.63398L4.7916 9.21731L3.90771 8.33343L9.99991 2.24121Z" fill="currentColor"/>`, "check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`, "edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`, @@ -170,6 +171,7 @@ const newIcons = { "layout-bottom": `<path d="M18.125 18.125L1.875 18.125L1.875 1.875L18.125 1.875L18.125 18.125ZM3.125 12.8308L3.125 16.875L16.875 16.875L16.875 12.8308L3.125 12.8308ZM3.125 3.125L3.125 11.5808L16.875 11.5808L16.875 3.125L3.125 3.125Z" fill="currentColor"/>`, "layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, "layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, + "dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 9ba1f177b..bde5325e9 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,10 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, splitProps, type ComponentProps } from "solid-js" +import { createMemo, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" -export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect"> & { +export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | "onSelect" | "children"> & { placeholder?: string options: T[] current?: T @@ -14,6 +14,7 @@ export type SelectProps<T> = Omit<ComponentProps<typeof Kobalte<T>>, "value" | " onSelect?: (value: T | undefined) => void class?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] + children?: (item: T | undefined) => JSX.Element } export function Select<T>(props: SelectProps<T> & ButtonProps) { @@ -27,6 +28,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { "label", "groupBy", "onSelect", + "children", ]) const grouped = createMemo(() => { const result = pipe( @@ -63,7 +65,11 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) { {...itemProps} > <Kobalte.ItemLabel data-slot="select-select-item-label"> - {local.label ? local.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} + {local.children + ? local.children(itemProps.item.rawValue) + : local.label + ? local.label(itemProps.item.rawValue) + : (itemProps.item.rawValue as string)} </Kobalte.ItemLabel> <Kobalte.ItemIndicator data-slot="select-select-item-indicator"> <Icon name="check-small" size="small" /> diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index afe005f84..656bb928a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -6,6 +6,7 @@ @import "./base.css" layer(base); @import "../components/accordion.css" layer(components); +@import "../components/avatar.css" layer(components); @import "../components/basic-tool.css" layer(components); @import "../components/button.css" layer(components); @import "../components/card.css" layer(components); @@ -14,6 +15,7 @@ @import "../components/collapsible.css" layer(components); @import "../components/diff.css" layer(components); @import "../components/diff-changes.css" layer(components); +@import "../components/dropdown-menu.css" layer(components); @import "../components/dialog.css" layer(components); @import "../components/file-icon.css" layer(components); @import "../components/icon.css" layer(components); |
