summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 09:44:06 +0000
committerDavid Hill <[email protected]>2025-12-12 09:44:06 +0000
commit99158e736bd983ee5c62bfc43032da1bafc45d71 (patch)
tree59b1afc0fff72a8c47ca76c90a767aafae7a5f45 /packages/ui/src/components
parent4c02d515a1e15a99fc009587e821087e042bd45b (diff)
parentf9d5e1879056dd9507bb1a1645da5b5ede87fcca (diff)
downloadopencode-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.css3
-rw-r--r--packages/ui/src/components/avatar.tsx13
-rw-r--r--packages/ui/src/components/dialog.css11
-rw-r--r--packages/ui/src/components/icon.tsx3
-rw-r--r--packages/ui/src/components/list.css7
-rw-r--r--packages/ui/src/components/message-nav.tsx21
-rw-r--r--packages/ui/src/components/select-dialog.tsx4
-rw-r--r--packages/ui/src/components/session-message-rail.tsx13
-rw-r--r--packages/ui/src/components/session-turn.tsx4
-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.css203
-rw-r--r--packages/ui/src/components/toast.tsx160
-rw-r--r--packages/ui/src/components/tooltip.css1
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);