Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/components/copy-connected-icon/CopyConnectedIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!-- Custom copy glyph. lucide's `Copy` draws the back square as a bracket that
stops ~4px short of the front square, leaving a visible gap that reads as two
disconnected shapes. This redraws the same two-square copy mark in lucide's
exact stroke language (24px box, 2px round stroke, currentColor) but runs the
bracket's two open ends onto the front square's left and top edges, so the
back square reads as continuously joined to the front one. Single <svg> root,
so it inherits the button's [&_svg] sizing and any passed size-* class. -->
<template>
<svg
data-slot="copy-connected-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect
width="14"
height="14"
x="8"
y="8"
rx="3"
/>
<path d="M8 16H5a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h8a3 3 0 0 1 3 3v3" />
</svg>
</template>

<script setup lang="ts">
withDefaults(defineProps<{ strokeWidth?: number }>(), { strokeWidth: 2 })
</script>
1 change: 1 addition & 0 deletions src/components/copy-connected-icon/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as CopyConnectedIcon } from './CopyConnectedIcon.vue'
125 changes: 104 additions & 21 deletions src/components/sonner/Toaster.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
<script lang="ts" setup>
import type { Component } from 'vue'
import { CircleCheckIcon, CircleXIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted } from 'vue'
import { CheckIcon, CircleCheckIcon, CircleXIcon, InfoIcon, TriangleAlertIcon, XIcon } from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
import { Button } from '#/components/button'
import { CopyConnectedIcon } from '../copy-connected-icon'
import { cn } from '#/lib/utils'
import { dismiss, pauseAll, resumeAll, toast as toastApi, toasts, type ToastRecord, type ToastVariant } from './toast'
import { dismiss, pauseAll, resumeAll, toast as toastApi, toasts, type ToastRecord, type ToastTextAction, type ToastVariant } from './toast'

export type ToasterPosition =
| 'top-right'
Expand Down Expand Up @@ -54,13 +55,50 @@ const isTop = computed(() => props.position.startsWith('top'))
// the bottom for a bottom dock. The store is chronological; we only flip the view.
const ordered = computed(() => (isTop.value ? [...toasts].reverse() : toasts))

const actionStates = reactive(new Map<string, { action: ToastTextAction; phase: 'pending' | 'success' | 'failure' }>())
const feedbackTimers = new Map<string, ReturnType<typeof setTimeout>>()
let disposed = false

function clearActionState(id: string) {
clearTimeout(feedbackTimers.get(id))
feedbackTimers.delete(id)
actionStates.delete(id)
}

function actionPhase(t: ToastRecord) {
const state = actionStates.get(t.id)
return state && state.action === t.textAction ? state.phase : undefined
}

async function runTextAction(t: ToastRecord) {
const action = t.textAction
if (!action || actionPhase(t) === 'pending') return
clearActionState(t.id)
actionStates.set(t.id, { action, phase: 'pending' })
let succeeded = false
try { succeeded = await action.onClick() } catch { /* The local failure label remains available for retry. */ }
if (disposed || toasts.find(current => current.id === t.id)?.textAction !== action) return
actionStates.set(t.id, { action, phase: succeeded ? 'success' : 'failure' })
if (succeeded) feedbackTimers.set(t.id, setTimeout(() => clearActionState(t.id), 1500))
}

watch(() => toasts.map(t => [t.id, t.textAction] as const), (current) => {
for (const [id, state] of actionStates) {
if (!current.some(([key, action]) => key === id && action === state.action)) clearActionState(id)
}
})

function onVisibilityChange() {
if (document.hidden) pauseAll()
else resumeAll()
}

onMounted(() => document.addEventListener('visibilitychange', onVisibilityChange))
onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibilityChange))
onBeforeUnmount(() => {
disposed = true
document.removeEventListener('visibilitychange', onVisibilityChange)
for (const id of actionStates.keys()) clearActionState(id)
})
</script>

<template>
Expand All @@ -75,14 +113,14 @@ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibil
<!--
Two layout variants — selected purely from data, no CSS hacks needed:

single : no description, action optional → flat row, everything center-aligned
rich : has description → icon+× pin to first line (flex-start);
body is a column: title → desc → action?
single : no description/text action → flat row, everything center-aligned
rich : description or text action → icon+× pin to first line (flex-start);
text action sits below × in the right column
-->
<li
v-for="t in ordered"
:key="t.id"
:class="cn('memoh-toast', t.description ? 'memoh-toast--rich' : 'memoh-toast--single')"
:class="cn('memoh-toast', t.description || t.textAction ? 'memoh-toast--rich' : 'memoh-toast--single')"
:data-variant="t.variant"
role="status"
aria-live="polite"
Expand All @@ -96,7 +134,7 @@ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibil
Everything in one flat row, vertically centered.
icon · title · (action) · ×
──────────────────────────────────────────────────────────────────────── -->
<template v-if="!t.description">
<template v-if="!t.description && !t.textAction">
<component
:is="semanticIcon[t.variant]"
v-if="semanticIcon[t.variant]"
Expand Down Expand Up @@ -124,11 +162,11 @@ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibil
</Button>
</template>

