mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-21 01:34:22 +01:00
wip: desktop work
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
129
packages/ui/src/components/dialog.css
Normal file
129
packages/ui/src/components/dialog.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
91
packages/ui/src/components/dialog.tsx
Normal file
91
packages/ui/src/components/dialog.tsx
Normal file
@@ -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,
|
||||
})
|
||||
117
packages/ui/src/components/icon-button.css
Normal file
117
packages/ui/src/components/icon-button.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/components/icon-button.tsx
Normal file
27
packages/ui/src/components/icon-button.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
packages/ui/src/components/input.css
Normal file
23
packages/ui/src/components/input.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/ui/src/components/input.tsx
Normal file
27
packages/ui/src/components/input.tsx
Normal file
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
109
packages/ui/src/components/select-dialog.css
Normal file
109
packages/ui/src/components/select-dialog.css
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
156
packages/ui/src/components/select-dialog.tsx
Normal file
156
packages/ui/src/components/select-dialog.tsx
Normal file
@@ -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">"{filter()}"</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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
1
packages/ui/src/hooks/index.ts
Normal file
1
packages/ui/src/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./use-filtered-list"
|
||||
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
89
packages/ui/src/hooks/use-filtered-list.tsx
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user