diff options
| author | Adam <[email protected]> | 2025-12-11 10:19:24 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-11 13:42:46 -0600 |
| commit | 4ae7e1b19c3915e3e9b1a39195d54c4721836b03 (patch) | |
| tree | 2faef6b5dabf3a1e9057aaf9538b1a726685e981 /packages/ui/src/components | |
| parent | 0ca758e13516d96180cc11631e02e5f7929ba4b0 (diff) | |
| download | opencode-4ae7e1b19c3915e3e9b1a39195d54c4721836b03.tar.gz opencode-4ae7e1b19c3915e3e9b1a39195d54c4721836b03.zip | |
wip(desktop): progress
Diffstat (limited to 'packages/ui/src/components')
| -rw-r--r-- | packages/ui/src/components/icon.tsx | 1 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.css | 175 | ||||
| -rw-r--r-- | packages/ui/src/components/toast.tsx | 142 |
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> + )) +} |
