diff options
| author | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-12-12 09:44:06 +0000 |
| commit | 99158e736bd983ee5c62bfc43032da1bafc45d71 (patch) | |
| tree | 59b1afc0fff72a8c47ca76c90a767aafae7a5f45 /packages/ui/src/components | |
| parent | 4c02d515a1e15a99fc009587e821087e042bd45b (diff) | |
| parent | f9d5e1879056dd9507bb1a1645da5b5ede87fcca (diff) | |
| download | opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.tar.gz opencode-99158e736bd983ee5c62bfc43032da1bafc45d71.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/ui/src/components')
| -rw-r--r-- | packages/ui/src/components/avatar.css | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/avatar.tsx | 13 | ||||
| -rw-r--r-- | packages/ui/src/components/dialog.css | 11 | ||||
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 3 | ||||
| -rw-r--r-- | packages/ui/src/components/list.css | 7 | ||||
| -rw-r--r-- | packages/ui/src/components/message-nav.tsx | 21 | ||||
| -rw-r--r-- | packages/ui/src/components/select-dialog.tsx | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/session-message-rail.tsx | 13 | ||||
| -rw-r--r-- | packages/ui/src/components/session-turn.tsx | 4 | ||||
| -rw-r--r-- | packages/ui/src/components/text-field.css (renamed from packages/ui/src/components/input.css) | 62 | ||||
| -rw-r--r-- | packages/ui/src/components/text-field.tsx (renamed from packages/ui/src/components/input.tsx) | 42 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.css | 203 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.tsx | 160 | ||||
| -rw-r--r-- | packages/ui/src/components/tooltip.css | 1 |
14 files changed, 483 insertions, 64 deletions
diff --git a/packages/ui/src/components/avatar.css b/packages/ui/src/components/avatar.css index 4e42e6f99..87be9a50a 100644 --- a/packages/ui/src/components/avatar.css +++ b/packages/ui/src/components/avatar.css @@ -1,5 +1,6 @@ [data-component="avatar"] { --avatar-bg: var(--color-surface-info-base); + --avatar-fg: var(--color-text-base); display: flex; align-items: center; justify-content: center; @@ -10,7 +11,7 @@ font-weight: 500; text-transform: uppercase; background-color: var(--avatar-bg); - color: oklch(from var(--avatar-bg) calc(l * 0.72) calc(c * 8) h); + color: var(--avatar-fg); } [data-component="avatar"][data-has-image] { diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index fb5798b08..ab7b0d0e2 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -4,11 +4,21 @@ export interface AvatarProps extends ComponentProps<"div"> { fallback: string src?: string background?: string + foreground?: string size?: "small" | "normal" | "large" } export function Avatar(props: AvatarProps) { - const [split, rest] = splitProps(props, ["fallback", "src", "background", "size", "class", "classList", "style"]) + const [split, rest] = splitProps(props, [ + "fallback", + "src", + "background", + "foreground", + "size", + "class", + "classList", + "style", + ]) const src = split.src // did this so i can zero it out to test fallback return ( <div @@ -23,6 +33,7 @@ export function Avatar(props: AvatarProps) { style={{ ...(typeof split.style === "object" ? split.style : {}), ...(!src && split.background ? { "--avatar-bg": split.background } : {}), + ...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}), }} > <Show when={src} fallback={split.fallback?.[0]}> diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index 1c7cd4f41..979906e26 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -88,8 +88,19 @@ flex-direction: column; flex: 1; overflow-y: auto; + + &:focus-visible { + outline: none; + } + } + &:focus-visible { + outline: none; } } + + &:focus-visible { + outline: none; + } } } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 40be8955c..79cd85532 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -47,6 +47,9 @@ const icons = { "layout-bottom-partial": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor" fill-opacity="40%" /><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, "layout-bottom-full": `<path d="M2.5 17.5L2.5 12.2059L17.5 12.2059L17.5 17.5L2.5 17.5Z" fill="currentColor"/><path d="M2.5 17.5L2.5 2.5M2.5 17.5L17.5 17.5M2.5 17.5L2.5 12.2059M2.5 2.5L17.5 2.5M2.5 2.5L2.5 12.2059M17.5 2.5L17.5 17.5M17.5 2.5L17.5 12.2059M17.5 17.5L17.5 12.2059M17.5 12.2059L2.5 12.2059" stroke="currentColor" stroke-linecap="square"/>`, "dot-grid": `<path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" fill="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" fill="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" fill="currentColor"/><path d="M2.08398 9.16602H3.75065V10.8327H2.08398V9.16602Z" stroke="currentColor"/><path d="M10.834 9.16602H9.16732V10.8327H10.834V9.16602Z" stroke="currentColor"/><path d="M16.2507 9.16602H17.9173V10.8327H16.2507V9.16602Z" stroke="currentColor"/>`, + "circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`, + copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`, + check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`, } export interface IconProps extends ComponentProps<"svg"> { diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index 38dcb773b..132824164 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -98,16 +98,15 @@ display: block; } [data-slot="list-item-extra-icon"] { + display: block !important; color: var(--icon-strong-base) !important; } } &:active { background: var(--surface-raised-base-active); } - &:hover { - [data-slot="list-item-extra-icon"] { - color: var(--icon-strong-base) !important; - } + &:focus-visible { + outline: none; } } } diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx index 29b465c8c..7416cfd93 100644 --- a/packages/ui/src/components/message-nav.tsx +++ b/packages/ui/src/components/message-nav.tsx @@ -1,7 +1,6 @@ import { UserMessage } from "@opencode-ai/sdk/v2" -import { ComponentProps, createMemo, For, Match, Show, splitProps, Switch } from "solid-js" +import { ComponentProps, For, Match, Show, splitProps, Switch } from "solid-js" import { DiffChanges } from "./diff-changes" -import { Spinner } from "./spinner" import { Tooltip } from "@kobalte/core/tooltip" export function MessageNav( @@ -9,20 +8,15 @@ export function MessageNav( messages: UserMessage[] current?: UserMessage size: "normal" | "compact" - working?: boolean onMessageSelect: (message: UserMessage) => void }, ) { - const [local, others] = splitProps(props, ["messages", "current", "size", "working", "onMessageSelect"]) - const lastUserMessage = createMemo(() => { - return local.messages?.at(0) - }) + const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"]) const content = () => ( <ul role="list" data-component="message-nav" data-size={local.size} {...others}> <For each={local.messages}> {(message) => { - const messageWorking = createMemo(() => message.id === lastUserMessage()?.id && local.working) const handleClick = () => local.onMessageSelect(message) return ( @@ -35,14 +29,7 @@ export function MessageNav( </Match> <Match when={local.size === "normal"}> <button data-slot="message-nav-message-button" onClick={handleClick}> - <Switch> - <Match when={messageWorking()}> - <Spinner /> - </Match> - <Match when={true}> - <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> - </Match> - </Switch> + <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" /> <div data-slot="message-nav-title-preview" data-active={message.id === local.current?.id || undefined} @@ -64,7 +51,7 @@ export function MessageNav( return ( <Switch> <Match when={local.size === "compact"}> - <Tooltip openDelay={0} closeDelay={300} placement="left-start" gutter={-65} shift={-16} overlap> + <Tooltip openDelay={0} closeDelay={300} placement="right-start" gutter={-40} shift={-10} overlap> <Tooltip.Trigger as="div">{content()}</Tooltip.Trigger> <Tooltip.Portal> <Tooltip.Content data-slot="message-nav-tooltip"> diff --git a/packages/ui/src/components/select-dialog.tsx b/packages/ui/src/components/select-dialog.tsx index efa6c405b..06953168c 100644 --- a/packages/ui/src/components/select-dialog.tsx +++ b/packages/ui/src/components/select-dialog.tsx @@ -3,7 +3,7 @@ import { Dialog, DialogProps } from "./dialog" import { Icon } from "./icon" import { IconButton } from "./icon-button" import { List, ListRef, ListProps } from "./list" -import { Input } from "./input" +import { TextField } from "./text-field" interface SelectDialogProps<T> extends Omit<ListProps<T>, "filter">, @@ -55,7 +55,7 @@ export function SelectDialog<T>(props: SelectDialogProps<T>) { <div data-component="select-dialog-input"> <div data-slot="select-dialog-input-container"> <Icon name="magnifying-glass" /> - <Input + <TextField ref={inputRef} autofocus variant="ghost" diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx index 132b813d2..1935a4f93 100644 --- a/packages/ui/src/components/session-message-rail.tsx +++ b/packages/ui/src/components/session-message-rail.tsx @@ -6,21 +6,12 @@ import "./session-message-rail.css" export interface SessionMessageRailProps extends ComponentProps<"div"> { messages: UserMessage[] current?: UserMessage - working?: boolean wide?: boolean onMessageSelect: (message: UserMessage) => void } export function SessionMessageRail(props: SessionMessageRailProps) { - const [local, others] = splitProps(props, [ - "messages", - "current", - "working", - "wide", - "onMessageSelect", - "class", - "classList", - ]) + const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"]) return ( <Show when={(local.messages?.length ?? 0) > 1}> @@ -39,7 +30,6 @@ export function SessionMessageRail(props: SessionMessageRailProps) { current={local.current} onMessageSelect={local.onMessageSelect} size="compact" - working={local.working} /> </div> <div data-slot="session-message-rail-full"> @@ -48,7 +38,6 @@ export function SessionMessageRail(props: SessionMessageRailProps) { current={local.current} onMessageSelect={local.onMessageSelect} size={local.wide ? "normal" : "compact"} - working={local.working} /> </div> </div> diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5e73c6772..f97a3224c 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -42,10 +42,10 @@ export function SessionTurn( const userMessages = createMemo(() => messages() .filter((m) => m.role === "user") - .sort((a, b) => b.id.localeCompare(a.id)), + .sort((a, b) => a.id.localeCompare(b.id)), ) const lastUserMessage = createMemo(() => { - return userMessages()?.at(0) + return userMessages()?.at(-1) }) const message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID)) diff --git a/packages/ui/src/components/input.css b/packages/ui/src/components/text-field.css index 276e8069b..897050a63 100644 --- a/packages/ui/src/components/input.css +++ b/packages/ui/src/components/text-field.css @@ -40,6 +40,37 @@ letter-spacing: var(--letter-spacing-normal); } + [data-slot="input-wrapper"] { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding-right: 4px; + + border-radius: var(--radius-md); + border: 1px solid var(--border-weak-base); + background: var(--input-base); + + &:focus-within { + /* border/shadow-xs/select */ + box-shadow: + 0 0 0 3px var(--border-weak-selected), + 0 0 0 1px var(--border-selected), + 0 1px 2px -1px rgba(19, 16, 16, 0.25), + 0 1px 2px 0 rgba(19, 16, 16, 0.08), + 0 1px 3px 0 rgba(19, 16, 16, 0.12); + } + + &:has([data-invalid]) { + background: var(--surface-critical-weak); + border: 1px solid var(--border-critical-selected); + } + + &:not(:has([data-slot="input-copy-button"])) { + padding-right: 0; + } + } + [data-slot="input-input"] { color: var(--text-strong); @@ -47,12 +78,11 @@ height: 32px; padding: 2px 12px; align-items: center; - gap: 8px; - align-self: stretch; + flex: 1; + min-width: 0; - border-radius: var(--radius-md); - border: 1px solid var(--border-weak-base); - background: var(--input-base); + background: transparent; + border: none; /* text-14-regular */ font-family: var(--font-family-sans); @@ -64,19 +94,6 @@ &:focus { outline: none; - - /* border/shadow-xs/select */ - box-shadow: - 0 0 0 3px var(--border-weak-selected), - 0 0 0 1px var(--border-selected), - 0 1px 2px -1px rgba(19, 16, 16, 0.25), - 0 1px 2px 0 rgba(19, 16, 16, 0.08), - 0 1px 3px 0 rgba(19, 16, 16, 0.12); - } - - &[data-invalid] { - background: var(--surface-critical-weak); - border: 1px solid var(--border-critical-selected); } &::placeholder { @@ -84,6 +101,15 @@ } } + [data-slot="input-copy-button"] { + flex-shrink: 0; + color: var(--icon-base); + + &:hover { + color: var(--icon-strong-base); + } + } + [data-slot="input-error"] { color: var(--text-on-critical-base); diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/text-field.tsx index 8e2a115c6..63ffb2594 100644 --- a/packages/ui/src/components/input.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -1,8 +1,10 @@ import { TextField as Kobalte } from "@kobalte/core/text-field" -import { Show, splitProps } from "solid-js" +import { createSignal, Show, splitProps } from "solid-js" import type { ComponentProps } from "solid-js" +import { IconButton } from "./icon-button" +import { Tooltip } from "./tooltip" -export interface InputProps +export interface TextFieldProps extends ComponentProps<typeof Kobalte.Input>, Partial< Pick< @@ -20,13 +22,13 @@ export interface InputProps > { label?: string hideLabel?: boolean - hidden?: boolean description?: string error?: string variant?: "normal" | "ghost" + copyable?: boolean } -export function Input(props: InputProps) { +export function TextField(props: TextFieldProps) { const [local, others] = splitProps(props, [ "name", "defaultValue", @@ -39,12 +41,21 @@ export function Input(props: InputProps) { "readOnly", "class", "label", - "hidden", "hideLabel", "description", "error", "variant", + "copyable", ]) + const [copied, setCopied] = createSignal(false) + + async function handleCopy() { + const value = local.value ?? local.defaultValue ?? "" + await navigator.clipboard.writeText(value) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + return ( <Kobalte data-component="input" @@ -57,7 +68,6 @@ export function Input(props: InputProps) { required={local.required} disabled={local.disabled} readOnly={local.readOnly} - style={{ height: local.hidden ? 0 : undefined }} validationState={local.validationState} > <Show when={local.label}> @@ -65,7 +75,20 @@ export function Input(props: InputProps) { {local.label} </Kobalte.Label> </Show> - <Kobalte.Input {...others} data-slot="input-input" class={local.class} /> + <div data-slot="input-wrapper"> + <Kobalte.Input {...others} data-slot="input-input" class={local.class} /> + <Show when={local.copyable}> + <Tooltip value={copied() ? "Copied" : "Copy to clipboard"} placement="top" gutter={8}> + <IconButton + type="button" + icon={copied() ? "check" : "copy"} + variant="ghost" + onClick={handleCopy} + data-slot="input-copy-button" + /> + </Tooltip> + </Show> + </div> <Show when={local.description}> <Kobalte.Description data-slot="input-description">{local.description}</Kobalte.Description> </Show> @@ -73,3 +96,8 @@ export function Input(props: InputProps) { </Kobalte> ) } + +/** @deprecated Use TextField instead */ +export const Input = TextField +/** @deprecated Use TextFieldProps instead */ +export type InputProps = TextFieldProps diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css new file mode 100644 index 000000000..fbc84f13c --- /dev/null +++ b/packages/ui/src/components/toast.css @@ -0,0 +1,203 @@ +[data-component="toast-region"] { + position: fixed; + bottom: 32px; + right: 32px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + max-width: 400px; + width: 100%; + pointer-events: none; + + [data-slot="toast-list"] { + display: flex; + flex-direction: column; + gap: 8px; + list-style: none; + margin: 0; + padding: 0; + } +} + +[data-component="toast"] { + display: flex; + align-items: flex-start; + gap: 20px; + padding: 16px 20px; + pointer-events: auto; + transition: all 150ms ease-out; + + border-radius: var(--radius-lg); + border: 1px solid var(--border-weak-base); + background: var(--surface-float-base); + color: var(--text-inverted-base); + box-shadow: var(--shadow-md); + + [data-slot="toast-inner"] { + display: flex; + align-items: flex-start; + gap: 10px; + } + + &[data-opened] { + animation: toastPopIn 150ms ease-out; + } + + &[data-closed] { + animation: toastPopOut 100ms ease-in forwards; + } + + &[data-swipe="move"] { + transform: translateX(var(--kb-toast-swipe-move-x)); + } + + &[data-swipe="cancel"] { + transform: translateX(0); + transition: transform 200ms ease-out; + } + + &[data-swipe="end"] { + animation: toastSwipeOut 100ms ease-out forwards; + } + + /* &[data-variant="success"] { */ + /* border-color: var(--color-semantic-positive); */ + /* } */ + /**/ + /* &[data-variant="error"] { */ + /* border-color: var(--color-semantic-danger); */ + /* } */ + /**/ + /* &[data-variant="loading"] { */ + /* border-color: var(--color-semantic-info); */ + /* } */ + + [data-slot="toast-icon"] { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + [data-component="icon"] { + color: rgba(253, 252, 252, 0.94); + } + } + + [data-slot="toast-content"] { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + [data-slot="toast-title"] { + color: var(--text-inverted-strong); + + /* text-14-medium */ + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); /* 142.857% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-description"] { + color: var(--text-inverted-base); + + /* text-14-regular */ + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-style: normal; + font-weight: var(--font-weight-regular); + line-height: var(--line-height-x-large); /* 171.429% */ + letter-spacing: var(--letter-spacing-normal); + + margin: 0; + } + + [data-slot="toast-actions"] { + display: flex; + gap: 16px; + margin-top: 8px; + } + + [data-slot="toast-action"] { + background: none; + border: none; + padding: 0; + cursor: pointer; + + color: var(--text-inverted-strong); + font-family: var(--font-family-sans); + font-size: var(--font-size-base); + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + &:hover { + text-decoration: underline; + } + + &:last-child { + color: var(--text-inverted-weak); + } + } + + [data-slot="toast-close-button"] { + flex-shrink: 0; + } + + [data-slot="toast-progress-track"] { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background-color: var(--surface-base); + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + overflow: hidden; + } + + [data-slot="toast-progress-fill"] { + height: 100%; + width: var(--kb-toast-progress-fill-width); + background-color: var(--color-primary); + transition: width 250ms linear; + } +} + +@keyframes toastPopIn { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes toastPopOut { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(20px); + } +} + +@keyframes toastSwipeOut { + from { + transform: translateX(var(--kb-toast-swipe-end-x)); + } + to { + transform: translateX(100%); + } +} diff --git a/packages/ui/src/components/toast.tsx b/packages/ui/src/components/toast.tsx new file mode 100644 index 000000000..5869f8a6b --- /dev/null +++ b/packages/ui/src/components/toast.tsx @@ -0,0 +1,160 @@ +import { Toast as Kobalte, toaster } from "@kobalte/core/toast" +import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import type { ComponentProps, JSX } from "solid-js" +import { Show } from "solid-js" +import { Portal } from "solid-js/web" +import { Icon, type IconProps } from "./icon" +import { IconButton } from "./icon-button" + +export interface ToastRegionProps extends ComponentProps<typeof Kobalte.Region> {} + +function ToastRegion(props: ToastRegionProps) { + return ( + <Portal> + <Kobalte.Region data-component="toast-region" {...props}> + <Kobalte.List data-slot="toast-list" /> + </Kobalte.Region> + </Portal> + ) +} + +export interface ToastRootComponentProps extends ToastRootProps { + class?: string + classList?: ComponentProps<"li">["classList"] + children?: JSX.Element +} + +function ToastRoot(props: ToastRootComponentProps) { + return ( + <Kobalte + data-component="toast" + classList={{ + ...(props.classList ?? {}), + [props.class ?? ""]: !!props.class, + }} + {...props} + /> + ) +} + +function ToastIcon(props: { name: IconProps["name"] }) { + return ( + <div data-slot="toast-icon"> + <Icon name={props.name} /> + </div> + ) +} + +function ToastContent(props: ComponentProps<"div">) { + return <div data-slot="toast-content" {...props} /> +} + +function ToastTitle(props: ToastTitleProps & ComponentProps<"div">) { + return <Kobalte.Title data-slot="toast-title" {...props} /> +} + +function ToastDescription(props: ToastDescriptionProps & ComponentProps<"div">) { + return <Kobalte.Description data-slot="toast-description" {...props} /> +} + +function ToastActions(props: ComponentProps<"div">) { + return <div data-slot="toast-actions" {...props} /> +} + +function ToastCloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { + return <Kobalte.CloseButton data-slot="toast-close-button" as={IconButton} icon="close" variant="ghost" {...props} /> +} + +function ToastProgressTrack(props: ComponentProps<typeof Kobalte.ProgressTrack>) { + return <Kobalte.ProgressTrack data-slot="toast-progress-track" {...props} /> +} + +function ToastProgressFill(props: ComponentProps<typeof Kobalte.ProgressFill>) { + return <Kobalte.ProgressFill data-slot="toast-progress-fill" {...props} /> +} + +export const Toast = Object.assign(ToastRoot, { + Region: ToastRegion, + Icon: ToastIcon, + Content: ToastContent, + Title: ToastTitle, + Description: ToastDescription, + Actions: ToastActions, + CloseButton: ToastCloseButton, + ProgressTrack: ToastProgressTrack, + ProgressFill: ToastProgressFill, +}) + +export { toaster } + +export type ToastVariant = "default" | "success" | "error" | "loading" + +export interface ToastAction { + label: string + onClick: () => void +} + +export interface ToastOptions { + title?: string + description?: string + icon?: IconProps["name"] + variant?: ToastVariant + duration?: number + actions?: ToastAction[] +} + +export function showToast(options: ToastOptions | string) { + const opts = typeof options === "string" ? { description: options } : options + return toaster.show((props) => ( + <Toast toastId={props.toastId} duration={opts.duration} data-variant={opts.variant ?? "default"}> + <Show when={opts.icon}> + <Toast.Icon name={opts.icon!} /> + </Show> + <Toast.Content> + <Show when={opts.title}> + <Toast.Title>{opts.title}</Toast.Title> + </Show> + <Show when={opts.description}> + <Toast.Description>{opts.description}</Toast.Description> + </Show> + <Show when={opts.actions?.length}> + <Toast.Actions> + {opts.actions!.map((action) => ( + <button data-slot="toast-action" onClick={action.onClick}> + {action.label} + </button> + ))} + </Toast.Actions> + </Show> + </Toast.Content> + <Toast.CloseButton /> + </Toast> + )) +} + +export interface ToastPromiseOptions<T, U = unknown> { + loading?: JSX.Element + success?: (data: T) => JSX.Element + error?: (error: U) => JSX.Element +} + +export function showPromiseToast<T, U = unknown>( + promise: Promise<T> | (() => Promise<T>), + options: ToastPromiseOptions<T, U>, +) { + return toaster.promise(promise, (props) => ( + <Toast + toastId={props.toastId} + data-variant={props.state === "pending" ? "loading" : props.state === "fulfilled" ? "success" : "error"} + > + <Toast.Content> + <Toast.Description> + {props.state === "pending" && options.loading} + {props.state === "fulfilled" && options.success?.(props.data!)} + {props.state === "rejected" && options.error?.(props.error)} + </Toast.Description> + </Toast.Content> + <Toast.CloseButton /> + </Toast> + )) +} diff --git a/packages/ui/src/components/tooltip.css b/packages/ui/src/components/tooltip.css index 72ee269b2..637986249 100644 --- a/packages/ui/src/components/tooltip.css +++ b/packages/ui/src/components/tooltip.css @@ -7,6 +7,7 @@ max-width: 320px; border-radius: var(--radius-md); background-color: var(--surface-float-base); + color: var(--text-inverted-base); color: rgba(253, 252, 252, 0.94); padding: 2px 8px; border: 0.5px solid rgba(253, 252, 252, 0.2); |