<!-- ── RICH (has description) ─────────────────────────────────────────
<!-- ── RICH (has description or text action) ──────────────────────────
icon and × pin to the top (first-line height).
Action always goes BELOW the desc — never inline with the title.
icon · title · ×
desc
desc / ID · (copy)
(action)
──────────────────────────────────────────────────────────────────────── -->
<template v-else>
Expand All @@ -140,9 +178,28 @@ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibil
/>
<div class="memoh-toast__body">
<span class="memoh-toast__title">{{ headingFor(t) }}</span>
<p class="memoh-toast__desc">
<p
v-if="t.description"
class="memoh-toast__desc"
>
{{ t.description }}
</p>
<div
v-if="t.textAction"
class="min-w-0 text-body text-muted-foreground"
>
<span class="block min-w-0 select-text break-all whitespace-normal">{{ t.textAction.label }}</span>
<span
v-if="actionPhase(t) === 'success'"
role="status"
class="sr-only"
>{{ t.textAction.successLabel }}</span>
<span
v-else-if="actionPhase(t) === 'failure'"
role="status"
class="text-body"
>{{ t.textAction.failureLabel }}</span>
</div>
<Button
v-if="t.action"
variant="outline"
Expand All @@ -153,15 +210,41 @@ onBeforeUnmount(() => document.removeEventListener('visibilitychange', onVisibil
{{ t.action.label }}
</Button>
</div>
<Button
variant="ghost"
size="icon-sm"
class="memoh-toast__close memoh-toast__close--rich"
aria-label="Dismiss notification"
@click="dismiss(t.id)"
>
<XIcon class="size-4" />
</Button>
<div class="flex shrink-0 flex-col items-center">
<Button
variant="ghost"
size="icon-sm"
class="memoh-toast__close memoh-toast__close--rich"
aria-label="Dismiss notification"
@click="dismiss(t.id)"
>
<XIcon class="size-4" />
</Button>
<Button
v-if="t.textAction"
type="button"
variant="ghost"
tone="muted"
size="icon-sm"
:aria-label="t.textAction.ariaLabel"
:title="t.textAction.ariaLabel"
:aria-busy="actionPhase(t) === 'pending' || undefined"
@click="runTextAction(t)"
>
<CheckIcon
v-if="actionPhase(t) === 'success'"
class="size-[18px]"
:stroke-width="1.75"
aria-hidden="true"
/>
<CopyConnectedIcon
v-else
class="size-[18px] -scale-x-100"
:stroke-width="1.75"
aria-hidden="true"
/>
</Button>
</div>
</template>
</li>
</TransitionGroup>
Expand Down
13 changes: 13 additions & 0 deletions src/components/sonner/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,24 @@ export interface ToastAction {
onClick: () => void
}

export interface ToastTextAction {
label: string
ariaLabel?: string
/** A local action leaves the notification open and reports its own result. */
onClick: () => boolean | Promise<boolean>
successLabel: string
failureLabel: string
}

export interface ToastOptions {
/** Secondary line under the title. */
description?: string
/** Auto-dismiss delay in ms. `0`/`Infinity` keeps it until dismissed. */
duration?: number
/** Optional trailing action button. */
action?: ToastAction
/** Low-emphasis action under the message, independent of the trailing action. */
textAction?: ToastTextAction
/** Stable id — re-using one updates the existing toast in place. */
id?: string | number
}
Expand All @@ -32,6 +43,7 @@ export interface ToastRecord {
title: string
description?: string
action?: ToastAction
textAction?: ToastTextAction
/** True when `title` is a synthesized variant heading (the long-blob auto-shape
* below), NOT caller copy. `<Toaster>` swaps it for a localized label when one
* is supplied via the `headings` prop; the English `title` here is the fallback. */
Expand Down Expand Up @@ -157,6 +169,7 @@ function create(variant: ToastVariant, message: string, options?: ToastOptions):
title,
description,
action: options?.action,
textAction: options?.textAction,
autoHeading,
}

Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export { useClipboard } from './lib/clipboard'
export * from './components/confirm-delete-dialog/index'
export * from './components/confirm-popover/index'
export * from './components/context-menu/index'
export * from './components/copy-connected-icon/index'
export * from './components/date-range-picker/index'
export * from './components/device-code-panel/index'
export * from './components/dialog/index'
Expand Down
Loading