diff options
| author | Adam <[email protected]> | 2025-12-10 21:16:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-11 06:48:58 -0600 |
| commit | 3bb546c94d6bb295bfeafdafbb9d34b7cc462560 (patch) | |
| tree | 54ae4fa1b18c68d23ff460caab596e942593a857 /packages | |
| parent | 8e15bcb68e9a8a37bb12afca3984f3967ccb58eb (diff) | |
| download | opencode-3bb546c94d6bb295bfeafdafbb9d34b7cc462560.tar.gz opencode-3bb546c94d6bb295bfeafdafbb9d34b7cc462560.zip | |
wip(desktop): progress
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/desktop/src/components/prompt-input.tsx | 97 | ||||
| -rw-r--r-- | packages/desktop/src/context/global-sync.tsx | 44 | ||||
| -rw-r--r-- | packages/desktop/src/context/layout.tsx | 22 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 23 | ||||
| -rw-r--r-- | packages/desktop/src/pages/layout.tsx | 213 | ||||
| -rw-r--r-- | packages/ui/src/components/button.css | 32 | ||||
| -rw-r--r-- | packages/ui/src/components/dialog.tsx | 7 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/input.css | 76 | ||||
| -rw-r--r-- | packages/ui/src/components/input.tsx | 42 | ||||
| -rw-r--r-- | packages/ui/src/components/select-dialog.tsx | 7 | ||||
| -rw-r--r-- | packages/ui/src/hooks/use-filtered-list.tsx | 7 |
12 files changed, 474 insertions, 97 deletions
diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index 22f2c1642..41af8644b 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -579,54 +579,61 @@ export const PromptInput: Component<PromptInputProps> = (props) => { </div> <div class="px-1.5 pb-1.5"> <div class="w-full rounded-sm border border-border-weak-base bg-surface-raised-base"> - <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-6"> + <div class="w-full flex flex-col items-start gap-4 px-1.5 pt-4 pb-4"> <div class="px-2 text-14-medium text-text-base"> Add more models from popular providers </div> - <List - class="w-full" - key={(x) => x?.id} - items={providers().popular()} - activeIcon="plus-small" - sortBy={(a, b) => { - if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) - return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) - return a.name.localeCompare(b.name) - }} - onSelect={(x) => { - layout.dialog.close("model") - }} - > - {(i) => ( - <div class="w-full flex items-center gap-x-4"> - <ProviderIcon - data-slot="list-item-extra-icon" - id={i.id as IconName} - // TODO: clean this up after we update icon in models.dev - classList={{ - "text-icon-weak-base": true, - "size-4 mx-0.5": i.id === "opencode", - "size-5": i.id !== "opencode", - }} - /> - <span>{i.name}</span> - <Show when={i.id === "opencode"}> - <Tag>Recommended</Tag> - </Show> - <Show when={i.id === "anthropic"}> - <div class="text-14-regular text-text-weak"> - Connect with Claude Pro/Max or API key - </div> - </Show> - </div> - )} - </List> - <Button variant="ghost" class="w-full justify-start"> - <div class="flex items-center gap-2"> - <Icon name="plus-small" /> - <div class="text-text-strong">View all providers</div> - </div> - </Button> + <div class="w-full"> + <List + class="w-full" + key={(x) => x?.id} + items={providers().popular()} + activeIcon="plus-small" + sortBy={(a, b) => { + if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) + return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id) + return a.name.localeCompare(b.name) + }} + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-4"> + <ProviderIcon + data-slot="list-item-extra-icon" + id={i.id as IconName} + // TODO: clean this up after we update icon in models.dev + classList={{ + "text-icon-weak-base": true, + "size-4 mx-0.5": i.id === "opencode", + "size-5": i.id !== "opencode", + }} + /> + <span>{i.name}</span> + <Show when={i.id === "opencode"}> + <Tag>Recommended</Tag> + </Show> + <Show when={i.id === "anthropic"}> + <div class="text-14-regular text-text-weak"> + Connect with Claude Pro/Max or API key + </div> + </Show> + </div> + )} + </List> + <Button + variant="ghost" + class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium" + icon="dot-grid" + onClick={() => { + layout.dialog.open("provider") + }} + > + View all providers + </Button> + </div> </div> </div> </div> diff --git a/packages/desktop/src/context/global-sync.tsx b/packages/desktop/src/context/global-sync.tsx index 3a6062fb8..09dfb3a83 100644 --- a/packages/desktop/src/context/global-sync.tsx +++ b/packages/desktop/src/context/global-sync.tsx @@ -12,11 +12,13 @@ import type { Todo, SessionStatus, ProviderListResponse, + ProviderAuthResponse, } from "@opencode-ai/sdk/v2" import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" +import { onMount } from "solid-js" type State = { ready: boolean @@ -54,11 +56,13 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple ready: boolean project: Project[] provider: ProviderListResponse + provider_auth: ProviderAuthResponse children: Record<string, State> }>({ ready: false, project: [], provider: { all: [], connected: [], default: {} }, + provider_auth: {}, children: {}, }) @@ -113,6 +117,10 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple const [store, setStore] = child(directory) switch (event.type) { + // case "server.instance.disposed": { + // bootstrap() + // break + // } case "session.updated": { const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) if (result.found) { @@ -181,19 +189,28 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple } }) - Promise.all([ - sdk.client.project.list().then(async (x) => { - setGlobalStore( - "project", - x - .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) - .sort((a, b) => a.id.localeCompare(b.id)), - ) - }), - sdk.client.provider.list().then((x) => { - setGlobalStore("provider", x.data ?? {}) - }), - ]).then(() => setGlobalStore("ready", true)) + async function bootstrap() { + return Promise.all([ + sdk.client.project.list().then(async (x) => { + setGlobalStore( + "project", + x + .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs) + .sort((a, b) => a.id.localeCompare(b.id)), + ) + }), + sdk.client.provider.list().then((x) => { + setGlobalStore("provider", x.data ?? {}) + }), + sdk.client.provider.auth().then((x) => { + setGlobalStore("provider_auth", x.data ?? {}) + }), + ]).then(() => setGlobalStore("ready", true)) + } + + onMount(() => { + bootstrap() + }) return { data: globalStore, @@ -201,6 +218,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple return globalStore.ready }, child, + bootstrap, } }, }) diff --git a/packages/desktop/src/context/layout.tsx b/packages/desktop/src/context/layout.tsx index 5530ad28f..d00e101b8 100644 --- a/packages/desktop/src/context/layout.tsx +++ b/packages/desktop/src/context/layout.tsx @@ -1,5 +1,5 @@ import { createStore } from "solid-js/store" -import { createMemo, onMount } from "solid-js" +import { batch, createMemo, onMount } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { makePersisted } from "@solid-primitives/storage" import { useGlobalSync } from "./global-sync" @@ -19,6 +19,8 @@ const PASTEL_COLORS = [ "#C1E1C1", // pastel mint ] +type Dialog = "provider" | "model" | "connect" + export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ name: "Layout", init: () => { @@ -44,8 +46,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, ) const [ephemeral, setEphemeral] = createStore({ + connect: { + provider: undefined as undefined | string, + }, dialog: { - open: undefined as undefined | "provider" | "model", + open: undefined as undefined | Dialog, }, }) const usedColors = new Set<string>() @@ -169,14 +174,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, dialog: { opened: createMemo(() => ephemeral.dialog?.open), - open(dialog: "provider" | "model") { + open(dialog: Dialog) { setEphemeral("dialog", "open", dialog) }, - close(dialog: "provider" | "model") { + close(dialog: Dialog) { if (ephemeral.dialog?.open === dialog) { setEphemeral("dialog", "open", undefined) } }, + connect(provider: string) { + batch(() => { + setEphemeral("dialog", "open", "connect") + setEphemeral("connect", "provider", provider) + }) + }, + }, + connect: { + provider: createMemo(() => ephemeral.connect.provider), }, } }, diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx index 1a11cd599..d64fcce2a 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -1,5 +1,5 @@ import { produce } from "solid-js/store" -import { createMemo } from "solid-js" +import { createMemo, onMount } from "solid-js" import { Binary } from "@opencode-ai/util/binary" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSync } from "./global-sync" @@ -31,7 +31,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ node: () => sdk.client.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), } - Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + async function bootstrap() { + return Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true)) + } + + onMount(() => { + bootstrap() + }) + + sdk.event.listen((e) => { + if (e.name !== sdk.directory) return + const event = e.details + switch (event.type) { + case "server.instance.disposed": { + bootstrap() + break + } + } + }) const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") @@ -82,7 +99,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }, more: createMemo(() => store.session.length >= store.limit), }, - load, + bootstrap, absolute, get directory() { return store.path.directory diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx index 10d4cbfda..0ba6c0a2d 100644 --- a/packages/desktop/src/pages/layout.tsx +++ b/packages/desktop/src/pages/layout.tsx @@ -17,7 +17,7 @@ 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, Project } from "@opencode-ai/sdk/v2/client" +import { Session, Project, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { usePlatform } from "@/context/platform" import { createStore } from "solid-js/store" import { @@ -34,6 +34,11 @@ import { SelectDialog } from "@opencode-ai/ui/select-dialog" import { Tag } from "@opencode-ai/ui/tag" import { IconName } from "@opencode-ai/ui/icons/provider" import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { iife } from "@opencode-ai/util/iife" +import { List, ListRef } from "@opencode-ai/ui/list" +import { Input } from "@opencode-ai/ui/input" +import { useGlobalSDK } from "@/context/global-sdk" export default function Layout(props: ParentProps) { const [store, setStore] = createStore({ @@ -42,6 +47,7 @@ export default function Layout(props: ParentProps) { }) const params = useParams() + const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() const platform = usePlatform() @@ -562,7 +568,6 @@ export default function Layout(props: ParentProps) { activeIcon="plus-small" key={(x) => x?.id} items={providers().all} - // current={local.model.current()} filterKeys={["id", "name"]} groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} sortBy={(a, b) => { @@ -575,7 +580,10 @@ export default function Layout(props: ParentProps) { if (b.category === "Popular" && a.category !== "Popular") return 1 return 0 }} - // onSelect={(x) => } + onSelect={(x) => { + if (!x) return + layout.dialog.connect(x.id) + }} onOpenChange={(open) => { if (open) { layout.dialog.open("provider") @@ -607,6 +615,205 @@ export default function Layout(props: ParentProps) { )} </SelectDialog> </Show> + <Show when={layout.dialog?.opened() === "connect"}> + {iife(() => { + const [store, setStore] = createStore({ + method: undefined as undefined | ProviderAuthMethod, + }) + const providerID = layout.connect.provider()! + const provider = globalSync.data.provider.all.find((x) => x.id === providerID)! + const methods = globalSync.data.provider_auth[providerID] ?? [ + { + type: "api", + label: "API key", + }, + ] + if (methods.length === 1) { + setStore("method", methods[0]) + } + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + return ( + <Dialog + modal + defaultOpen + onOpenChange={(open) => { + if (open) { + layout.dialog.open("connect") + } else { + layout.dialog.close("connect") + } + }} + > + <Dialog.Header class="px-4.5"> + <Dialog.Title class="flex items-center"> + <IconButton + tabIndex={-1} + icon="arrow-left" + variant="ghost" + onClick={() => { + if (store.method && methods.length > 1) { + setStore("method", undefined) + return + } + layout.dialog.open("provider") + }} + /> + </Dialog.Title> + <Dialog.CloseButton tabIndex={-1} /> + </Dialog.Header> + <Dialog.Body> + <div class="flex flex-col gap-6 px-2.5 pb-3"> + <div class="px-2.5 flex gap-4 items-center"> + <ProviderIcon id={providerID as IconName} class="size-5 shrink-0 icon-strong-base" /> + <div class="text-16-medium text-text-strong">Connect {provider.name}</div> + </div> + <Show when={store.method === undefined}> + <div class="px-2.5 text-14-regular text-text-base">Select login method for {provider.name}.</div> + <div class=""> + <Input hidden type="text" class="opacity-0 size-0" autofocus onKeyDown={handleKey} /> + <List + ref={(ref) => (listRef = ref)} + items={methods} + key={(m) => m?.label} + onSelect={(method) => { + if (!method) return + setStore("method", method) + + if (method.type === "oauth") { + // const result = await sdk.client.provider.oauth.authorize({ + // providerID: provider.id, + // method: index, + // }) + // if (result.data?.method === "code") { + // dialog.replace(() => ( + // <CodeMethod + // providerID={provider.id} + // title={method.label} + // index={index} + // authorization={result.data!} + // /> + // )) + // } + // if (result.data?.method === "auto") { + // dialog.replace(() => ( + // <AutoMethod + // providerID={provider.id} + // title={method.label} + // index={index} + // authorization={result.data!} + // /> + // )) + // } + } + if (method.type === "api") { + // return dialog.replace(() => <ApiMethod providerID={provider.id} title={method.label} />) + } + }} + > + {(i) => ( + <div class="w-full flex items-center gap-x-2.5"> + {/* TODO: add checkmark thing */} + <span>{i.label}</span> + </div> + )} + </List> + </div> + </Show> + <Show when={store.method?.type === "api"}> + {iife(() => { + const [formStore, setFormStore] = createStore({ + value: "", + error: undefined as string | undefined, + }) + + async function handleSubmit(e: SubmitEvent) { + e.preventDefault() + + const form = e.currentTarget as HTMLFormElement + const formData = new FormData(form) + const apiKey = formData.get("apiKey") as string + + if (!apiKey?.trim()) { + setFormStore("error", "API key is required") + return + } + + setFormStore("error", undefined) + globalSDK.client.auth.set({ + providerID, + auth: { + type: "api", + key: apiKey, + }, + }) + await globalSDK.client.instance.dispose() + } + + return ( + <div class="px-2.5 pb-10 flex flex-col gap-6"> + <Switch> + <Match when={provider.id === "opencode"}> + <div class="flex flex-col gap-4"> + <div class="text-14-regular text-text-base"> + OpenCode Zen gives you access to a curated set of reliable optimized models for + coding agents. + </div> + <div class="text-14-regular text-text-base"> + With a single API key you’ll get access to models such as Claude, GPT, Gemini, GLM + and more. + </div> + <div class="text-14-regular text-text-base"> + Visit{" "} + <button + tabIndex={-1} + class="text-text-strong underline" + onClick={() => platform.openLink("https://opencode.ai/zen")} + > + opencode.ai/zen + </button>{" "} + to collect your API key. + </div> + </div> + </Match> + <Match when={true}> + <div class="text-14-regular text-text-base"> + Enter your {provider.name} API key to connect your account and use {provider.name}{" "} + models in OpenCode. + </div> + </Match> + </Switch> + <form onSubmit={handleSubmit} class="flex flex-col items-start gap-4"> + <Input + autofocus + type="text" + label={`${provider.name} API key`} + placeholder="API key" + name="apiKey" + value={formStore.value} + onChange={setFormStore.bind(null, "value")} + validationState={formStore.error ? "invalid" : undefined} + error={formStore.error} + /> + <Button class="w-auto" type="submit" size="large" variant="primary"> + Submit + </Button> + </form> + </div> + ) + })} + </Show> + </div> + </Dialog.Body> + </Dialog> + ) + })} + </Show> </div> </div> ) diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index f95317028..3a32672fe 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -11,27 +11,29 @@ outline: none; &[data-variant="primary"] { - border-color: var(--border-base); - background-color: var(--surface-brand-base); - color: var(--text-on-brand-strong); + background-color: var(--icon-strong-base); + border-color: var(--border-weak-base); + color: var(--icon-invert-base); + + [data-slot="icon-svg"] { + color: var(--icon-invert-base); + } &:hover:not(:disabled) { - border-color: var(--border-hover); - background-color: var(--surface-brand-hover); + background-color: var(--icon-strong-hover); } &:focus:not(:disabled) { - border-color: var(--border-focus); - background-color: var(--surface-brand-focus); + background-color: var(--icon-strong-focus); } &:active:not(:disabled) { - border-color: var(--border-active); - background-color: var(--surface-brand-active); + background-color: var(--icon-strong-active); } &:disabled { - border-color: var(--border-disabled); - background-color: var(--surface-disabled); - color: var(--text-weak); - cursor: not-allowed; + background-color: var(--icon-strong-disabled); + + [data-slot="icon-svg"] { + color: var(--icon-invert-base); + } } } @@ -120,13 +122,13 @@ &[data-size="large"] { height: 32px; - padding: 0 8px; + padding: 6px 12px; &[data-icon] { padding: 0 12px 0 8px; } - gap: 8px; + gap: 4px; /* text-14-medium */ font-family: var(--font-family-sans); diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index a16705a57..56053278d 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -5,7 +5,7 @@ import { DialogCloseButtonProps, DialogDescriptionProps, } from "@kobalte/core/dialog" -import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js" +import { ComponentProps, type JSX, onCleanup, onMount, Show, splitProps } from "solid-js" import { IconButton } from "./icon-button" export interface DialogProps extends DialogRootProps { @@ -35,6 +35,11 @@ export function DialogRoot(props: DialogProps) { }) } + onMount(() => { + // @ts-ignore + document?.activeElement?.blur?.() + }) + return ( <Kobalte {...others}> <Show when={props.trigger}> diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 97f2e8eab..080a6274d 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -3,6 +3,7 @@ import { splitProps, type ComponentProps } from "solid-js" const icons = { "align-right": `<path d="M12.292 6.04167L16.2503 9.99998L12.292 13.9583M2.91699 9.99998H15.6253M17.0837 3.75V16.25" 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"/>`, + "arrow-left": `<path d="M8.33464 4.58398L2.91797 10.0007L8.33464 15.4173M3.33464 10.0007H17.0846" stroke="currentColor" stroke-linecap="square"/>`, "bubble-5": `<path d="M18.3327 9.99935C18.3327 5.57227 15.0919 2.91602 9.99935 2.91602C4.90676 2.91602 1.66602 5.57227 1.66602 9.99935C1.66602 11.1487 2.45505 13.1006 2.57637 13.3939C2.58707 13.4197 2.59766 13.4434 2.60729 13.4697C2.69121 13.6987 3.04209 14.9354 1.66602 16.7674C3.51787 17.6528 5.48453 16.1973 5.48453 16.1973C6.84518 16.9193 8.46417 17.0827 9.99935 17.0827C15.0919 17.0827 18.3327 14.4264 18.3327 9.99935Z" stroke="currentColor" stroke-linecap="square"/>`, "bullet-list": `<path d="M9.58329 13.7497H17.0833M9.58329 6.24967H17.0833M6.24996 6.24967C6.24996 7.17015 5.50377 7.91634 4.58329 7.91634C3.66282 7.91634 2.91663 7.17015 2.91663 6.24967C2.91663 5.3292 3.66282 4.58301 4.58329 4.58301C5.50377 4.58301 6.24996 5.3292 6.24996 6.24967ZM6.24996 13.7497C6.24996 14.6701 5.50377 15.4163 4.58329 15.4163C3.66282 15.4163 2.91663 14.6701 2.91663 13.7497C2.91663 12.8292 3.66282 12.083 4.58329 12.083C5.50377 12.083 6.24996 12.8292 6.24996 13.7497Z" stroke="currentColor" stroke-linecap="square"/>`, "check-small": `<path d="M6.5 11.4412L8.97059 13.5L13.5 6.5" stroke="currentColor" stroke-linecap="square"/>`, diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css index c5f8cb8c5..276e8069b 100644 --- a/packages/ui/src/components/input.css +++ b/packages/ui/src/components/input.css @@ -1,6 +1,5 @@ [data-component="input"] { width: 100%; - /* [data-slot="input-label"] {} */ [data-slot="input-input"] { width: 100%; @@ -22,4 +21,79 @@ color: var(--text-weak); } } + + &[data-variant="normal"] { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + + [data-slot="input-label"] { + color: var(--text-weak); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="input-input"] { + color: var(--text-strong); + + display: flex; + height: 32px; + padding: 2px 12px; + align-items: center; + gap: 8px; + align-self: stretch; + + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background: var(--input-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + &:focus { + outline: none; + + /* border/shadow-xs/select */ + box-shadow: + 0 0 0 3px var(--border-weak-selected), + 0 0 0 1px var(--border-selected), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), + 0 1px 3px 0 rgba(19, 16, 16, 0.12); + } + + &[data-invalid] { + background: var(--surface-critical-weak); + border: 1px solid var(--border-critical-selected); + } + + &::placeholder { + color: var(--text-weak); + } + } + + [data-slot="input-error"] { + color: var(--text-on-critical-base); + + /* text-12-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: 18px; /* 150% */ + letter-spacing: var(--letter-spacing-normal); + } + } } diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx index 82f704e8c..8e2a115c6 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/input.tsx @@ -4,31 +4,61 @@ import type { ComponentProps } from "solid-js" export interface InputProps extends ComponentProps<typeof Kobalte.Input>, - Partial<Pick<ComponentProps<typeof Kobalte>, "value" | "onChange" | "onKeyDown">> { + Partial< + Pick< + ComponentProps<typeof Kobalte>, + | "name" + | "defaultValue" + | "value" + | "onChange" + | "onKeyDown" + | "validationState" + | "required" + | "disabled" + | "readOnly" + > + > { label?: string hideLabel?: boolean hidden?: boolean description?: string + error?: string + variant?: "normal" | "ghost" } export function Input(props: InputProps) { const [local, others] = splitProps(props, [ + "name", + "defaultValue", + "value", + "onChange", + "onKeyDown", + "validationState", + "required", + "disabled", + "readOnly", "class", "label", "hidden", "hideLabel", "description", - "value", - "onChange", - "onKeyDown", + "error", + "variant", ]) return ( <Kobalte data-component="input" - style={{ height: local.hidden ? 0 : undefined }} + data-variant={local.variant || "normal"} + name={local.name} + defaultValue={local.defaultValue} value={local.value} onChange={local.onChange} onKeyDown={local.onKeyDown} + required={local.required} + disabled={local.disabled} + readOnly={local.readOnly} + style={{ height: local.hidden ? 0 : undefined }} + validationState={local.validationState} > <Show when={local.label}> <Kobalte.Label data-slot="input-label" classList={{ "sr-only": local.hideLabel }}> @@ -39,7 +69,7 @@ export function Input(props: InputProps) { <Show when={local.description}> <Kobalte.Description data-slot="input-description">{local.description}</Kobalte.Description> </Show> - <Kobalte.ErrorMessage data-slot="input-error" /> + <Kobalte.ErrorMessage data-slot="input-error">{local.error}</Kobalte.ErrorMessage> </Kobalte> ) } diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index 952ba881f..efa6c405b 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -1,9 +1,9 @@ import { createEffect, Show, type JSX, splitProps, createSignal } from "solid-js" import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" -import { Input } from "./input" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" +import { Input } from "./input" interface SelectDialogProps<T> extends Omit<ListProps<T>, "filter">, @@ -29,8 +29,8 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) { }) }) - const handleSelect = (item: T | undefined) => { - others.onSelect?.(item) + const handleSelect = (item: T | undefined, index: number) => { + others.onSelect?.(item, index) closeButton.click() } @@ -58,6 +58,7 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) { <Input ref={inputRef} autofocus + variant="ghost" data-slot="select-dialog-input" type="text" value={filter()} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e0cb6e7aa..f9745918a 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -12,7 +12,7 @@ export interface FilteredListProps<T> { groupBy?: (x: T) => string sortBy?: (a: T, b: T) => number sortGroupsBy?: (a: { category: string; items: T[] }, b: { category: string; items: T[] }) => number - onSelect?: (value: T | undefined) => void + onSelect?: (value: T | undefined, index: number) => void } export function useFilteredList<T>(props: FilteredListProps<T>) { @@ -63,8 +63,9 @@ export function useFilteredList<T>(props: FilteredListProps<T>) { const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault() - const selected = flat().find((x) => props.key(x) === list.active()) - if (selected) props.onSelect?.(selected) + const selectedIndex = flat().findIndex((x) => props.key(x) === list.active()) + const selected = flat()[selectedIndex] + if (selected) props.onSelect?.(selected, selectedIndex) } else { list.onKeyDown(event) } |
