From 89b703c387aed3ee918d826b788b4be1729bdde9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 22 Oct 2025 17:31:44 -0500 Subject: wip: desktop work --- packages/ui/package.json | 9 +- packages/ui/src/components/button.css | 13 +-- packages/ui/src/components/button.tsx | 6 +- packages/ui/src/components/dialog.css | 129 ++++++++++++++++++++++ packages/ui/src/components/dialog.tsx | 91 ++++++++++++++++ packages/ui/src/components/icon-button.css | 117 ++++++++++++++++++++ packages/ui/src/components/icon-button.tsx | 27 +++++ packages/ui/src/components/icon.css | 23 ++++ packages/ui/src/components/icon.tsx | 61 ++++++++--- packages/ui/src/components/index.ts | 4 + packages/ui/src/components/input.css | 23 ++++ packages/ui/src/components/input.tsx | 27 +++++ packages/ui/src/components/list.css | 4 +- packages/ui/src/components/list.tsx | 7 +- packages/ui/src/components/select-dialog.css | 109 +++++++++++++++++++ packages/ui/src/components/select-dialog.tsx | 156 +++++++++++++++++++++++++++ packages/ui/src/components/select.css | 62 +++++------ packages/ui/src/components/select.tsx | 4 +- packages/ui/src/components/tabs.css | 8 +- packages/ui/src/components/tooltip.tsx | 2 +- packages/ui/src/hooks/index.ts | 1 + packages/ui/src/hooks/use-filtered-list.tsx | 89 +++++++++++++++ packages/ui/src/styles/index.css | 4 + packages/ui/src/styles/utilities.css | 22 +++- 24 files changed, 917 insertions(+), 81 deletions(-) create mode 100644 packages/ui/src/components/dialog.css create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/icon-button.css create mode 100644 packages/ui/src/components/icon-button.tsx create mode 100644 packages/ui/src/components/input.css create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/select-dialog.css create mode 100644 packages/ui/src/components/select-dialog.tsx create mode 100644 packages/ui/src/hooks/index.ts create mode 100644 packages/ui/src/hooks/use-filtered-list.tsx (limited to 'packages/ui') diff --git a/packages/ui/package.json b/packages/ui/package.json index 4419b1f20..c32bfb7ee 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -5,6 +5,7 @@ "exports": { ".": "./src/components/index.ts", "./*": "./src/components/*.tsx", + "./hooks": "./src/hooks/index.ts", "./styles": "./src/styles/index.css", "./styles/tailwind": "./src/styles/tailwind/index.css", "./fonts/*": "./src/assets/fonts/*" @@ -23,11 +24,13 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@pierre/precision-diffs": "0.0.2-alpha.1-1", "@solidjs/meta": "catalog:", - "remeda": "catalog:", + "fuzzysort": "catalog:", "luxon": "catalog:", - "virtua": "catalog:", + "remeda": "catalog:", "solid-js": "catalog:", - "solid-list": "catalog:" + "solid-list": "catalog:", + "virtua": "catalog:" } } 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, + Pick, "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 ( ["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 ( + + + + {props.trigger} + + + + +
+
+ + {local.children} + +
+
+
+
+ ) +} + +function DialogHeader(props: ComponentProps<"div">) { + return
+} + +function DialogBody(props: ComponentProps<"div">) { + return
+} + +function DialogTitle(props: DialogTitleProps & ComponentProps<"h2">) { + return +} + +function DialogDescription(props: DialogDescriptionProps & ComponentProps<"p">) { + return +} + +function DialogCloseButton(props: DialogCloseButtonProps & ComponentProps<"button">) { + return +} + +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 ( + + + + ) +} 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: '', } as const +const newIcons = { + "circle-x": ``, + "magnifying-glass": ``, + "plus-small": ``, + "chevron-down": ``, + "arrow-up": ``, +} + 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 ( +
+ +
+ ) + } + return ( - +
+ +
) } 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 { + label?: string + hideLabel?: boolean + description?: string +} + +export function Input(props: InputProps) { + const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"]) + return ( + + + + {local.label} + + + + + {local.description} + + + + ) +} 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(props: ListProps) { // } const handleSelect = (item: T) => { props.onSelect?.(item) + list.setActive(props.key(item)) } const handleKey = (e: KeyboardEvent) => { @@ -64,10 +65,10 @@ export function List(props: ListProps) { 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 + extends FilteredListProps, + Pick { + title: string + placeholder?: string + emptyMessage?: string + children: (item: T) => JSX.Element + onSelect?: (value: T | undefined) => void +} + +export function SelectDialog(props: SelectDialogProps) { + 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({ + 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 ( + + + {others.title} + + +
+
+ + handleInput(value)} + onKeyDown={handleKey} + placeholder={others.placeholder} + autofocus + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + /> +
+ + { + onInput("") + reset() + }} + /> + +
+ + 0} + fallback={ +
+
+ {props.emptyMessage ?? "No search results"} for "{filter()}" +
+
+ } + > + + {(group) => ( +
+ +
{group.category}
+
+
+ + {(item) => ( + + )} + +
+
+ )} +
+
+
+
+ ) +} 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(props: SelectProps & ButtonProps) { {props.label ? props.label(itemProps.item.rawValue) : (itemProps.item.rawValue as string)} - + )} @@ -79,7 +79,7 @@ export function Select(props: SelectProps & ButtonProps) { }} - + 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) { {typeof others.value === "function" ? others.value() : others.value} - {/* */} + {/* */} 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 { + items: T[] | ((filter: string) => Promise) + 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(props: FilteredListProps) { + 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); -- cgit v1.2.3