@leavepulse/ui 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +61 -0
- package/dist/component-names.d.ts +1 -0
- package/dist/components/LayoutCanvas.vue.d.ts +28 -0
- package/dist/components/LayoutNode.vue.d.ts +40 -0
- package/dist/components/LpAlert.vue.d.ts +44 -0
- package/dist/components/LpAppShell.vue.d.ts +83 -0
- package/dist/components/LpAutocomplete.vue.d.ts +50 -0
- package/dist/components/LpAvatar.vue.d.ts +20 -0
- package/dist/components/LpBadge.vue.d.ts +47 -0
- package/dist/components/LpBreadcrumbs.vue.d.ts +14 -0
- package/dist/components/LpButton.vue.d.ts +113 -0
- package/dist/components/LpCalendar.vue.d.ts +20 -0
- package/dist/components/LpCard.vue.d.ts +29 -0
- package/dist/components/LpCheckbox.vue.d.ts +28 -0
- package/dist/components/LpCodeBlock.vue.d.ts +39 -0
- package/dist/components/LpCommandPalette.vue.d.ts +42 -0
- package/dist/components/LpConfirmDialog.vue.d.ts +32 -0
- package/dist/components/LpContextMenu.vue.d.ts +30 -0
- package/dist/components/LpDatePicker.vue.d.ts +25 -0
- package/dist/components/LpDisclosure.vue.d.ts +37 -0
- package/dist/components/LpDivider.vue.d.ts +17 -0
- package/dist/components/LpDrawer.vue.d.ts +88 -0
- package/dist/components/LpDropdownMenu.vue.d.ts +23 -0
- package/dist/components/LpEmptyState.vue.d.ts +18 -0
- package/dist/components/LpFormField.vue.d.ts +21 -0
- package/dist/components/LpIcon.vue.d.ts +8 -0
- package/dist/components/LpInfraNode.vue.d.ts +21 -0
- package/dist/components/LpInput.vue.d.ts +68 -0
- package/dist/components/LpLaneNode.vue.d.ts +24 -0
- package/dist/components/LpLink.vue.d.ts +27 -0
- package/dist/components/LpLogViewer.vue.d.ts +92 -0
- package/dist/components/LpModal.vue.d.ts +43 -0
- package/dist/components/LpNotificationBell.vue.d.ts +51 -0
- package/dist/components/LpNumberField.vue.d.ts +14 -0
- package/dist/components/LpOtpInput.vue.d.ts +27 -0
- package/dist/components/LpPagination.vue.d.ts +25 -0
- package/dist/components/LpPasswordInput.vue.d.ts +28 -0
- package/dist/components/LpPhoneInput.vue.d.ts +37 -0
- package/dist/components/LpPopover.vue.d.ts +34 -0
- package/dist/components/LpProgress.vue.d.ts +12 -0
- package/dist/components/LpRadio.vue.d.ts +18 -0
- package/dist/components/LpRadioGroup.vue.d.ts +26 -0
- package/dist/components/LpScrollArea.vue.d.ts +24 -0
- package/dist/components/LpSegmented.vue.d.ts +24 -0
- package/dist/components/LpSelect.vue.d.ts +45 -0
- package/dist/components/LpServiceNode.vue.d.ts +14 -0
- package/dist/components/LpSidebar.vue.d.ts +85 -0
- package/dist/components/LpSidebarNav.vue.d.ts +34 -0
- package/dist/components/LpSkeleton.vue.d.ts +6 -0
- package/dist/components/LpSlider.vue.d.ts +23 -0
- package/dist/components/LpStat.vue.d.ts +22 -0
- package/dist/components/LpStepper.vue.d.ts +10 -0
- package/dist/components/LpSwitch.vue.d.ts +11 -0
- package/dist/components/LpTable.vue.d.ts +63 -0
- package/dist/components/LpTableOfContents.vue.d.ts +19 -0
- package/dist/components/LpTabs.vue.d.ts +45 -0
- package/dist/components/LpTextarea.vue.d.ts +17 -0
- package/dist/components/LpThemeSwitcher.vue.d.ts +40 -0
- package/dist/components/LpToaster.vue.d.ts +3 -0
- package/dist/components/LpTooltip.vue.d.ts +19 -0
- package/dist/components/LpTopologyCanvas.vue.d.ts +154 -0
- package/dist/components/LpUptimeBar.vue.d.ts +30 -0
- package/dist/components/codeHighlight.d.ts +8 -0
- package/dist/components/countries-data.d.ts +2 -0
- package/dist/components/countries.d.ts +20 -0
- package/dist/components/dropdown.d.ts +29 -0
- package/dist/components/sidebar.d.ts +18 -0
- package/dist/composables/useInputFilter.d.ts +14 -0
- package/dist/composables/usePillTransition.d.ts +9 -0
- package/dist/composables/useToast.d.ts +39 -0
- package/dist/countries-data-9rgqJWHo.js +203 -0
- package/dist/icons.d.ts +1 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +100 -0
- package/dist/index.js +17600 -0
- package/dist/layout/registry.d.ts +26 -0
- package/dist/layout/tree.d.ts +75 -0
- package/dist/layout/useLayout.d.ts +39 -0
- package/dist/theme/config.d.ts +16 -0
- package/dist/theme/define.d.ts +12 -0
- package/dist/theme/presets.d.ts +20 -0
- package/dist/theme/tokens.d.ts +72 -0
- package/dist/theme/useTheme.d.ts +36 -0
- package/package.json +93 -0
- package/src/canvas.css +67 -0
- package/src/component-names.ts +65 -0
- package/src/components/LayoutCanvas.vue +76 -0
- package/src/components/LayoutNode.vue +248 -0
- package/src/components/LpAlert.vue +59 -0
- package/src/components/LpAppShell.vue +210 -0
- package/src/components/LpAutocomplete.vue +244 -0
- package/src/components/LpAvatar.vue +67 -0
- package/src/components/LpBadge.vue +32 -0
- package/src/components/LpBreadcrumbs.vue +35 -0
- package/src/components/LpButton.vue +111 -0
- package/src/components/LpCalendar.vue +134 -0
- package/src/components/LpCard.vue +71 -0
- package/src/components/LpCheckbox.vue +44 -0
- package/src/components/LpCodeBlock.vue +298 -0
- package/src/components/LpCommandPalette.vue +323 -0
- package/src/components/LpConfirmDialog.vue +49 -0
- package/src/components/LpContextMenu.vue +111 -0
- package/src/components/LpDatePicker.vue +117 -0
- package/src/components/LpDisclosure.vue +92 -0
- package/src/components/LpDivider.vue +20 -0
- package/src/components/LpDrawer.vue +402 -0
- package/src/components/LpDropdownMenu.vue +54 -0
- package/src/components/LpEmptyState.vue +21 -0
- package/src/components/LpFormField.vue +31 -0
- package/src/components/LpIcon.vue +49 -0
- package/src/components/LpInfraNode.vue +108 -0
- package/src/components/LpInput.vue +147 -0
- package/src/components/LpLaneNode.vue +48 -0
- package/src/components/LpLink.vue +48 -0
- package/src/components/LpLogViewer.vue +569 -0
- package/src/components/LpModal.vue +123 -0
- package/src/components/LpNotificationBell.vue +238 -0
- package/src/components/LpNumberField.vue +56 -0
- package/src/components/LpOtpInput.vue +81 -0
- package/src/components/LpPagination.vue +165 -0
- package/src/components/LpPasswordInput.vue +50 -0
- package/src/components/LpPhoneInput.vue +241 -0
- package/src/components/LpPopover.vue +42 -0
- package/src/components/LpProgress.vue +30 -0
- package/src/components/LpRadio.vue +28 -0
- package/src/components/LpRadioGroup.vue +43 -0
- package/src/components/LpScrollArea.vue +82 -0
- package/src/components/LpSegmented.vue +83 -0
- package/src/components/LpSelect.vue +0 -0
- package/src/components/LpServiceNode.vue +57 -0
- package/src/components/LpSidebar.vue +200 -0
- package/src/components/LpSidebarNav.vue +118 -0
- package/src/components/LpSkeleton.vue +16 -0
- package/src/components/LpSlider.vue +53 -0
- package/src/components/LpStat.vue +90 -0
- package/src/components/LpStepper.vue +46 -0
- package/src/components/LpSwitch.vue +19 -0
- package/src/components/LpTable.vue +252 -0
- package/src/components/LpTableOfContents.vue +183 -0
- package/src/components/LpTabs.vue +120 -0
- package/src/components/LpTextarea.vue +39 -0
- package/src/components/LpThemeSwitcher.vue +168 -0
- package/src/components/LpToaster.vue +216 -0
- package/src/components/LpTooltip.vue +30 -0
- package/src/components/LpTopologyCanvas.vue +370 -0
- package/src/components/LpUptimeBar.vue +126 -0
- package/src/components/codeHighlight.ts +137 -0
- package/src/components/countries-data.ts +207 -0
- package/src/components/countries.ts +67 -0
- package/src/components/dropdown.ts +64 -0
- package/src/components/sidebar.ts +26 -0
- package/src/composables/useInputFilter.ts +89 -0
- package/src/composables/usePillTransition.ts +19 -0
- package/src/composables/useToast.ts +92 -0
- package/src/icons.ts +11 -0
- package/src/index.css +21 -0
- package/src/index.ts +146 -0
- package/src/layout/registry.ts +44 -0
- package/src/layout/tree.ts +288 -0
- package/src/layout/useLayout.ts +134 -0
- package/src/nuxt.ts +22 -0
- package/src/theme/config.ts +48 -0
- package/src/theme/define.ts +50 -0
- package/src/theme/presets.ts +279 -0
- package/src/theme/tokens.ts +151 -0
- package/src/theme/useTheme.ts +288 -0
- package/src/tokens/scrollbar.css +138 -0
- package/src/tokens/tokens.css +333 -0
- package/src/tokens/utilities.css +23 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Theme switcher. The kit convention for theming, so consumers stop hand-rolling
|
|
4
|
+
* their own. Default behaviour mirrors LeaveHosting: LEFT-click cycles to the
|
|
5
|
+
* next theme in the list (with the circular-reveal transition from the click
|
|
6
|
+
* point); RIGHT-click opens a menu to pick any theme directly. Fully data-driven
|
|
7
|
+
* and configurable — pass your own `themes`, bind `v-model` to the active name,
|
|
8
|
+
* and pick how the trigger looks.
|
|
9
|
+
*
|
|
10
|
+
* Applying + caching goes through useTheme() so every kit component re-skins live
|
|
11
|
+
* and the choice survives a reload (anti-flash bootstrap). The component never
|
|
12
|
+
* fetches; it just drives the theme engine.
|
|
13
|
+
*/
|
|
14
|
+
import { computed, onMounted } from "vue"
|
|
15
|
+
import { presets } from "../theme/presets"
|
|
16
|
+
import type { TokenSet } from "../theme/tokens"
|
|
17
|
+
import { useTheme } from "../theme/useTheme"
|
|
18
|
+
import LpContextMenu, { type ContextMenuItemDef } from "./LpContextMenu.vue"
|
|
19
|
+
import LpIcon from "./LpIcon.vue"
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
/** Active theme NAME (v-model). Falls back to the first theme. */
|
|
24
|
+
modelValue?: string
|
|
25
|
+
/** Themes to offer. Defaults to all built-in presets. */
|
|
26
|
+
themes?: TokenSet[]
|
|
27
|
+
/**
|
|
28
|
+
* Trigger style:
|
|
29
|
+
* - "swatch" (default): a square showing the active theme's brand colour.
|
|
30
|
+
* - "icon": a sun/moon icon following the active theme's mode.
|
|
31
|
+
* - "pill": swatch + the theme name in a pill (always shows the label).
|
|
32
|
+
*/
|
|
33
|
+
variant?: "swatch" | "icon" | "pill"
|
|
34
|
+
/** Show the active theme's name next to the trigger (forced on for "pill"). */
|
|
35
|
+
showLabel?: boolean
|
|
36
|
+
/**
|
|
37
|
+
* Default theme applied on mount when nothing is cached yet — the theme NAME
|
|
38
|
+
* (must be in `themes`) or a TokenSet. Lets a consuming app pick its default
|
|
39
|
+
* declaratively (<LpThemeSwitcher :default="'Violet'">) with no separate
|
|
40
|
+
* bootstrap plugin; a previously saved choice still wins over it.
|
|
41
|
+
*/
|
|
42
|
+
default?: string | TokenSet
|
|
43
|
+
/** Animate theme changes with the circular reveal. On by default. */
|
|
44
|
+
transition?: boolean
|
|
45
|
+
size?: "sm" | "md" | "lg"
|
|
46
|
+
"aria-label"?: string
|
|
47
|
+
}>(),
|
|
48
|
+
{
|
|
49
|
+
variant: "swatch",
|
|
50
|
+
transition: true,
|
|
51
|
+
size: "md",
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const emit = defineEmits<{
|
|
56
|
+
(e: "update:modelValue", name: string): void
|
|
57
|
+
(e: "change", theme: TokenSet): void
|
|
58
|
+
}>()
|
|
59
|
+
|
|
60
|
+
const theme = useTheme()
|
|
61
|
+
|
|
62
|
+
const list = computed<TokenSet[]>(() =>
|
|
63
|
+
props.themes && props.themes.length ? props.themes : Object.values(presets),
|
|
64
|
+
)
|
|
65
|
+
// Active theme name: an explicit modelValue wins (controlled use); otherwise the
|
|
66
|
+
// kit's self-managed `currentName` (so a consumer needs NO ref/bootstrap — the
|
|
67
|
+
// kit remembers the theme like an i18n locale). Falls back to the first theme.
|
|
68
|
+
const activeName = computed(
|
|
69
|
+
() => props.modelValue ?? (theme.currentName.value || list.value[0]?.name || ""),
|
|
70
|
+
)
|
|
71
|
+
const active = computed<TokenSet | undefined>(
|
|
72
|
+
() => list.value.find((t) => t.name === activeName.value) ?? list.value[0],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Apply the consumer's default on mount when nothing is cached yet. init()
|
|
76
|
+
// hydrates from the cache first (a saved choice wins), so this only takes effect
|
|
77
|
+
// on a first visit / cleared storage. Resolving a name goes through the offered
|
|
78
|
+
// list, so <LpThemeSwitcher :default="'Violet'"> just works.
|
|
79
|
+
onMounted(() => {
|
|
80
|
+
if (props.default == null) return
|
|
81
|
+
const fallback =
|
|
82
|
+
typeof props.default === "string"
|
|
83
|
+
? list.value.find((t) => t.name === props.default)
|
|
84
|
+
: props.default
|
|
85
|
+
if (fallback) theme.init(fallback)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const SIZES = { sm: "size-7", md: "size-8", lg: "size-9" } as const
|
|
89
|
+
const ICON_SIZE = { sm: 15, md: 16, lg: 18 } as const
|
|
90
|
+
|
|
91
|
+
const wantsLabel = computed(() => props.showLabel || props.variant === "pill")
|
|
92
|
+
|
|
93
|
+
// Apply a theme through the kit's self-managed setter (it persists + updates
|
|
94
|
+
// the shared `current`), then notify. The reveal animates from the click point.
|
|
95
|
+
function applyTheme(t: TokenSet, ev?: MouseEvent) {
|
|
96
|
+
if (props.transition) {
|
|
97
|
+
theme.setTheme(t, ev ? { x: ev.clientX, y: ev.clientY } : undefined)
|
|
98
|
+
} else {
|
|
99
|
+
theme.apply(t)
|
|
100
|
+
}
|
|
101
|
+
emit("update:modelValue", t.name)
|
|
102
|
+
emit("change", t)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Left-click → next theme in the list (wraps), revealing from the click point.
|
|
106
|
+
function cycle(ev: MouseEvent) {
|
|
107
|
+
const arr = list.value
|
|
108
|
+
if (!arr.length) return
|
|
109
|
+
const i = arr.findIndex((t) => t.name === activeName.value)
|
|
110
|
+
const next = arr[(i + 1) % arr.length]
|
|
111
|
+
if (next) applyTheme(next, ev)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Right-click menu: every theme, the active one checked. A circular-reveal from
|
|
115
|
+
// the (saved) click point isn't available here, so the menu picks reveal from
|
|
116
|
+
// the trigger centre via applyWithTransition's default origin.
|
|
117
|
+
const menu = computed<ContextMenuItemDef[]>(() =>
|
|
118
|
+
list.value.map((t) => ({
|
|
119
|
+
label: t.name,
|
|
120
|
+
icon:
|
|
121
|
+
t.name === activeName.value
|
|
122
|
+
? "lucide:check"
|
|
123
|
+
: t.mode === "dark"
|
|
124
|
+
? "lucide:moon"
|
|
125
|
+
: "lucide:sun",
|
|
126
|
+
onSelect: () => applyTheme(t),
|
|
127
|
+
})),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const triggerLabel = computed(
|
|
131
|
+
() => props["aria-label"] ?? `Theme: ${activeName.value}. Click to switch, right-click to choose.`,
|
|
132
|
+
)
|
|
133
|
+
</script>
|
|
134
|
+
|
|
135
|
+
<template>
|
|
136
|
+
<LpContextMenu :items="menu">
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
:aria-label="triggerLabel"
|
|
140
|
+
:title="triggerLabel"
|
|
141
|
+
class="inline-flex items-center gap-2 rounded-control border border-line bg-surface-raised px-1.5 text-ink outline-none transition-colors hover:border-line-strong focus-visible:ring-2 focus-visible:ring-ring"
|
|
142
|
+
:class="wantsLabel ? 'py-1 pr-2.5' : ''"
|
|
143
|
+
@click="cycle"
|
|
144
|
+
>
|
|
145
|
+
<!-- swatch: brand over surface, hinting the theme at a glance -->
|
|
146
|
+
<span
|
|
147
|
+
v-if="variant !== 'icon'"
|
|
148
|
+
class="grid shrink-0 place-items-center rounded-control border border-line/60"
|
|
149
|
+
:class="SIZES[size]"
|
|
150
|
+
:style="{ background: active?.colors.surface }"
|
|
151
|
+
>
|
|
152
|
+
<span
|
|
153
|
+
class="size-3 rounded-full"
|
|
154
|
+
:style="{ background: active?.colors.brand }"
|
|
155
|
+
/>
|
|
156
|
+
</span>
|
|
157
|
+
<!-- icon: sun/moon by mode -->
|
|
158
|
+
<span v-else class="grid shrink-0 place-items-center" :class="SIZES[size]">
|
|
159
|
+
<LpIcon
|
|
160
|
+
:name="active?.mode === 'dark' ? 'lucide:moon' : 'lucide:sun'"
|
|
161
|
+
:size="ICON_SIZE[size]"
|
|
162
|
+
/>
|
|
163
|
+
</span>
|
|
164
|
+
|
|
165
|
+
<span v-if="wantsLabel" class="text-sm font-medium">{{ activeName }}</span>
|
|
166
|
+
</button>
|
|
167
|
+
</LpContextMenu>
|
|
168
|
+
</template>
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Mount once near the app root. Renders the shared toast queue on the top
|
|
4
|
+
* z-layer (--z-toast) — above modals on purpose. Minimalist look: icon + text
|
|
5
|
+
* + a countdown progress bar. Colors come from tokens, so it follows the theme.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
ToastDescription,
|
|
9
|
+
ToastProvider,
|
|
10
|
+
ToastRoot,
|
|
11
|
+
ToastTitle,
|
|
12
|
+
ToastViewport,
|
|
13
|
+
} from "reka-ui"
|
|
14
|
+
import { onBeforeUnmount, ref, watch } from "vue"
|
|
15
|
+
import { useToast, type ToastItem } from "../composables/useToast"
|
|
16
|
+
import { CLOSE_ICON } from "./dropdown"
|
|
17
|
+
import LpIcon from "./LpIcon.vue"
|
|
18
|
+
|
|
19
|
+
const { queue, dismiss } = useToast()
|
|
20
|
+
|
|
21
|
+
// Toasts that are playing their exit animation. We don't splice them out of the
|
|
22
|
+
// queue immediately on close — that would unmount before `toast-out` can run.
|
|
23
|
+
// Instead we flip `open` to false (reka sets data-state=closed → the keyframe
|
|
24
|
+
// plays), then drop the toast for real once `animationend` fires. A safety
|
|
25
|
+
// timer removes it even if the animationend event is missed (e.g. tab hidden).
|
|
26
|
+
const closing = ref<Set<number>>(new Set())
|
|
27
|
+
|
|
28
|
+
function beginClose(id: number) {
|
|
29
|
+
if (closing.value.has(id)) return
|
|
30
|
+
closing.value = new Set(closing.value).add(id)
|
|
31
|
+
// Fallback slightly longer than the 140ms toast-out duration.
|
|
32
|
+
window.setTimeout(() => finishClose(id), 220)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function finishClose(id: number) {
|
|
36
|
+
if (!closing.value.has(id)) return
|
|
37
|
+
const next = new Set(closing.value)
|
|
38
|
+
next.delete(id)
|
|
39
|
+
closing.value = next
|
|
40
|
+
dismiss(id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function onAnimEnd(e: AnimationEvent, id: number) {
|
|
44
|
+
// Only the exit keyframe should trigger removal, not toast-in.
|
|
45
|
+
if (e.animationName === "toast-out") finishClose(id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const variantIcon: Record<ToastItem["variant"], string> = {
|
|
49
|
+
info: "lucide:info",
|
|
50
|
+
success: "lucide:circle-check",
|
|
51
|
+
warning: "lucide:triangle-alert",
|
|
52
|
+
error: "lucide:circle-x",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One token color drives icon + progress per variant.
|
|
56
|
+
const variantColor: Record<ToastItem["variant"], string> = {
|
|
57
|
+
info: "text-brand",
|
|
58
|
+
success: "text-action",
|
|
59
|
+
warning: "text-accent",
|
|
60
|
+
error: "text-danger",
|
|
61
|
+
}
|
|
62
|
+
const variantBar: Record<ToastItem["variant"], string> = {
|
|
63
|
+
info: "bg-brand",
|
|
64
|
+
success: "bg-action",
|
|
65
|
+
warning: "bg-accent",
|
|
66
|
+
error: "bg-danger",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// reka pauses its own auto-dismiss timer while a toast is hovered/focused; mirror
|
|
70
|
+
// that on the visual bar by carrying each toast's dismissAt forward over the
|
|
71
|
+
// paused span, so the countdown matches when it actually closes.
|
|
72
|
+
const pausedAt = new Map<number, number>()
|
|
73
|
+
|
|
74
|
+
// Ticking clock so the progress bar animates smoothly. Runs only while there's
|
|
75
|
+
// at least one auto-dismissing toast on screen — no idle interval otherwise.
|
|
76
|
+
const now = ref(Date.now())
|
|
77
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
78
|
+
|
|
79
|
+
function startTimer() {
|
|
80
|
+
if (timer === null) timer = setInterval(() => (now.value = Date.now()), 80)
|
|
81
|
+
}
|
|
82
|
+
function stopTimer() {
|
|
83
|
+
if (timer !== null) {
|
|
84
|
+
clearInterval(timer)
|
|
85
|
+
timer = null
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
watch(
|
|
90
|
+
() => queue.length,
|
|
91
|
+
() => {
|
|
92
|
+
// Drop pause state for toasts that have left the queue (e.g. dismissed
|
|
93
|
+
// while hovered) so the map can't grow unbounded over a long session.
|
|
94
|
+
for (const id of pausedAt.keys()) {
|
|
95
|
+
if (!queue.some((t) => t.id === id)) pausedAt.delete(id)
|
|
96
|
+
}
|
|
97
|
+
// Same for the closing set — a toast evicted by the queue cap or clear()
|
|
98
|
+
// leaves without an animationend, so prune dead ids here.
|
|
99
|
+
if (closing.value.size) {
|
|
100
|
+
const live = new Set([...closing.value].filter((id) => queue.some((t) => t.id === id)))
|
|
101
|
+
if (live.size !== closing.value.size) closing.value = live
|
|
102
|
+
}
|
|
103
|
+
if (queue.some((t) => t.dismissAt !== null)) startTimer()
|
|
104
|
+
else stopTimer()
|
|
105
|
+
},
|
|
106
|
+
{ immediate: true },
|
|
107
|
+
)
|
|
108
|
+
onBeforeUnmount(stopTimer)
|
|
109
|
+
|
|
110
|
+
function onPause(t: ToastItem) {
|
|
111
|
+
pausedAt.set(t.id, now.value)
|
|
112
|
+
}
|
|
113
|
+
function onResume(t: ToastItem) {
|
|
114
|
+
const since = pausedAt.get(t.id)
|
|
115
|
+
if (since !== undefined && t.dismissAt !== null) {
|
|
116
|
+
t.dismissAt += Date.now() - since
|
|
117
|
+
}
|
|
118
|
+
pausedAt.delete(t.id)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function progress(t: ToastItem): number {
|
|
122
|
+
if (!t.dismissAt || t.duration <= 0) return 0
|
|
123
|
+
// While paused, freeze the bar at the moment hover started.
|
|
124
|
+
const at = pausedAt.get(t.id) ?? now.value
|
|
125
|
+
const remaining = t.dismissAt - at
|
|
126
|
+
return Math.max(0, Math.min(100, (remaining / t.duration) * 100))
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function runClick(t: ToastItem) {
|
|
130
|
+
if (t.onClick) {
|
|
131
|
+
await t.onClick()
|
|
132
|
+
beginClose(t.id)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runAction(t: ToastItem, action: NonNullable<ToastItem["actions"]>[number]) {
|
|
137
|
+
await action.onClick()
|
|
138
|
+
beginClose(t.id)
|
|
139
|
+
}
|
|
140
|
+
</script>
|
|
141
|
+
|
|
142
|
+
<template>
|
|
143
|
+
<ToastProvider>
|
|
144
|
+
<ToastRoot
|
|
145
|
+
v-for="t in queue"
|
|
146
|
+
:key="t.id"
|
|
147
|
+
:open="!closing.has(t.id)"
|
|
148
|
+
:duration="t.duration"
|
|
149
|
+
class="pointer-events-auto relative overflow-hidden rounded-card border border-line bg-surface-raised shadow-panel data-[state=open]:animate-[toast-in_200ms_var(--ease-emphasized)] data-[state=closed]:animate-[toast-out_140ms_ease]"
|
|
150
|
+
:class="{ 'cursor-pointer': t.onClick }"
|
|
151
|
+
@update:open="(open) => !open && beginClose(t.id)"
|
|
152
|
+
@animationend="(e: AnimationEvent) => onAnimEnd(e, t.id)"
|
|
153
|
+
@pause="onPause(t)"
|
|
154
|
+
@resume="onResume(t)"
|
|
155
|
+
@click="runClick(t)"
|
|
156
|
+
>
|
|
157
|
+
<div class="flex items-start gap-3 p-3.5">
|
|
158
|
+
<LpIcon
|
|
159
|
+
:name="variantIcon[t.variant]"
|
|
160
|
+
:size="18"
|
|
161
|
+
class="mt-0.5 shrink-0"
|
|
162
|
+
:class="variantColor[t.variant]"
|
|
163
|
+
/>
|
|
164
|
+
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
165
|
+
<ToastTitle v-if="t.title" class="text-sm font-semibold text-ink">
|
|
166
|
+
{{ t.title }}
|
|
167
|
+
</ToastTitle>
|
|
168
|
+
<ToastDescription class="break-words text-xs text-muted">
|
|
169
|
+
{{ t.message }}
|
|
170
|
+
</ToastDescription>
|
|
171
|
+
|
|
172
|
+
<div
|
|
173
|
+
v-if="t.actions?.length"
|
|
174
|
+
class="mt-2 flex flex-wrap gap-2"
|
|
175
|
+
@click.stop
|
|
176
|
+
>
|
|
177
|
+
<button
|
|
178
|
+
v-for="(action, i) in t.actions"
|
|
179
|
+
:key="i"
|
|
180
|
+
type="button"
|
|
181
|
+
class="rounded-control border border-line bg-surface-soft px-2.5 py-1 text-xs font-medium text-ink outline-none transition-[border-color,background-color,scale] duration-[var(--duration-fast)] ease-[var(--ease-emphasized)] hover:border-line-strong hover:bg-surface-soft/70 active:scale-95 motion-reduce:active:scale-100 focus-visible:ring-2 focus-visible:ring-ring"
|
|
182
|
+
@click="runAction(t, action)"
|
|
183
|
+
>
|
|
184
|
+
{{ action.label }}
|
|
185
|
+
</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
class="group -mr-1 -mt-1 rounded-md p-1 text-muted outline-none transition-colors duration-[var(--duration-fast)] hover:text-ink"
|
|
192
|
+
aria-label="Dismiss"
|
|
193
|
+
@click.stop="beginClose(t.id)"
|
|
194
|
+
>
|
|
195
|
+
<LpIcon
|
|
196
|
+
name="lucide:x"
|
|
197
|
+
:size="16"
|
|
198
|
+
:class="CLOSE_ICON"
|
|
199
|
+
/>
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<!-- Countdown progress bar -->
|
|
204
|
+
<div
|
|
205
|
+
v-if="t.dismissAt"
|
|
206
|
+
class="absolute inset-x-0 bottom-0 h-0.5 origin-left transition-transform duration-100 ease-linear motion-reduce:transition-none"
|
|
207
|
+
:class="variantBar[t.variant]"
|
|
208
|
+
:style="{ transform: `scaleX(${progress(t) / 100})` }"
|
|
209
|
+
/>
|
|
210
|
+
</ToastRoot>
|
|
211
|
+
|
|
212
|
+
<ToastViewport
|
|
213
|
+
class="fixed bottom-0 right-0 z-(--z-toast) flex w-[min(92vw,22rem)] flex-col gap-2 p-4 outline-none"
|
|
214
|
+
/>
|
|
215
|
+
</ToastProvider>
|
|
216
|
+
</template>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
TooltipContent,
|
|
4
|
+
TooltipPortal,
|
|
5
|
+
TooltipProvider,
|
|
6
|
+
TooltipRoot,
|
|
7
|
+
TooltipTrigger,
|
|
8
|
+
} from "reka-ui"
|
|
9
|
+
import { PANEL_SURFACE, TOOLTIP_ANIM } from "./dropdown"
|
|
10
|
+
|
|
11
|
+
defineProps<{ content?: string; sideOffset?: number }>()
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<TooltipProvider :delay-duration="200">
|
|
16
|
+
<TooltipRoot>
|
|
17
|
+
<TooltipTrigger as-child>
|
|
18
|
+
<slot />
|
|
19
|
+
</TooltipTrigger>
|
|
20
|
+
<TooltipPortal>
|
|
21
|
+
<TooltipContent
|
|
22
|
+
:side-offset="sideOffset ?? 6"
|
|
23
|
+
:class="[PANEL_SURFACE, TOOLTIP_ANIM, 'z-(--z-tooltip) px-2.5 py-1.5 text-xs text-ink']"
|
|
24
|
+
>
|
|
25
|
+
<slot name="content">{{ content }}</slot>
|
|
26
|
+
</TooltipContent>
|
|
27
|
+
</TooltipPortal>
|
|
28
|
+
</TooltipRoot>
|
|
29
|
+
</TooltipProvider>
|
|
30
|
+
</template>
|