summaryrefslogtreecommitdiffhomepage
path: root/packages/ui/src/components
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-11 10:19:24 -0600
committerAdam <[email protected]>2025-12-11 13:42:46 -0600
commit4ae7e1b19c3915e3e9b1a39195d54c4721836b03 (patch)
tree2faef6b5dabf3a1e9057aaf9538b1a726685e981 /packages/ui/src/components
parent0ca758e13516d96180cc11631e02e5f7929ba4b0 (diff)
downloadopencode-4ae7e1b19c3915e3e9b1a39195d54c4721836b03.tar.gz
opencode-4ae7e1b19c3915e3e9b1a39195d54c4721836b03.zip
wip(desktop): progress
Diffstat (limited to 'packages/ui/src/components')
-rw-r--r--packages/ui/src/components/icon.tsx1
-rw-r--r--packages/ui/src/components/toast.css175
-rw-r--r--packages/ui/src/components/toast.tsx142
3 files changed, 318 insertions, 0 deletions
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 080a6274d..56cef2d8c 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -46,6 +46,7 @@ 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"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css
new file mode 100644
index 000000000..2c55a4b06
--- /dev/null
+++ b/packages/ui/src/components/toast.css
@@ -0,0 +1,175 @@
+[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: rgba(253, 252, 252, 0.94);
+ 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: rgba(253, 252, 252, 0.94);
+
+ /* 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: rgba(253, 249, 249, 0.7);
+
+ /* 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-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..b6c9f8b08
--- /dev/null
+++ b/packages/ui/src/components/toast.tsx
@@ -0,0 +1,142 @@
+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 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,
+ CloseButton: ToastCloseButton,
+ ProgressTrack: ToastProgressTrack,
+ ProgressFill: ToastProgressFill,
+})
+
+export { toaster }
+
+export type ToastVariant = "default" | "success" | "error" | "loading"
+
+export interface ToastOptions {
+ title?: string
+ description?: string
+ icon?: IconProps["name"]
+ variant?: ToastVariant
+ duration?: number
+}
+
+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"}>
+ <div data-slot="toast-inner">
+ <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>
+ </Toast.Content>
+ </div>
+ <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>
+ ))
+}