From 4246cdb069502c96ab11e260eb36a07a0370b710 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Sun, 14 Dec 2025 19:33:40 -0600 Subject: [PATCH] wip(desktop): progress --- .../desktop/src/components/dialog-connect.tsx | 2 +- .../src/components/dialog-file-select.tsx | 52 ++++ .../src/components/dialog-manage-models.tsx | 65 +++++ .../src/components/dialog-model-unpaid.tsx | 133 +++++++++ .../desktop/src/components/dialog-model.tsx | 275 ++++++------------ .../src/components/dialog-select-provider.tsx | 101 ++++--- .../desktop/src/components/prompt-input.tsx | 9 +- packages/desktop/src/context/local.tsx | 7 +- packages/desktop/src/pages/session.tsx | 38 +-- packages/ui/src/components/dialog.css | 27 +- packages/ui/src/components/list.css | 38 +++ packages/ui/src/components/list.tsx | 141 +++++---- packages/ui/src/components/select-dialog.css | 44 --- packages/ui/src/components/select-dialog.tsx | 93 ------ packages/ui/src/components/session-turn.css | 2 - packages/ui/src/components/session-turn.tsx | 4 +- packages/ui/src/components/switch.css | 131 +++++++++ packages/ui/src/components/switch.tsx | 30 ++ packages/ui/src/hooks/use-filtered-list.tsx | 11 +- packages/ui/src/styles/index.css | 2 +- 20 files changed, 726 insertions(+), 479 deletions(-) create mode 100644 packages/desktop/src/components/dialog-file-select.tsx create mode 100644 packages/desktop/src/components/dialog-manage-models.tsx create mode 100644 packages/desktop/src/components/dialog-model-unpaid.tsx delete mode 100644 packages/ui/src/components/select-dialog.css delete mode 100644 packages/ui/src/components/select-dialog.tsx create mode 100644 packages/ui/src/components/switch.css create mode 100644 packages/ui/src/components/switch.tsx diff --git a/packages/desktop/src/components/dialog-connect.tsx b/packages/desktop/src/components/dialog-connect.tsx index d482b3f50..3a1e05f27 100644 --- a/packages/desktop/src/components/dialog-connect.tsx +++ b/packages/desktop/src/components/dialog-connect.tsx @@ -117,7 +117,7 @@ export const DialogConnect: Component<{ provider: string }> = (props) => { title: `${provider().name} connected`, description: `${provider().name} models are now available to use.`, }) - dialog.replace(() => ) + dialog.replace(() => ) }, 500) } diff --git a/packages/desktop/src/components/dialog-file-select.tsx b/packages/desktop/src/components/dialog-file-select.tsx new file mode 100644 index 000000000..3afe06062 --- /dev/null +++ b/packages/desktop/src/components/dialog-file-select.tsx @@ -0,0 +1,52 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { FileIcon } from "@opencode-ai/ui/file-icon" +import { getDirectory, getFilename } from "@opencode-ai/util/path" + +export const DialogFileSelect: Component<{ + onOpenChange?: (open: boolean) => void + onSelect?: (path: string) => void +}> = (props) => { + const local = useLocal() + let closeButton!: HTMLButtonElement + + return ( + + + Select file + + + + x} + onSelect={(x) => { + if (x) { + props.onSelect?.(x) + } + closeButton.click() + }} + > + {(i) => ( +
+
+ +
+ + {getDirectory(i)} + + {getFilename(i)} +
+
+
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-manage-models.tsx b/packages/desktop/src/components/dialog-manage-models.tsx new file mode 100644 index 000000000..2904f9a5b --- /dev/null +++ b/packages/desktop/src/components/dialog-manage-models.tsx @@ -0,0 +1,65 @@ +import { Component } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders } from "@/hooks/use-providers" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +export const DialogManageModels: Component = () => { + const local = useLocal() + const dialog = useDialog() + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Manage models + + + Customize which models appear in the model selector. + + `${x?.provider?.id}:${x?.id}`} + items={local.model.list()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + if (!x) return + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !x.visible) + }} + > + {(i) => ( +
+ {i.name} + { + local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked) + }} + /> +
+ )} +
+
+
+ ) +} diff --git a/packages/desktop/src/components/dialog-model-unpaid.tsx b/packages/desktop/src/components/dialog-model-unpaid.tsx new file mode 100644 index 000000000..d218770d9 --- /dev/null +++ b/packages/desktop/src/components/dialog-model-unpaid.tsx @@ -0,0 +1,133 @@ +import { Component, onCleanup, onMount, Show } from "solid-js" +import { useLocal } from "@/context/local" +import { useDialog } from "@/context/dialog" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { Button } from "@opencode-ai/ui/button" +import { Tag } from "@opencode-ai/ui/tag" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List, ListRef } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { IconName } from "@opencode-ai/ui/icons/provider" +import { DialogSelectProvider } from "./dialog-select-provider" +import { DialogConnect } from "./dialog-connect" + +export const DialogModelUnpaid: Component = () => { + const local = useLocal() + const dialog = useDialog() + const providers = useProviders() + + let listRef: ListRef | undefined + const handleKey = (e: KeyboardEvent) => { + if (e.key === "Escape") return + listRef?.onKeyDown(e) + } + + onMount(() => { + document.addEventListener("keydown", handleKey) + onCleanup(() => { + document.removeEventListener("keydown", handleKey) + }) + }) + + return ( + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + +
+
Free models provided by OpenCode
+ (listRef = ref)} + items={local.model.list} + current={local.model.current()} + key={(x) => `${x.provider.id}:${x.id}`} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, + }) + dialog.clear() + }} + > + {(i) => ( +
+ {i.name} + Free + + Latest + +
+ )} +
+
+
+
+
+
+
+
Add more models from popular providers
+
+ 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 + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+ +
+
+
+
+ +
+ ) +} diff --git a/packages/desktop/src/components/dialog-model.tsx b/packages/desktop/src/components/dialog-model.tsx index 7f90e1a78..e8f9df055 100644 --- a/packages/desktop/src/components/dialog-model.tsx +++ b/packages/desktop/src/components/dialog-model.tsx @@ -1,208 +1,95 @@ -import { Component, createMemo, Match, onCleanup, onMount, Show, Switch } from "solid-js" +import { Component, createMemo, Show } from "solid-js" import { useLocal } from "@/context/local" import { useDialog } from "@/context/dialog" -import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { popularProviders } from "@/hooks/use-providers" import { Button } from "@opencode-ai/ui/button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" -import { List, ListRef } from "@opencode-ai/ui/list" -import { iife } from "@opencode-ai/util/iife" -import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { IconName } from "@opencode-ai/ui/icons/provider" +import { List } from "@opencode-ai/ui/list" import { DialogSelectProvider } from "./dialog-select-provider" -import { DialogConnect } from "./dialog-connect" +import { DialogManageModels } from "./dialog-manage-models" -export const DialogModel: Component<{ connectedProvider?: string }> = (props) => { +export const DialogModel: Component<{ provider?: string }> = (props) => { const local = useLocal() const dialog = useDialog() - const providers = useProviders() + + let closeButton!: HTMLButtonElement + const models = createMemo(() => + local.model + .list() + .filter((m) => m.visible) + .filter((m) => (props.provider ? m.provider.id === props.provider : true)), + ) return ( - - 0}> - {iife(() => { - const models = createMemo(() => - local.model - .list() - .filter((m) => m.visible) - .filter((m) => (props.connectedProvider ? m.provider.id === props.connectedProvider : true)), - ) - return ( - { - if (!open) { - dialog.clear() - } - }} - title="Select model" - placeholder="Search models" - emptyMessage="No model results" - key={(x) => `${x.provider.id}:${x.id}`} - items={models} - current={local.model.current()} - filterKeys={["provider.name", "name", "id"]} - sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} - sortGroupsBy={(a, b) => { - if (a.category === "Recent" && b.category !== "Recent") return -1 - if (b.category === "Recent" && a.category !== "Recent") return 1 - const aProvider = a.items[0].provider.id - const bProvider = b.items[0].provider.id - if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 - if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 - return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) - }} - onSelect={(x) => - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - } - actions={ - - } - > - {(i) => ( -
- {i.name} - - Free - - - Latest - -
- )} -
- ) - })} -
- - {iife(() => { - let listRef: ListRef | undefined - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - onMount(() => { - document.addEventListener("keydown", handleKey) - onCleanup(() => { - document.removeEventListener("keydown", handleKey) + { + if (!open) { + dialog.clear() + } + }} + > + + Select model + + + + + `${x.provider.id}:${x.id}`} + items={models} + current={local.model.current()} + filterKeys={["provider.name", "name", "id"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + groupBy={(x) => x.provider.name} + sortGroupsBy={(a, b) => { + if (a.category === "Recent" && b.category !== "Recent") return -1 + if (b.category === "Recent" && a.category !== "Recent") return 1 + const aProvider = a.items[0].provider.id + const bProvider = b.items[0].provider.id + if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1 + if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1 + return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider) + }} + onSelect={(x) => { + local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { + recent: true, }) - }) - - return ( - { - if (!open) { - dialog.clear() - } - }} - > - - Select model - - - -
-
Free models provided by OpenCode
- (listRef = ref)} - items={local.model.list} - current={local.model.current()} - key={(x) => `${x.provider.id}:${x.id}`} - onSelect={(x) => { - local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, { - recent: true, - }) - dialog.clear() - }} - > - {(i) => ( -
- {i.name} - Free - - Latest - -
- )} -
-
-
-
-
-
-
-
Add more models from popular providers
-
- 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 - dialog.replace(() => ) - }} - > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
- -
-
-
-
- -
- ) - })} -
-
+ closeButton.click() + }} + > + {(i) => ( +
+ {i.name} + + Free + + + Latest + +
+ )} + + + + ) } diff --git a/packages/desktop/src/components/dialog-select-provider.tsx b/packages/desktop/src/components/dialog-select-provider.tsx index 6dabdb8b4..1c54184bd 100644 --- a/packages/desktop/src/components/dialog-select-provider.tsx +++ b/packages/desktop/src/components/dialog-select-provider.tsx @@ -1,7 +1,8 @@ import { Component, Show } from "solid-js" import { useDialog } from "@/context/dialog" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { SelectDialog } from "@opencode-ai/ui/select-dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" import { Tag } from "@opencode-ai/ui/tag" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { IconName } from "@opencode-ai/ui/icons/provider" @@ -12,56 +13,66 @@ export const DialogSelectProvider: Component = () => { const providers = useProviders() return ( - x?.id} - items={providers.all} - filterKeys={["id", "name"]} - groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} - 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) - }} - sortGroupsBy={(a, b) => { - if (a.category === "Popular" && b.category !== "Popular") return -1 - if (b.category === "Popular" && a.category !== "Popular") return 1 - return 0 - }} - onSelect={(x) => { - if (!x) return - dialog.replace(() => ) - }} onOpenChange={(open) => { if (!open) { dialog.clear() } }} > - {(i) => ( -
- - {i.name} - - Recommended - - -
Connect with Claude Pro/Max or API key
-
-
- )} -
+ + Connect provider + + + + x?.id} + items={providers.all} + filterKeys={["id", "name"]} + groupBy={(x) => (popularProviders.includes(x.id) ? "Popular" : "Other")} + 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) + }} + sortGroupsBy={(a, b) => { + if (a.category === "Popular" && b.category !== "Popular") return -1 + if (b.category === "Popular" && a.category !== "Popular") return 1 + return 0 + }} + onSelect={(x) => { + if (!x) return + dialog.replace(() => ) + }} + > + {(i) => ( +
+ + {i.name} + + Recommended + + +
Connect with Claude Pro/Max or API key
+
+
+ )} +
+
+ ) } diff --git a/packages/desktop/src/components/prompt-input.tsx b/packages/desktop/src/components/prompt-input.tsx index ca0ccf96a..faecd9520 100644 --- a/packages/desktop/src/components/prompt-input.tsx +++ b/packages/desktop/src/components/prompt-input.tsx @@ -17,6 +17,8 @@ import { Select } from "@opencode-ai/ui/select" import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useDialog } from "@/context/dialog" import { DialogModel } from "@/components/dialog-model" +import { DialogModelUnpaid } from "@/components/dialog-model-unpaid" +import { useProviders } from "@/hooks/use-providers" interface PromptInputProps { class?: string @@ -58,6 +60,7 @@ export const PromptInput: Component = (props) => { const local = useLocal() const session = useSession() const dialog = useDialog() + const providers = useProviders() let editorRef!: HTMLDivElement const [store, setStore] = createStore<{ @@ -610,7 +613,11 @@ export const PromptInput: Component = (props) => { class="capitalize" variant="ghost" /> - - )} - + + setInternalFilter("")} /> + + + +
+ 0} + fallback={ +
+
+ {props.emptyMessage ?? "No results"} for "{filter()}"
- )} - -
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item, i) => ( + + )} + +
+
+ )} +
+ +
) } diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css deleted file mode 100644 index 9759174a6..000000000 --- a/packages/ui/src/components/select-dialog.css +++ /dev/null @@ -1,44 +0,0 @@ -[data-slot="select-dialog-content"] { - width: 100%; - display: flex; - flex-direction: column; - overflow: hidden; - gap: 20px; - padding: 0 10px; - - [data-slot="dialog-body"] { - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } - } -} - -[data-component="select-dialog-input"] { - display: flex; - height: 40px; - flex-shrink: 0; - padding: 4px 10px 4px 16px; - align-items: center; - gap: 12px; - align-self: stretch; - - border-radius: var(--radius-md); - background: var(--surface-base); - - [data-slot="select-dialog-input-container"] { - display: flex; - align-items: center; - gap: 16px; - flex: 1 0 0; - - /* [data-slot="select-dialog-icon"] {} */ - - [data-slot="select-dialog-input"] { - width: 100%; - } - } - - /* [data-slot="select-dialog-clear-button"] {} */ -} diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx deleted file mode 100644 index 68707536a..000000000 --- a/packages/ui/src/components/select-dialog.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Show, type JSX, splitProps, createSignal } from "solid-js" -import { Dialog, DialogProps } from "./dialog" -import { Icon } from "./icon" -import { IconButton } from "./icon-button" -import { List, ListRef, ListProps } from "./list" -import { TextField } from "./text-field" - -interface SelectDialogProps - extends Omit, "filter">, - Pick { - title: string - placeholder?: string - actions?: JSX.Element -} - -export function SelectDialog(props: SelectDialogProps) { - const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"]) - let closeButton!: HTMLButtonElement - let inputRef: HTMLInputElement | undefined - const [filter, setFilter] = createSignal("") - let listRef: ListRef | undefined - - const handleSelect = (item: T | undefined, index: number) => { - others.onSelect?.(item, index) - closeButton.click() - } - - const handleKey = (e: KeyboardEvent) => { - if (e.key === "Escape") return - listRef?.onKeyDown(e) - } - - const handleOpenChange = (open: boolean) => { - if (!open) setFilter("") - props.onOpenChange?.(open) - } - - return ( - - - {others.title} - {others.actions} - - -
-
-
- - -
- - setFilter("")} /> - -
- - { - listRef = ref - }} - items={others.items} - key={others.key} - filterKeys={others.filterKeys} - current={others.current} - groupBy={others.groupBy} - sortBy={others.sortBy} - sortGroupsBy={others.sortGroupsBy} - emptyMessage={others.emptyMessage} - activeIcon={others.activeIcon} - filter={filter()} - onSelect={handleSelect} - onKeyEvent={others.onKeyEvent} - > - {others.children} - - -
-
- ) -} diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index bc61318e3..0f218b515 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -37,7 +37,6 @@ top: 0; background-color: var(--background-stronger); z-index: 21; - /* padding-bottom: clamp(0px, calc(8px - var(--scroll-y) * 0.16), 8px); */ } [data-slot="session-turn-response-trigger"] { @@ -297,7 +296,6 @@ [data-slot="session-turn-collapsible"] { gap: 32px; overflow: visible; - /* margin-top: clamp(8px, calc(24px - var(--scroll-y) * 0.32), 24px); */ } [data-slot="session-turn-collapsible-trigger-content"] { diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 196e0bdb6..ad2e6c36e 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -60,6 +60,8 @@ export function SessionTurn( function handleScroll() { if (!scrollRef) return + // prevents scroll loops + if (working() && scrollRef.scrollTop < 100) return setState("scrollY", scrollRef.scrollTop) if (state.autoScrolling) return const { scrollTop, scrollHeight, clientHeight } = scrollRef @@ -79,7 +81,7 @@ export function SessionTurn( if (!scrollRef || state.userScrolled || !working() || state.autoScrolling) return setState("autoScrolling", true) requestAnimationFrame(() => { - scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "auto" }) + scrollRef?.scrollTo({ top: scrollRef.scrollHeight, behavior: "instant" }) requestAnimationFrame(() => { setState("autoScrolling", false) }) diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css new file mode 100644 index 000000000..c01e45d5f --- /dev/null +++ b/packages/ui/src/components/switch.css @@ -0,0 +1,131 @@ +[data-component="switch"] { + 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"] { + display: inline-flex; + align-items: center; + width: 28px; + height: 16px; + flex-shrink: 0; + border-radius: 3px; + border: 1px solid var(--border-weak-base); + background: var(--surface-base); + transition: + background-color 150ms, + border-color 150ms; + } + + [data-slot="switch-thumb"] { + width: 14px; + height: 14px; + box-sizing: content-box; + + border-radius: 2px; + border: 1px solid var(--border-base); + background: var(--icon-invert-base); + + /* shadows/shadow-xs */ + box-shadow: + 0 1px 2px -1px rgba(19, 16, 16, 0.04), + 0 1px 2px 0 rgba(19, 16, 16, 0.06), + 0 1px 3px 0 rgba(19, 16, 16, 0.08); + + transform: translateX(-1px); + transition: + transform 150ms, + background-color 150ms; + } + + [data-slot="switch-label"] { + user-select: none; + color: var(--text-base); + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + } + + [data-slot="switch-description"] { + color: var(--text-base); + 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"] { + color: var(--text-error); + 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); + } + + &:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-focus); + box-shadow: 0 0 0 2px var(--surface-focus); + } + + &[data-checked] [data-slot="switch-control"] { + box-sizing: border-box; + border-color: var(--icon-strong-base); + background-color: var(--icon-strong-base); + } + + &[data-checked] [data-slot="switch-thumb"] { + border: none; + transform: translateX(12px); + background-color: var(--icon-invert-base); + } + + &[data-checked]:hover:not([data-disabled], [data-readonly]) [data-slot="switch-control"] { + border-color: var(--border-hover); + background-color: var(--surface-hover); + } + + &[data-disabled] { + cursor: not-allowed; + } + + &[data-disabled] [data-slot="switch-control"] { + border-color: var(--border-disabled); + background-color: var(--surface-disabled); + } + + &[data-disabled] [data-slot="switch-thumb"] { + background-color: var(--icon-disabled); + } + + &[data-invalid] [data-slot="switch-control"] { + border-color: var(--border-error); + } + + &[data-readonly] { + cursor: default; + pointer-events: none; + } +} diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx new file mode 100644 index 000000000..af70dfb5c --- /dev/null +++ b/packages/ui/src/components/switch.tsx @@ -0,0 +1,30 @@ +import { Switch as Kobalte } from "@kobalte/core/switch" +import { children, Show, splitProps } from "solid-js" +import type { ComponentProps, ParentProps } from "solid-js" + +export interface SwitchProps extends ParentProps> { + hideLabel?: boolean + description?: string +} + +export function Switch(props: SwitchProps) { + const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) + const resolved = children(() => local.children) + return ( + + + + + {resolved()} + + + + {local.description} + + + + + + + ) +} diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx index e3b373d4d..76a5ae84f 100644 --- a/packages/ui/src/hooks/use-filtered-list.tsx +++ b/packages/ui/src/hooks/use-filtered-list.tsx @@ -5,7 +5,7 @@ import { createStore } from "solid-js/store" import { createList } from "solid-list" export interface FilteredListProps { - items: (filter: string) => T[] | Promise + items: T[] | ((filter: string) => T[] | Promise) key: (item: T) => string filterKeys?: string[] current?: T @@ -19,10 +19,13 @@ export function useFilteredList(props: FilteredListProps) { const [store, setStore] = createStore<{ filter: string }>({ filter: "" }) const [grouped, { refetch }] = createResource( - () => store.filter, - async (filter) => { + () => ({ + filter: store.filter, + items: typeof props.items === "function" ? undefined : props.items, + }), + async ({ filter, items }) => { const needle = filter?.toLowerCase() - const all = (await props.items(needle)) || [] + const all = (items ?? (await (props.items as (filter: string) => T[] | Promise)(needle))) || [] const result = pipe( all, (x) => { diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index ba2c954bc..3f8838a7a 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -30,8 +30,8 @@ @import "../components/progress-circle.css" layer(components); @import "../components/resize-handle.css" layer(components); @import "../components/select.css" layer(components); -@import "../components/select-dialog.css" layer(components); @import "../components/spinner.css" layer(components); +@import "../components/switch.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components);