summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-10-22 17:31:44 -0500
committerAdam <[email protected]>2025-10-22 17:31:49 -0500
commit89b703c387aed3ee918d826b788b4be1729bdde9 (patch)
tree9a22d26bfc5e921789924e2344d04113660b67a3 /packages/ui/src
parenteff12cb48453e45590a53a7705c5044a0da9e7f7 (diff)
downloadopencode-89b703c387aed3ee918d826b788b4be1729bdde9.tar.gz
opencode-89b703c387aed3ee918d826b788b4be1729bdde9.zip
wip: desktop work
Diffstat (limited to 'packages/ui/src')
-rw-r--r--packages/ui/src/components/button.css13
-rw-r--r--packages/ui/src/components/button.tsx6
-rw-r--r--packages/ui/src/components/dialog.css129
-rw-r--r--packages/ui/src/components/dialog.tsx91
-rw-r--r--packages/ui/src/components/icon-button.css117
-rw-r--r--packages/ui/src/components/icon-button.tsx27
-rw-r--r--packages/ui/src/components/icon.css23
-rw-r--r--packages/ui/src/components/icon.tsx61
-rw-r--r--packages/ui/src/components/index.ts4
-rw-r--r--packages/ui/src/components/input.css23
-rw-r--r--packages/ui/src/components/input.tsx27
-rw-r--r--packages/ui/src/components/list.css4
-rw-r--r--packages/ui/src/components/list.tsx7
-rw-r--r--packages/ui/src/components/select-dialog.css109
-rw-r--r--packages/ui/src/components/select-dialog.tsx156
-rw-r--r--packages/ui/src/components/select.css62
-rw-r--r--packages/ui/src/components/select.tsx4
-rw-r--r--packages/ui/src/components/tabs.css8
-rw-r--r--packages/ui/src/components/tooltip.tsx2
-rw-r--r--packages/ui/src/hooks/index.ts1
-rw-r--r--packages/ui/src/hooks/use-filtered-list.tsx89
-rw-r--r--packages/ui/src/styles/index.css4
-rw-r--r--packages/ui/src/styles/utilities.css22
23 files changed, 911 insertions, 78 deletions
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index c9ccf4ecb..7bf096853 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -1,5 +1,4 @@
[data-component="button"] {
- cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
@@ -32,12 +31,7 @@
border-color: var(--border-weak-base);
background-color: var(--button-secondary-base);
color: var(--text-strong);
-
- /* 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);
+ box-shadow: var(--shadow-xs);
&:hover:not(:disabled) {
border-color: var(--border-hover);
@@ -84,12 +78,11 @@
padding: 0 8px 0 6px;
gap: 8px;
- /* text-12-medium */
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-style: normal;
font-weight: var(--font-weight-medium);
- line-height: var(--line-height-large); /* 166.667% */
+ line-height: var(--line-height-large); /* 171.429% */
letter-spacing: var(--letter-spacing-normal);
}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index db1da2fb9..cae658439 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -1,12 +1,14 @@
import { Button as Kobalte } from "@kobalte/core/button"
import { type ComponentProps, splitProps } from "solid-js"
-export interface ButtonProps {
+export interface ButtonProps
+ extends ComponentProps<typeof Kobalte>,
+ Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
size?: "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
}
-export function Button(props: ComponentProps<"button"> & ButtonProps) {
+export function Button(props: ButtonProps) {
const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
return (
<Kobalte
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css
new file mode 100644
index 000000000..750596308
--- /dev/null
+++ b/packages/ui/src/components/dialog.css
@@ -0,0 +1,129 @@
+/* [data-component="dialog-trigger"] { } */
+
+[data-component="dialog-overlay"] {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ background-color: transparent;
+
+ /* animation: overlayHide 250ms ease 100ms forwards; */
+ /**/
+ /* &[data-expanded] { */
+ /* animation: overlayShow 250ms ease; */
+ /* } */
+}
+
+[data-component="dialog"] {
+ position: fixed;
+ inset: 0;
+ z-index: 50;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ [data-slot="container"] {
+ position: relative;
+ z-index: 50;
+ width: min(calc(100vw - 16px), 624px);
+ height: min(calc(100vh - 16px), 512px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-items: start;
+
+ [data-slot="content"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ align-self: stretch;
+ gap: 8px;
+ width: 100%;
+ max-height: 100%;
+
+ /* padding: 8px; */
+ padding: 8px 8px 0 8px;
+ border: 1px solid var(--border-base);
+ border-radius: 16px;
+ background: var(--surface-raised-stronger-non-alpha);
+ box-shadow:
+ 0 15px 45px 0 rgba(19, 16, 16, 0.22),
+ 0 3.35px 10.051px 0 rgba(19, 16, 16, 0.13),
+ 0 0.998px 2.993px 0 rgba(19, 16, 16, 0.09);
+
+ /* animation: contentHide 300ms ease-in forwards; */
+ /**/
+ /* &[data-expanded] { */
+ /* animation: contentShow 300ms ease-out; */
+ /* } */
+
+ [data-slot="header"] {
+ display: flex;
+ height: 40px;
+ padding: 4px 4px 4px 8px;
+ justify-content: space-between;
+ align-items: center;
+ flex-shrink: 0;
+ align-self: stretch;
+
+ [data-slot="title"] {
+ color: var(--text-strong);
+
+ /* text-16-medium */
+ font-family: var(--font-family-sans);
+ font-size: var(--font-size-large);
+ font-style: normal;
+ font-weight: var(--font-weight-medium);
+ line-height: var(--line-height-x-large); /* 150% */
+ letter-spacing: var(--letter-spacing-tight);
+ }
+ /* [data-slot="close-button"] {} */
+ }
+ /* [data-slot="description"] {} */
+ [data-slot="body"] {
+ width: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow-y: auto;
+ }
+ }
+ }
+}
+
+@keyframes overlayShow {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@keyframes overlayHide {
+ from {
+ opacity: 1;
+ }
+ to {
+ opacity: 0;
+ }
+}
+@keyframes contentShow {
+ from {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+@keyframes contentHide {
+ from {
+ opacity: 1;
+ transform: scale(1);
+ }
+ to {
+ opacity: 0;
+ transform: scale(0.96);
+ }
+}
diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx
new file mode 100644
index 000000000..ce7a4b3ac
--- /dev/null
+++ b/packages/ui/src/components/dialog.tsx
@@ -0,0 +1,91 @@
+import {
+ Dialog as Kobalte,
+ DialogRootProps,
+ DialogTitleProps,
+ DialogCloseButtonProps,
+ DialogDescriptionProps,
+} from "@kobalte/core/dialog"
+import { ComponentProps, type JSX, onCleanup, Show, splitProps } from "solid-js"
+import { IconButton } from "./icon-button"
+
+export interface DialogProps extends DialogRootProps {
+ trigger?: JSX.Element
+ class?: ComponentProps<"div">["class"]
+ classList?: ComponentProps<"div">["classList"]
+}
+
+export function DialogRoot(props: DialogProps) {
+ let trigger!: HTMLElement
+ const [local, others] = splitProps(props, ["trigger", "class", "classList", "children"])
+
+ const resetTabIndex = () => {
+ trigger.tabIndex = 0
+ }
+
+ const handleTriggerFocus = (e: FocusEvent & { currentTarget: HTMLElement | null }) => {
+ const firstChild = e.currentTarget?.firstElementChild as HTMLElement
+ if (!firstChild) return
+
+ firstChild.focus()
+ trigger.tabIndex = -1
+
+ firstChild.addEventListener("focusout", resetTabIndex)
+ onCleanup(() => {
+ firstChild.removeEventListener("focusout", resetTabIndex)
+ })
+ }
+
+ return (
+ <Kobalte {...others}>
+ <Show when={props.trigger}>
+ <Kobalte.Trigger ref={trigger} data-component="dialog-trigger" onFocusIn={handleTriggerFocus}>
+ {props.trigger}
+ </Kobalte.Trigger>
+ </Show>
+ <Kobalte.Portal>
+ <Kobalte.Overlay data-component="dialog-overlay" />
+ <div data-component="dialog">
+ <div data-slot="container">
+ <Kobalte.Content
+ data-slot="content"
+ classList={{
+ ...(local.classList ?? {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ >
+ {local.children}
+ </Kobalte.Content>
+ </div>
+ </div>
+ </Kobalte.Portal>
+ </Kobalte>
+ )
+}
+
+function DialogHeader(props: ComponentProps<"div">) {
+ return <div data-slot="header" {...props} />
+}
+
+function DialogBody(props: ComponentProps<"div">) {
+ return <div data-slot="body" {...props} />
+}
+
+function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) {
+ return <Kobalte.Title data-slot="title" {...props} />
+}
+
+function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) {
+ return <Kobalte.Description data-slot="description" {...props} />
+}
+
+function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) {
+ return <Kobalte.CloseButton data-slot="close-button" as={IconButton} icon="close" {...props} />
+}
+
+export const Dialog = Object.assign(DialogRoot, {
+ Header: DialogHeader,
+ Title: DialogTitle,
+ Description: DialogDescription,
+ CloseButton: DialogCloseButton,
+ Body: DialogBody,
+})
diff --git a/packages/ui/src/components/icon-button.css b/packages/ui/src/components/icon-button.css
new file mode 100644
index 000000000..6fe95fccf
--- /dev/null
+++ b/packages/ui/src/components/icon-button.css
@@ -0,0 +1,117 @@
+[data-component="icon-button"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100%;
+ text-decoration: none;
+ user-select: none;
+ aspect-ratio: 1;
+
+ &:disabled {
+ background-color: var(--icon-strong-disabled);
+ color: var(--icon-invert-base);
+ cursor: not-allowed;
+ }
+
+ &:focus {
+ outline: none;
+ }
+
+ &[data-variant="primary"] {
+ background-color: var(--icon-strong-base);
+
+ [data-slot="icon"] {
+ /* color: var(--icon-weak-base); */
+ color: var(--icon-invert-base);
+
+ /* &:hover:not(:disabled) { */
+ /* color: var(--icon-weak-hover); */
+ /* } */
+ /* &:active:not(:disabled) { */
+ /* color: var(--icon-string-active); */
+ /* } */
+ }
+
+ &:hover:not(:disabled) {
+ background-color: var(--icon-strong-hover);
+ }
+ &:active:not(:disabled) {
+ background-color: var(--icon-string-active);
+ }
+ &:focus:not(:disabled) {
+ background-color: var(--icon-strong-focus);
+ }
+ &:disabled {
+ background-color: var(--icon-strong-disabled);
+
+ [data-slot="icon"] {
+ color: var(--icon-invert-base);
+ }
+ }
+ }
+
+ &[data-variant="secondary"] {
+ background-color: var(--button-secondary-base);
+ color: var(--text-strong);
+
+ &:hover:not(:disabled) {
+ background-color: var(--surface-hover);
+ }
+ &:active:not(:disabled) {
+ background-color: var(--surface-active);
+ }
+ &:focus:not(:disabled) {
+ background-color: var(--surface-focus);
+ }
+ }
+
+ &[data-variant="ghost"] {
+ background-color: transparent;
+
+ [data-slot="icon"] {
+ color: var(--icon-weak-base);
+
+ &:hover:not(:disabled) {
+ color: var(--icon-weak-hover);
+ }
+ &:active:not(:disabled) {
+ color: var(--icon-string-active);
+ }
+ }
+
+ /* color: var(--text-strong); */
+ /**/
+ /* &:hover:not(:disabled) { */
+ /* background-color: var(--surface-hover); */
+ /* } */
+ /* &:active:not(:disabled) { */
+ /* background-color: var(--surface-active); */
+ /* } */
+ /* &:focus:not(:disabled) { */
+ /* background-color: var(--surface-focus); */
+ /* } */
+ }
+
+ &[data-size="normal"] {
+ width: 24px;
+ height: 24px;
+
+ font-size: var(--font-size-small);
+ line-height: var(--line-height-large);
+ gap: calc(var(--spacing) * 0.5);
+ }
+
+ &[data-size="large"] {
+ height: 32px;
+ padding: 0 8px 0 6px;
+ gap: 8px;
+
+ /* 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: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+}
diff --git a/packages/ui/src/components/icon-button.tsx b/packages/ui/src/components/icon-button.tsx
new file mode 100644
index 000000000..f483f92a7
--- /dev/null
+++ b/packages/ui/src/components/icon-button.tsx
@@ -0,0 +1,27 @@
+import { Button as Kobalte } from "@kobalte/core/button"
+import { type ComponentProps, splitProps } from "solid-js"
+import { Icon, IconProps } from "./icon"
+
+export interface IconButtonProps {
+ icon: IconProps["name"]
+ size?: "normal" | "large"
+ variant?: "primary" | "secondary" | "ghost"
+}
+
+export function IconButton(props: ComponentProps<"button"> & IconButtonProps) {
+ const [split, rest] = splitProps(props, ["variant", "size", "class", "classList"])
+ return (
+ <Kobalte
+ {...rest}
+ data-component="icon-button"
+ data-size={split.size || "normal"}
+ data-variant={split.variant || "secondary"}
+ classList={{
+ ...(split.classList ?? {}),
+ [split.class ?? ""]: !!split.class,
+ }}
+ >
+ <Icon data-slot="icon" name={props.icon} size={split.size === "large" ? "normal" : "small"} />
+ </Kobalte>
+ )
+}
diff --git a/packages/ui/src/components/icon.css b/packages/ui/src/components/icon.css
index abc193220..59c644b70 100644
--- a/packages/ui/src/components/icon.css
+++ b/packages/ui/src/components/icon.css
@@ -3,4 +3,27 @@
align-items: center;
justify-content: center;
flex-shrink: 0;
+ /* resize: both; */
+ aspect-ratio: 1/1;
+ color: var(--icon-base);
+
+ &[data-size="small"] {
+ width: 16px;
+ height: 16px;
+ }
+
+ &[data-size="normal"] {
+ width: 20px;
+ height: 20px;
+ }
+
+ &[data-size="large"] {
+ width: 32px;
+ height: 32px;
+ }
+
+ [data-slot="svg"] {
+ width: 100%;
+ height: auto;
+ }
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 05dda6ea6..8d63bf0f8 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -128,28 +128,55 @@ const icons = {
mic: '<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.75 8C8.75 6.20507 10.2051 4.75 12 4.75C13.7949 4.75 15.25 6.20507 15.25 8V11C15.25 12.7949 13.7949 14.25 12 14.25C10.2051 14.25 8.75 12.7949 8.75 11V8Z"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5.75 12.75C5.75 12.75 6 17.25 12 17.25C18 17.25 18.25 12.75 18.25 12.75"></path><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 17.75V19.25"></path>',
} as const
+const newIcons = {
+ "circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
+ "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"/>`,
+ "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"/>`,
+}
+
export interface IconProps extends ComponentProps<"svg"> {
- name: keyof typeof icons
- size?: number
+ name: keyof typeof icons | keyof typeof newIcons
+ size?: "small" | "normal" | "large"
}
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
- const size = local.size ?? 24
+
+ if (local.name in newIcons) {
+ return (
+ <div data-component="icon" data-size={local.size || "normal"}>
+ <svg
+ data-slot="svg"
+ classList={{
+ ...(local.classList || {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ fill="none"
+ viewBox="0 0 20 20"
+ innerHTML={newIcons[local.name as keyof typeof newIcons]}
+ aria-hidden="true"
+ {...others}
+ />
+ </div>
+ )
+ }
+
return (
- <svg
- data-component="icon"
- classList={{
- ...(local.classList || {}),
- [local.class ?? ""]: !!local.class,
- }}
- width={size}
- height={size}
- fill="none"
- viewBox="0 0 24 24"
- innerHTML={icons[local.name]}
- aria-hidden="true"
- {...others}
- />
+ <div data-component="icon" data-size={local.size || "normal"}>
+ <svg
+ data-slot="svg"
+ classList={{
+ ...(local.classList || {}),
+ [local.class ?? ""]: !!local.class,
+ }}
+ fill="none"
+ viewBox="0 0 24 24"
+ innerHTML={icons[local.name as keyof typeof icons]}
+ aria-hidden="true"
+ {...others}
+ />
+ </div>
)
}
diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts
index d6ddc3ec0..71cfd3a89 100644
--- a/packages/ui/src/components/index.ts
+++ b/packages/ui/src/components/index.ts
@@ -1,7 +1,11 @@
export * from "./button"
+export * from "./dialog"
export * from "./icon"
+export * from "./icon-button"
+export * from "./input"
export * from "./fonts"
export * from "./list"
export * from "./select"
+export * from "./select-dialog"
export * from "./tabs"
export * from "./tooltip"
diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/input.css
new file mode 100644
index 000000000..24cec19c5
--- /dev/null
+++ b/packages/ui/src/components/input.css
@@ -0,0 +1,23 @@
+[data-component="input"] {
+ /* [data-slot="label"] {} */
+
+ [data-slot="input"] {
+ color: var(--text-strong);
+
+ /* 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;
+ }
+
+ &::placeholder {
+ color: var(--text-weak);
+ }
+ }
+}
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx
new file mode 100644
index 000000000..509e242c9
--- /dev/null
+++ b/packages/ui/src/components/input.tsx
@@ -0,0 +1,27 @@
+import { TextField as Kobalte } from "@kobalte/core/text-field"
+import { Show, splitProps } from "solid-js"
+import type { ComponentProps } from "solid-js"
+
+export interface InputProps extends ComponentProps<typeof Kobalte> {
+ label?: string
+ hideLabel?: boolean
+ description?: string
+}
+
+export function Input(props: InputProps) {
+ const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
+ return (
+ <Kobalte {...others} data-component="input">
+ <Show when={local.label}>
+ <Kobalte.Label data-slot="label" classList={{ "sr-only": local.hideLabel }}>
+ {local.label}
+ </Kobalte.Label>
+ </Show>
+ <Kobalte.Input data-slot="input" class={local.class} placeholder={local.placeholder} />
+ <Show when={local.description}>
+ <Kobalte.Description data-slot="description">{local.description}</Kobalte.Description>
+ </Show>
+ <Kobalte.ErrorMessage data-slot="error" />
+ </Kobalte>
+ )
+}
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index b98cae07c..d60b55aeb 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -12,7 +12,6 @@
scrollbar-width: none;
[data-slot="item"] {
- cursor: pointer;
width: 100%;
padding: 4px 12px;
text-align: left;
@@ -23,6 +22,9 @@
&[data-active="true"] {
background-color: var(--surface-raised-base-hover);
}
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
+ }
&:focus {
outline: none;
}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 8bfbbdc98..cb212d1a8 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -29,6 +29,7 @@ export function List<T>(props: ListProps<T>) {
// }
const handleSelect = (item: T) => {
props.onSelect?.(item)
+ list.setActive(props.key(item))
}
const handleKey = (e: KeyboardEvent) => {
@@ -64,10 +65,10 @@ export function List<T>(props: ListProps<T>) {
data-key={props.key(item)}
data-active={props.key(item) === list.active()}
onClick={() => handleSelect(item)}
- onMouseMove={(e) => {
- e.currentTarget.focus()
+ onMouseMove={() => {
+ // e.currentTarget.focus()
setStore("mouseActive", true)
- list.setActive(props.key(item))
+ // list.setActive(props.key(item))
}}
>
{props.children(item)}
diff --git a/packages/ui/src/components/select-dialog.css b/packages/ui/src/components/select-dialog.css
new file mode 100644
index 000000000..41d8f3921
--- /dev/null
+++ b/packages/ui/src/components/select-dialog.css
@@ -0,0 +1,109 @@
+[data-component="select-dialog-input"] {
+ display: flex;
+ height: 40px;
+ flex-shrink: 0;
+ padding: 4px 10px 4px 6px;
+ align-items: center;
+ gap: 12px;
+ align-self: stretch;
+
+ border-radius: 8px;
+ background: var(--surface-base);
+
+ [data-slot="input-container"] {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex: 1 0 0;
+
+ /* [data-slot="icon"] {} */
+
+ [data-slot="input"] {
+ width: 100%;
+ }
+ }
+
+ /* [data-slot="clear-button"] {} */
+}
+
+[data-component="select-dialog"] {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ [data-slot="empty-state"] {
+ display: flex;
+ padding: 32px 160px;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ align-self: stretch;
+
+ [data-slot="message"] {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 2px;
+ color: var(--text-weak);
+ text-align: center;
+
+ /* 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);
+ }
+
+ [data-slot="filter"] {
+ color: var(--text-strong);
+ }
+ }
+
+ [data-slot="group"] {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ [data-slot="header"] {
+ display: flex;
+ padding: 4px 8px;
+ justify-content: space-between;
+ align-items: center;
+ align-self: stretch;
+
+ 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: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+ }
+
+ [data-slot="list"] {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ align-self: stretch;
+
+ [data-slot="item"] {
+ display: flex;
+ width: 100%;
+ height: 32px;
+ padding: 4px 8px 4px 4px;
+ align-items: center;
+
+ &[data-active="true"] {
+ border-radius: 8px;
+ background: var(--surface-raised-base-hover);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx
new file mode 100644
index 000000000..63fad13ec
--- /dev/null
+++ b/packages/ui/src/components/select-dialog.tsx
@@ -0,0 +1,156 @@
+import { createEffect, Show, For, type JSX, splitProps } from "solid-js"
+import { Dialog, DialogProps, Icon, IconButton, Input } from "@opencode-ai/ui"
+import { createStore } from "solid-js/store"
+import { FilteredListProps, useFilteredList } from "@opencode-ai/ui/hooks"
+
+interface SelectDialogProps<T>
+ extends FilteredListProps<T>,
+ Pick<DialogProps, "trigger" | "onOpenChange" | "defaultOpen"> {
+ title: string
+ placeholder?: string
+ emptyMessage?: string
+ children: (item: T) => JSX.Element
+ onSelect?: (value: T | undefined) => void
+}
+
+export function SelectDialog<T>(props: SelectDialogProps<T>) {
+ const [dialog, others] = splitProps(props, ["trigger", "onOpenChange", "defaultOpen"])
+ let closeButton!: HTMLButtonElement
+ let scrollRef: HTMLDivElement | undefined
+ const [store, setStore] = createStore({
+ mouseActive: false,
+ })
+
+ const { filter, grouped, flat, reset, clear, active, setActive, onKeyDown, onInput } = useFilteredList<T>({
+ items: others.items,
+ key: others.key,
+ filterKeys: others.filterKeys,
+ current: others.current,
+ groupBy: others.groupBy,
+ sortBy: others.sortBy,
+ sortGroupsBy: others.sortGroupsBy,
+ })
+
+ createEffect(() => {
+ filter()
+ scrollRef?.scrollTo(0, 0)
+ reset()
+ })
+
+ createEffect(() => {
+ const all = flat()
+ if (store.mouseActive || all.length === 0) return
+ if (active() === others.key(all[0])) {
+ scrollRef?.scrollTo(0, 0)
+ return
+ }
+ const element = scrollRef?.querySelector(`[data-key="${active()}"]`)
+ element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+ })
+
+ const handleInput = (value: string) => {
+ onInput(value)
+ reset()
+ }
+
+ const handleSelect = (item: T | undefined) => {
+ others.onSelect?.(item)
+ closeButton.click()
+ }
+
+ const handleKey = (e: KeyboardEvent) => {
+ setStore("mouseActive", false)
+ if (e.key === "Escape") return
+
+ if (e.key === "Enter") {
+ e.preventDefault()
+ const selected = flat().find((x) => others.key(x) === active())
+ if (selected) handleSelect(selected)
+ } else {
+ onKeyDown(e)
+ }
+ }
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) clear()
+ props.onOpenChange?.(open)
+ }
+
+ return (
+ <Dialog modal {...dialog} onOpenChange={handleOpenChange}>
+ <Dialog.Header>
+ <Dialog.Title>{others.title}</Dialog.Title>
+ <Dialog.CloseButton ref={closeButton} style={{ display: "none" }} />
+ </Dialog.Header>
+ <div data-component="select-dialog-input">
+ <div data-slot="input-container">
+ <Icon data-slot="icon" name="magnifying-glass" />
+ <Input
+ data-slot="input"
+ type="text"
+ value={filter()}
+ onChange={(value) => handleInput(value)}
+ onKeyDown={handleKey}
+ placeholder={others.placeholder}
+ autofocus
+ spellcheck={false}
+ autocorrect="off"
+ autocomplete="off"
+ autocapitalize="off"
+ />
+ </div>
+ <Show when={filter()}>
+ <IconButton
+ data-slot="clear-button"
+ icon="circle-x"
+ variant="ghost"
+ onClick={() => {
+ onInput("")
+ reset()
+ }}
+ />
+ </Show>
+ </div>
+ <Dialog.Body ref={scrollRef} data-component="select-dialog" class="no-scrollbar">
+ <Show
+ when={flat().length > 0}
+ fallback={
+ <div data-slot="empty-state">
+ <div data-slot="message">
+ {props.emptyMessage ?? "No search results"} for <span data-slot="filter">&quot;{filter()}&quot;</span>
+ </div>
+ </div>
+ }
+ >
+ <For each={grouped()}>
+ {(group) => (
+ <div data-slot="group">
+ <Show when={group.category}>
+ <div data-slot="header">{group.category}</div>
+ </Show>
+ <div data-slot="list">
+ <For each={group.items}>
+ {(item) => (
+ <button
+ data-slot="item"
+ data-key={others.key(item)}
+ data-active={others.key(item) === active()}
+ onClick={() => handleSelect(item)}
+ onMouseMove={() => {
+ setStore("mouseActive", true)
+ setActive(others.key(item))
+ }}
+ >
+ {others.children(item)}
+ </button>
+ )}
+ </For>
+ </div>
+ </div>
+ )}
+ </For>
+ </Show>
+ </Dialog.Body>
+ </Dialog>
+ )
+}
diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css
index b6b884a1f..0eb7cea15 100644
--- a/packages/ui/src/components/select.css
+++ b/packages/ui/src/components/select.css
@@ -1,6 +1,7 @@
[data-component="select"] {
[data-slot="trigger"] {
padding: 0 4px 0 8px;
+ box-shadow: none;
[data-slot="value"] {
overflow: hidden;
@@ -8,8 +9,8 @@
white-space: nowrap;
}
[data-slot="icon"] {
- width: fit-content;
- height: fit-content;
+ width: 16px;
+ height: 16px;
flex-shrink: 0;
color: var(--text-weak);
transition: transform 0.1s ease-in-out;
@@ -18,15 +19,15 @@
}
[data-component="select-content"] {
- min-width: 8rem;
+ min-width: 4rem;
overflow: hidden;
- border-radius: var(--radius-md);
+ border-radius: 8px;
border-width: 1px;
border-style: solid;
border-color: var(--border-weak-base);
- background-color: var(--surface-raised-base);
- padding: calc(var(--spacing) * 1);
- box-shadow: var(--shadow-md);
+ background-color: var(--surface-raised-stronger-non-alpha);
+ padding: 2px;
+ box-shadow: var(--shadow-xs);
z-index: 50;
&[data-closed] {
@@ -42,36 +43,35 @@
max-height: 12rem;
white-space: nowrap;
overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
&:focus {
outline: none;
}
}
- [data-slot="section"] {
- font-size: var(--text-xs);
- line-height: var(--text-xs--line-height);
- font-weight: var(--font-weight-light);
- text-transform: uppercase;
- color: var(--text-weak);
- opacity: 0.6;
- margin-top: calc(var(--spacing) * 3);
- margin-left: calc(var(--spacing) * 2);
- &:first-child {
- margin-top: 0;
- }
- }
+ /* [data-slot="section"] { */
+ /* } */
[data-slot="item"] {
position: relative;
display: flex;
align-items: center;
- padding: calc(var(--spacing) * 2) calc(var(--spacing) * 2);
- border-radius: var(--radius-sm);
- font-size: var(--text-xs);
- line-height: var(--text-xs--line-height);
- color: var(--text-base);
- cursor: pointer;
+ padding: 0 6px 0 6px;
+ border-radius: 6px;
+
+ /* 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: var(--line-height-large); /* 166.667% */
+ letter-spacing: var(--letter-spacing-normal);
+
+ color: var(--text-strong);
+
transition:
background-color 0.2s ease-in-out,
color 0.2s ease-in-out;
@@ -79,24 +79,20 @@
user-select: none;
&[data-highlighted] {
- background-color: var(--surface-base);
+ background: var(--surface-raised-base-hover);
}
-
&[data-disabled] {
- background-color: var(--surface-disabled);
+ background-color: var(--surface-raised-base);
pointer-events: none;
}
-
[data-slot="item-indicator"] {
margin-left: auto;
}
-
&:focus {
outline: none;
}
-
&:hover {
- background-color: var(--surface-hover);
+ background: var(--surface-raised-base-hover);
}
}
}
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index ecf05d5e2..111608e28 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -52,7 +52,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
{props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)}
</Kobalte.ItemLabel>
<Kobalte.ItemIndicator data-slot="item-indicator">
- <Icon name="checkmark" size={16} />
+ <Icon name="checkmark" />
</Kobalte.ItemIndicator>
</Kobalte.Item>
)}
@@ -79,7 +79,7 @@ export function Select<T>(props: SelectProps<T> & ButtonProps) {
}}
</Kobalte.Value>
<Kobalte.Icon data-slot="icon">
- <Icon name="chevron-down" size={16} />
+ <Icon name="chevron-down" size="small" />
</Kobalte.Icon>
</Kobalte.Trigger>
<Kobalte.Portal>
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index c6d09c656..70d7b03e1 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -10,7 +10,7 @@
background-color: var(--background-stronger);
overflow: clip;
- & [data-slot="list"] {
+ [data-slot="list"] {
width: 100%;
position: relative;
display: flex;
@@ -40,7 +40,7 @@
}
}
- & [data-slot="trigger"] {
+ [data-slot="trigger"] {
position: relative;
height: 36px;
padding: 8px 12px;
@@ -49,7 +49,7 @@
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--text-weak);
- cursor: pointer;
+
white-space: nowrap;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
@@ -77,7 +77,7 @@
}
}
- & [data-slot="content"] {
+ [data-slot="content"] {
overflow-y: auto;
flex: 1;
diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx
index 14e433e21..ff13c8d61 100644
--- a/packages/ui/src/components/tooltip.tsx
+++ b/packages/ui/src/components/tooltip.tsx
@@ -36,7 +36,7 @@ export function Tooltip(props: TooltipProps) {
<KobalteTooltip.Portal>
<KobalteTooltip.Content data-component="tooltip" data-placement={props.placement}>
{typeof others.value === "function" ? others.value() : others.value}
- {/* <KobalteTooltip.Arrow data-slot="arrow" size={18} /> */}
+ {/* <KobalteTooltip.Arrow data-slot="arrow" /> */}
</KobalteTooltip.Content>
</KobalteTooltip.Portal>
</KobalteTooltip>
diff --git a/packages/ui/src/hooks/index.ts b/packages/ui/src/hooks/index.ts
new file mode 100644
index 000000000..7eef78091
--- /dev/null
+++ b/packages/ui/src/hooks/index.ts
@@ -0,0 +1 @@
+export * from "./use-filtered-list"
diff --git a/packages/ui/src/hooks/use-filtered-list.tsx b/packages/ui/src/hooks/use-filtered-list.tsx
new file mode 100644
index 000000000..b3ddf69ed
--- /dev/null
+++ b/packages/ui/src/hooks/use-filtered-list.tsx
@@ -0,0 +1,89 @@
+import fuzzysort from "fuzzysort"
+import { entries, flatMap, groupBy, map, pipe } from "remeda"
+import { createMemo, createResource } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createList } from "solid-list"
+
+export interface FilteredListProps<T> {
+ items: T[] | ((filter: string) => Promise<T[]>)
+ key: (item: T) => string
+ filterKeys?: string[]
+ current?: 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
+}
+
+export function useFilteredList<T>(props: FilteredListProps<T>) {
+ const [store, setStore] = createStore<{ filter: string }>({ filter: "" })
+
+ const [grouped] = createResource(
+ () => store.filter,
+ async (filter) => {
+ const needle = filter?.toLowerCase()
+ const all = (typeof props.items === "function" ? await props.items(needle) : props.items) || []
+ const result = pipe(
+ all,
+ (x) => {
+ if (!needle) return x
+ if (!props.filterKeys && Array.isArray(x) && x.every((e) => typeof e === "string")) {
+ return fuzzysort.go(needle, x).map((x) => x.target) as T[]
+ }
+ return fuzzysort.go(needle, x, { keys: props.filterKeys! }).map((x) => x.obj)
+ },
+ groupBy((x) => (props.groupBy ? props.groupBy(x) : "")),
+ entries(),
+ map(([k, v]) => ({ category: k, items: props.sortBy ? v.sort(props.sortBy) : v })),
+ (groups) => (props.sortGroupsBy ? groups.sort(props.sortGroupsBy) : groups),
+ )
+ 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) : props.key(flat()[0]),
+ loop: true,
+ })
+
+ const reset = () => {
+ const all = flat()
+ if (all.length === 0) return
+ list.setActive(props.key(all[0]))
+ }
+
+ 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)
+ } else {
+ list.onKeyDown(event)
+ }
+ }
+
+ const onInput = (value: string) => {
+ setStore("filter", value)
+ reset()
+ }
+
+ return {
+ filter: () => store.filter,
+ grouped,
+ flat,
+ reset,
+ clear: () => setStore("filter", ""),
+ onKeyDown,
+ onInput,
+ active: list.active,
+ setActive: list.setActive,
+ }
+}
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index 0a89a4a0d..dc5335c43 100644
--- a/packages/ui/src/styles/index.css
+++ b/packages/ui/src/styles/index.css
@@ -6,9 +6,13 @@
@import "./base.css" layer(base);
@import "../components/button.css" layer(components);
+@import "../components/dialog.css" layer(components);
@import "../components/icon.css" layer(components);
+@import "../components/icon-button.css" layer(components);
+@import "../components/input.css" layer(components);
@import "../components/list.css" layer(components);
@import "../components/select.css" layer(components);
+@import "../components/select-dialog.css" layer(components);
@import "../components/tabs.css" layer(components);
@import "../components/tooltip.css" layer(components);
diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css
index f01a6b2ee..7d14b6539 100644
--- a/packages/ui/src/styles/utilities.css
+++ b/packages/ui/src/styles/utilities.css
@@ -5,11 +5,11 @@
pointer-events: none;
}
- ::selection {
- background-color: color-mix(in srgb, var(--color-primary) 33%, transparent);
- /* background-color: var(--color-primary); */
- /* color: var(--color-background); */
- }
+ /* ::selection { */
+ /* background-color: color-mix(in srgb, var(--color-primary) 33%, transparent); */
+ /* background-color: var(--color-primary); */
+ /* color: var(--color-background); */
+ /* } */
::-webkit-scrollbar-track {
background: var(--theme-background-panel);
@@ -36,6 +36,18 @@
}
}
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border-width: 0;
+}
+
.text-12-regular {
font-family: var(--font-family-sans);
font-size: var(--font-size-small);