feat(app): added command palette (#2630)

Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
This commit is contained in:
Filip
2025-09-24 18:05:15 +02:00
committed by GitHub
parent 3f911b22b0
commit d3b6545e7c
11 changed files with 381 additions and 205 deletions

View File

@@ -279,7 +279,7 @@ export function Code(props: Props) {
}}
innerHTML={html()}
class="
font-mono text-xs tracking-wide overflow-y-auto no-scrollbar h-full
font-mono text-xs tracking-wide overflow-y-auto h-full
[&]:[counter-reset:line]
[&_pre]:focus-visible:outline-none
[&_pre]:overflow-x-auto [&_pre]:no-scrollbar

View File

@@ -0,0 +1,216 @@
import { createEffect, Show, For, createMemo, type JSX } from "solid-js"
import { Dialog } from "@kobalte/core/dialog"
import { Icon, IconButton } from "@/ui"
import { createStore } from "solid-js/store"
import { entries, flatMap, groupBy, map, mapValues, pipe } from "remeda"
import { createList } from "solid-list"
import fuzzysort from "fuzzysort"
interface SelectDialogProps<T> {
items: T[]
key: (item: T) => string
render: (item: T) => JSX.Element
current?: T
placeholder?: string
filter?:
| false
| {
keys: string[]
}
groupBy?: (x: T) => string
onSelect?: (value: T | undefined) => void
onClose?: () => void
}
export function SelectDialog<T>(props: SelectDialogProps<T>) {
let scrollRef: HTMLDivElement | undefined
const [store, setStore] = createStore({
filter: "",
mouseActive: false,
})
const grouped = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.items,
(x) =>
!needle || !props.filter
? x
: fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
mapValues((x) => x.sort((a, b) => props.key(a).localeCompare(props.key(b)))),
entries(),
map(([k, v]) => ({ category: k, items: v })),
)
return result
})
const flat = createMemo(() => {
return pipe(
grouped(),
flatMap((x) => x.items),
)
})
const list = createList({
items: () => flat().map(props.key),
initialActive: props.current ? props.key(props.current) : undefined,
loop: true,
})
const resetSelection = () => list.setActive(props.key(flat()[0]))
createEffect(() => {
store.filter
scrollRef?.scrollTo(0, 0)
resetSelection()
})
createEffect(() => {
if (store.mouseActive) return
if (list.active() === props.key(flat()[0])) {
scrollRef?.scrollTo(0, 0)
return
}
const element = scrollRef?.querySelector(`[data-key="${list.active()}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
const handleInput = (value: string) => {
setStore("filter", value)
resetSelection()
}
const handleSelect = (item: T) => {
props.onSelect?.(item)
props.onClose?.()
}
const handleKey = (e: KeyboardEvent) => {
setStore("mouseActive", false)
if (e.key === "Enter") {
e.preventDefault()
const selected = flat().find((x) => props.key(x) === list.active())
if (selected) handleSelect(selected)
} else if (e.key === "Escape") {
e.preventDefault()
props.onClose?.()
} else {
list.onKeyDown(e)
}
}
return (
<Dialog defaultOpen modal onOpenChange={(open) => open || props.onClose?.()}>
<Dialog.Portal>
<Dialog.Overlay class="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100]" />
<Dialog.Content
class="fixed top-[20%] left-1/2 -translate-x-1/2 w-[90vw] max-w-2xl
shadow-[0_0_33px_rgba(0,0,0,0.8)]
bg-background border border-border-subtle/30 rounded-lg z-[101]
max-h-[60vh] flex flex-col"
>
<div class="border-b border-border-subtle/30">
<div class="relative">
<Icon name="command" size={16} class="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted/80" />
<input
type="text"
value={store.filter}
onInput={(e) => handleInput(e.currentTarget.value)}
onKeyDown={handleKey}
placeholder={props.placeholder}
class="w-full pl-10 pr-4 py-2 rounded-t-md
text-sm text-text placeholder-text-muted/70
focus:outline-none"
autofocus
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
<div class="absolute right-3 top-1/2 -translate-y-1/2 flex items-center gap-2">
{/* <Show when={fileResults.loading && mode() === "files"}>
<div class="text-text-muted">
<Icon name="refresh" size={14} class="animate-spin" />
</div>
</Show> */}
<Show when={store.filter}>
<IconButton
size="xs"
variant="ghost"
class="text-text-muted hover:text-text"
onClick={() => {
setStore("filter", "")
resetSelection()
}}
>
<Icon name="close" size={14} />
</IconButton>
</Show>
</div>
</div>
</div>
<div ref={(el) => (scrollRef = el)} class="relative flex-1 overflow-y-auto">
<Show
when={flat().length > 0}
fallback={<div class="text-center py-8 text-text-muted text-sm">No results</div>}
>
<For each={grouped()}>
{(group) => (
<>
<div class="top-0 sticky z-10 bg-background-panel p-2 text-xs text-text-muted/60 tracking-wider uppercase">
{group.category}
</div>
<div class="p-2">
<For each={group.items}>
{(item) => (
<button
data-key={props.key(item)}
onClick={() => handleSelect(item)}
onMouseMove={() => {
setStore("mouseActive", true)
list.setActive(props.key(item))
}}
classList={{
"w-full px-3 py-2 flex items-center gap-3": true,
"rounded-md text-left transition-colors group": true,
"bg-background-element": props.key(item) === list.active(),
"hover:bg-background-element": true,
}}
>
{props.render(item)}
</button>
)}
</For>
</div>
</>
)}
</For>
</Show>
</div>
<div class="p-3 border-t border-border-subtle/30 flex items-center justify-between text-xs text-text-muted">
<div class="flex items-center gap-5">
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
</kbd>
Navigate
</span>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
</kbd>
Select
</span>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 bg-background-element border border-border-subtle/30 rounded text-[10px]">
ESC
</kbd>
Close
</span>
</div>
<span>{`${flat().length} results`}</span>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
)
}

View File

@@ -1,46 +1,26 @@
import { Select as KobalteSelect } from "@kobalte/core/select"
import { createEffect, createMemo, Show } from "solid-js"
import { createMemo } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Icon } from "@/ui/icon"
import fuzzysort from "fuzzysort"
import { pipe, groupBy, entries, map } from "remeda"
import { createStore } from "solid-js/store"
import { Button, type ButtonProps } from "@/ui"
export interface SelectProps<T> {
variant?: "default" | "outline"
size?: "sm" | "md" | "lg"
placeholder?: string
filter?:
| false
| {
placeholder?: string
keys: string[]
}
options: T[]
current?: T
value?: (x: T) => string
label?: (x: T) => string
groupBy?: (x: T) => string
onFilter?: (query: string) => void
onSelect?: (value: T | undefined) => void
class?: ComponentProps<"div">["class"]
classList?: ComponentProps<"div">["classList"]
}
export function Select<T>(props: SelectProps<T>) {
let inputRef: HTMLInputElement | undefined = undefined
let listboxRef: HTMLUListElement | undefined = undefined
const [store, setStore] = createStore({
filter: "",
})
export function Select<T>(props: SelectProps<T> & ButtonProps) {
const grouped = createMemo(() => {
const needle = store.filter.toLowerCase()
const result = pipe(
props.options,
(x) =>
!needle || !props.filter
? x
: fuzzysort.go(needle, x, { keys: props.filter && props.filter.keys }).map((x) => x.obj),
groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
// mapValues((x) => x.sort((a, b) => a.title.localeCompare(b.title))),
entries(),
@@ -48,19 +28,6 @@ export function Select<T>(props: SelectProps<T>) {
)
return result
})
// const flat = createMemo(() => {
// return pipe(
// grouped(),
// flatMap(({ options }) => options),
// )
// })
createEffect(() => {
store.filter
listboxRef?.scrollTo(0, 0)
// setStore("selected", 0)
// scroll.scrollTo(0)
})
return (
<KobalteSelect<T, { category: string; options: T[] }>
@@ -89,36 +56,21 @@ export function Select<T>(props: SelectProps<T>) {
<KobalteSelect.ItemLabel>
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</KobalteSelect.ItemLabel>
<KobalteSelect.ItemIndicator
classList={{
"ml-auto": true,
}}
>
<KobalteSelect.ItemIndicator class="ml-auto">
<Icon name="checkmark" size={16} />
</KobalteSelect.ItemIndicator>
</KobalteSelect.Item>
)}
onChange={(v) => {
if (props.onSelect) props.onSelect(v ?? undefined)
if (v !== null) {
// close the select
}
props.onSelect?.(v ?? undefined)
}}
onOpenChange={(v) => v || setStore("filter", "")}
>
<KobalteSelect.Trigger
as={Button}
size={props.size || "sm"}
variant={props.variant || "secondary"}
classList={{
...(props.classList ?? {}),
"flex w-full items-center justify-between rounded-md transition-colors": true,
"focus-visible:outline-none focus-visible:ring focus-visible:ring-border-active/30": true,
"disabled:cursor-not-allowed disabled:opacity-50": true,
"data-[placeholder-shown]:text-text-muted cursor-pointer": true,
"hover:bg-background-element focus-visible:ring-border-active": true,
"bg-background-element text-text": props.variant === "default" || !props.variant,
"border-2 border-border bg-transparent text-text": props.variant === "outline",
"h-6 pl-2 text-xs": props.size === "sm",
"h-8 pl-3 text-sm": props.size === "md" || !props.size,
"h-10 pl-4 text-base": props.size === "lg",
[props.class ?? ""]: !!props.class,
}}
>
@@ -140,13 +92,6 @@ export function Select<T>(props: SelectProps<T>) {
</KobalteSelect.Trigger>
<KobalteSelect.Portal>
<KobalteSelect.Content
onKeyDown={(e) => {
if (!props.filter) return
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
return
}
inputRef?.focus()
}}
classList={{
"min-w-32 overflow-hidden rounded-md border border-border-subtle/40": true,
"bg-background-panel p-1 shadow-md z-50": true,
@@ -154,33 +99,7 @@ export function Select<T>(props: SelectProps<T>) {
"data-[expanded]:animate-in data-[expanded]:fade-in-0 data-[expanded]:zoom-in-95": true,
}}
>
<Show when={props.filter}>
<input
ref={(el) => (inputRef = el)}
id="select-filter"
type="text"
placeholder={props.filter ? props.filter.placeholder : "Filter items"}
value={store.filter}
onInput={(e) => setStore("filter", e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Escape") {
e.preventDefault()
e.stopPropagation()
listboxRef?.focus()
}
}}
classList={{
"w-full": true,
"px-2 pb-2 text-text font-light placeholder-text-muted/70 text-xs focus:outline-none": true,
}}
/>
</Show>
<KobalteSelect.Listbox
ref={(el) => (listboxRef = el)}
classList={{
"overflow-y-auto max-h-48 no-scrollbar": true,
}}
/>
<KobalteSelect.Listbox class="overflow-y-auto max-h-48" />
</KobalteSelect.Content>
</KobalteSelect.Portal>
</KobalteSelect>

View File

@@ -7,7 +7,7 @@ export default function SessionList() {
const local = useLocal()
return (
<VList data={sync.data.session} class="p-2 no-scrollbar">
<VList data={sync.data.session} class="p-2">
{(session) => (
<Tooltip placement="right" value={session.title} class="w-full min-w-0">
<Button

View File

@@ -1,7 +1,7 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createContext, createEffect, createMemo, useContext, type ParentProps } from "solid-js"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode } from "@opencode-ai/sdk"
import type { FileContent, FileNode, Model, Provider } from "@opencode-ai/sdk"
import { useSDK, useEvent, useSync } from "@/context"
export type LocalFile = FileNode &
@@ -19,12 +19,17 @@ export type LocalFile = FileNode &
export type TextSelection = LocalFile["selection"]
export type View = LocalFile["view"]
export type LocalModel = Omit<Model, "provider"> & {
provider: Provider
}
export type ModelKey = { providerID: string; modelID: string }
function init() {
const sdk = useSDK()
const sync = useSync()
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent"))
const [store, setStore] = createStore<{
current: string
}>({
@@ -54,18 +59,14 @@ function init() {
})()
const model = (() => {
const list = createMemo(() =>
sync.data.provider.flatMap((p) => Object.values(p.models).map((m) => ({ ...m, provider: p }) as LocalModel)),
)
const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID)
const [store, setStore] = createStore<{
model: Record<
string,
{
providerID: string
modelID: string
}
>
recent: {
providerID: string
modelID: string
}[]
model: Record<string, ModelKey>
recent: ModelKey[]
}>({
model: {},
recent: [],
@@ -81,37 +82,21 @@ function init() {
if (store.recent.length) return store.recent[0]
const provider = sync.data.provider[0]
const model = Object.values(provider.models)[0]
return {
providerID: provider.id,
modelID: model.id,
}
return { modelID: model.id, providerID: provider.id }
})
const current = createMemo(() => {
const a = agent.current()
return store.model[agent.current().name] ?? (a.model ? a.model : fallback())
return find(store.model[agent.current().name]) ?? find(a.model ?? fallback())
})
const list = createMemo(() =>
sync.data.provider.flatMap((x) => Object.values(x.models).map((m) => ({ providerID: x.id, modelID: m.id }))),
)
const recent = createMemo(() => store.recent.map(find).filter(Boolean))
return {
list,
current,
recent() {
return store.recent
},
parsed: createMemo(() => {
const value = current()
const provider = sync.data.provider.find((x) => x.id === value.providerID)!
const model = provider.models[value.modelID]
return {
provider: provider.name ?? value.providerID,
model: model.name ?? value.modelID,
}
}),
set(model: { providerID: string; modelID: string } | undefined, options?: { recent?: boolean }) {
recent,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setStore("model", agent.current().name, model ?? fallback())
if (options?.recent && model) {
@@ -234,6 +219,7 @@ function init() {
break
case "file.watcher.updated":
load(event.properties.file)
sync.load.changes()
break
}
})

View File

@@ -94,20 +94,24 @@ function init() {
})
const sdk = useSDK()
Promise.all([
sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
sdk.path.get().then((x) => setStore("path", x.data!)),
sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
sdk.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
const load = {
provider: () => sdk.config.providers().then((x) => setStore("provider", x.data!.providers)),
path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
session: () =>
sdk.session.list().then((x) =>
setStore(
"session",
(x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)),
),
),
),
sdk.config.get().then((x) => setStore("config", x.data!)),
sdk.file.status().then((x) => setStore("changes", x.data!)),
sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
]).then(() => setStore("ready", true))
config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
}
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
return {
data: store,
@@ -138,6 +142,7 @@ function init() {
)
},
},
load,
}
}

View File

@@ -19,6 +19,19 @@
/* color: var(--color-background); */
}
::-webkit-scrollbar-track {
background: var(--theme-background-panel);
}
::-webkit-scrollbar-thumb {
background-color: var(--theme-border-subtle);
border-radius: 6px;
}
* {
scrollbar-color: var(--theme-border-subtle) var(--theme-background-panel);
}
.prose h1 {
color: var(--color-text);
font-size: var(--text-sm);

View File

@@ -1,8 +1,9 @@
import { FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Button, FileIcon, Icon, IconButton, Logo, Tooltip } from "@/ui"
import { Tabs } from "@/ui/tabs"
import { Select } from "@/components/select"
import FileTree from "@/components/file-tree"
import { For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
import { SelectDialog } from "@/components/select-dialog"
import { useLocal, useSDK } from "@/context"
import { Code } from "@/components/code"
import {
@@ -28,6 +29,7 @@ export default function Page() {
activeItem: undefined as string | undefined,
prompt: "",
dragging: undefined as "left" | "right" | undefined,
modelSelectOpen: false,
})
let inputRef: HTMLInputElement | undefined = undefined
@@ -43,6 +45,17 @@ export default function Page() {
})
const handleKeyDown = (e: KeyboardEvent) => {
if (e.getModifierState(MOD) && e.shiftKey && e.key.toLowerCase() === "p") {
e.preventDefault()
setStore("modelSelectOpen", true)
return
}
if (e.getModifierState(MOD) && e.key.toLowerCase() === "p") {
e.preventDefault()
setStore("modelSelectOpen", true)
return
}
const inputFocused = document.activeElement === inputRef
if (inputFocused) {
if (e.key === "Escape") {
@@ -190,7 +203,7 @@ export default function Page() {
path: { id: session!.id },
body: {
agent: local.agent.current()!.name,
model: local.model.current(),
model: { modelID: local.model.current()!.id, providerID: local.model.current()!.provider.id },
parts: [
{
type: "text",
@@ -265,7 +278,7 @@ export default function Page() {
class="fixed top-0 right-0 h-full border-l border-border-subtle/30 flex flex-col overflow-hidden"
style={`width: ${local.layout.rightWidth()}px`}
>
<div class="relative flex-1 min-h-0 overflow-y-auto no-scrollbar">
<div class="relative flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
<Show when={local.session.active()} fallback={<SessionList />}>
{(activeSession) => (
<div class="relative">
@@ -470,7 +483,7 @@ export default function Page() {
type="text"
value={store.prompt}
onInput={(e) => setStore("prompt", e.currentTarget.value)}
placeholder="It all starts with a prompt..."
placeholder="Placeholder text..."
class="w-full p-1 pb-4 text-text font-light placeholder-text-muted/70 text-sm focus:outline-none"
/>
<div class="flex justify-between items-center text-xs text-text-muted">
@@ -479,24 +492,13 @@ export default function Page() {
options={local.agent.list().map((a) => a.name)}
current={local.agent.current().name}
onSelect={local.agent.set}
size="sm"
class="uppercase"
/>
<Select
options={local.model.list()}
current={local.model.current()}
onSelect={local.model.set}
label={(x) => x.modelID}
value={(x) => `${x.providerID}.${x.modelID}`}
filter={{
keys: ["providerID", "modelID"],
placeholder: "Filter models",
}}
groupBy={(x) => x.providerID}
size="sm"
class="uppercase"
/>
<span class="text-text-muted/70">{local.model.parsed().provider}</span>
<Button onClick={() => setStore("modelSelectOpen", true)}>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size={24} class="text-text-muted" />
</Button>
<span class="text-text-muted/70 whitespace-nowrap">{local.model.current()?.provider.name}</span>
</div>
<div class="flex gap-1 items-center">
<IconButton class="text-text-muted" size="xs" variant="ghost">
@@ -510,6 +512,56 @@ export default function Page() {
</div>
</form>
</div>
<Show when={store.modelSelectOpen}>
<SelectDialog
key={(x) => `${x.provider.id}:${x.id}`}
items={local.model.list()}
current={local.model.current()}
render={(i) => (
<div class="w-full flex items-center justify-between">
<div class="flex items-center gap-x-2 text-text-muted grow min-w-0">
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-4 invert opacity-40" />
<span class="text-xs text-text whitespace-nowrap">{i.name}</span>
<span class="text-xs text-text-muted/80 whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{i.id}
</span>
</div>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0">
<Tooltip forceMount={false} value="Reasoning">
<Icon name="brain" size={16} classList={{ "text-accent": i.reasoning }} />
</Tooltip>
<Tooltip forceMount={false} value="Tools">
<Icon name="hammer" size={16} classList={{ "text-secondary": i.tool_call }} />
</Tooltip>
<Tooltip forceMount={false} value="Attachments">
<Icon name="photo" size={16} classList={{ "text-success": i.attachment }} />
</Tooltip>
<div class="rounded-full bg-text-muted/20 text-text-muted/80 w-9 h-4 flex items-center justify-center text-[10px]">
{new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(i.limit.context)}
</div>
<Tooltip forceMount={false} value={`$${i.cost?.input}/1M input, $${i.cost?.output}/1M output`}>
<div class="rounded-full bg-success/20 text-success/80 w-9 h-4 flex items-center justify-center text-[10px]">
<Switch fallback="FREE">
<Match when={i.cost?.input > 10}>$$$</Match>
<Match when={i.cost?.input > 1}>$$</Match>
<Match when={i.cost?.input > 0.1}>$</Match>
</Switch>
</div>
</Tooltip>
</div>
</div>
)}
filter={{
keys: ["provider.name", "name", "id"],
}}
groupBy={(x) => x.provider.name}
onClose={() => setStore("modelSelectOpen", false)}
onSelect={(x) => local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined)}
/>
</Show>
</div>
)
}

View File

@@ -1,49 +1,36 @@
import { Button as KobalteButton } from "@kobalte/core/button"
import { splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
export interface ButtonProps extends ComponentProps<typeof KobalteButton> {
variant?: "primary" | "secondary" | "outline" | "ghost"
export interface ButtonProps {
variant?: "primary" | "secondary" | "ghost"
size?: "sm" | "md" | "lg"
}
export const buttonStyles = {
base: "inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 cursor-pointer",
variants: {
primary: "bg-primary text-background hover:bg-secondary focus-visible:ring-primary data-[disabled]:opacity-50",
secondary:
"bg-background-panel text-text hover:bg-background-element focus-visible:ring-secondary data-[disabled]:opacity-50",
outline:
"border border-border bg-transparent text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:border-border-subtle data-[disabled]:text-text-muted",
ghost: "text-text hover:bg-background-panel focus-visible:ring-border-active data-[disabled]:text-text-muted",
},
sizes: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-6 text-base",
},
}
export function getButtonClasses(
variant: keyof typeof buttonStyles.variants = "primary",
size: keyof typeof buttonStyles.sizes = "md",
className?: string,
) {
return `${buttonStyles.base} ${buttonStyles.variants[variant]} ${buttonStyles.sizes[size]}${className ? ` ${className}` : ""}`
}
export function Button(props: ButtonProps) {
const [local, others] = splitProps(props, ["variant", "size", "class", "classList"])
export function Button(props: ComponentProps<"button"> & ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
return (
<KobalteButton
<Kobalte
{...rest}
data-size={split.size || "sm"}
data-variant={split.variant || "secondary"}
class="inline-flex items-center justify-center rounded-md cursor-pointer font-medium transition-colors
min-w-0 whitespace-nowrap truncate
data-[size=sm]:h-6 data-[size=sm]:pl-2 data-[size=sm]:text-xs
data-[size=md]:h-8 data-[size=md]:pl-3 data-[size=md]:text-sm
data-[size=lg]:h-10 data-[size=lg]:pl-4 data-[size=lg]:text-base
data-[variant=primary]:bg-primary data-[variant=primary]:text-background
data-[variant=primary]:hover:bg-secondary data-[variant=primary]:focus-visible:ring-primary
data-[variant=secondary]:bg-background-element data-[variant=secondary]:text-text
data-[variant=secondary]:hover:bg-background-element data-[variant=secondary]:focus-visible:ring-secondary
data-[variant=ghost]:text-text data-[variant=ghost]:hover:bg-background-panel data-[variant=ghost]:focus-visible:ring-border-active
focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-transparent
disabled:pointer-events-none disabled:opacity-50"
classList={{
...(local.classList ?? {}),
[buttonStyles.base]: true,
[buttonStyles.variants[local.variant || "primary"]]: true,
[buttonStyles.sizes[local.size || "md"]]: true,
[local.class ?? ""]: !!local.class,
...(split.classList ?? {}),
[split.class ?? ""]: !!split.class,
}}
{...others}
/>
>
{props.children}
</Kobalte>
)
}

View File

@@ -1,15 +1,13 @@
import { A } from "@solidjs/router"
import { splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
import { getButtonClasses } from "./button"
export interface LinkProps extends ComponentProps<typeof A> {
variant?: "primary" | "secondary" | "outline" | "ghost"
variant?: "primary" | "secondary" | "ghost"
size?: "sm" | "md" | "lg"
}
export function Link(props: LinkProps) {
const [local, others] = splitProps(props, ["variant", "size", "class"])
const classes = local.variant ? getButtonClasses(local.variant, local.size, local.class) : local.class
return <A class={classes} {...others} />
const [, others] = splitProps(props, ["variant", "size", "class"])
return <A {...others} />
}

View File

@@ -34,7 +34,7 @@ export function Tooltip(props: TooltipProps) {
<KobalteTooltip.Portal>
<KobalteTooltip.Content
classList={{
"z-50 max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
"z-[1000] max-w-[320px] rounded-md bg-background-element px-2 py-1": true,
"text-xs font-medium text-text shadow-md pointer-events-none!": true,
"transition-all duration-150 ease-out": true,
"transform-gpu transform-origin-[var(--kb-tooltip-content-transform-origin)]": true,