Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
67 commits
Select commit Hold shift + click to select a range
006be59
feat(createNotifications): add notification management composable
johnleider Mar 5, 2026
6bcb2c2
feat(createNotifications): add tests
johnleider Mar 5, 2026
540912c
docs(createNotifications): add documentation page
johnleider Mar 5, 2026
a112547
feat(createNotifications): add FCM, OneSignal, and Knock adapters
johnleider Mar 5, 2026
5e634c0
refactor(createNotifications): remove action types
johnleider Mar 5, 2026
7f31953
refactor(createNotifications): rename to useNotifications directory
johnleider Mar 5, 2026
8146c15
fix(useNotifications): address inspection findings
johnleider Mar 5, 2026
d575149
feat(useNotifications): add derived collection computed properties
johnleider Mar 6, 2026
53f2c4a
refactor(useNotifications): align with v0 adapter and batch patterns
johnleider Mar 6, 2026
fc53059
docs(useNotifications): add notification inbox example
johnleider Mar 6, 2026
23d65d4
refactor(useNotifications): replace manual sync with useProxyRegistry
johnleider Mar 6, 2026
f8a5958
fix(useNotifications): address inspection findings
johnleider Mar 12, 2026
cdf5631
docs: add snackbar component design spec
johnleider Mar 12, 2026
0ece7c2
refactor(useNotifications): rename unsnooze to wake
johnleider Mar 12, 2026
03523dd
docs: add snackbar component implementation plan
johnleider Mar 12, 2026
1de352e
feat(Snackbar): add headless snackbar compound component
johnleider Mar 12, 2026
28e8f4f
feat(Snackbar): add component tests
johnleider Mar 12, 2026
575043f
docs(Snackbar): add documentation page and example
johnleider Mar 12, 2026
3e81247
fix(Snackbar): unselect stack ticket on portal unmount
johnleider Mar 12, 2026
f3dde13
docs(useNotifications): reorder adapter tabs and remove state mutatio…
johnleider Mar 12, 2026
a6db28d
refactor(useNotifications): use standalone export functions for API g…
johnleider Mar 12, 2026
3af4f61
feat(createRegistry): add optional event parameter to upsert
johnleider Mar 12, 2026
265970f
refactor(useNotifications): replace createQueue with createRegistry
johnleider Mar 12, 2026
27cc0a3
docs(useNotifications): update example for registry-based implementation
johnleider Mar 12, 2026
2ec171d
refactor(useNotifications): remove FCM/OneSignal adapters, keep Knock…
johnleider Mar 13, 2026
10ed58c
refactor(useNotifications): remove proxy from context, let consumers …
johnleider Mar 13, 2026
7c0f4e6
docs(useNotifications): wire up skillz resume popup via notification bus
johnleider Mar 13, 2026
34e1168
chore: remove tracked docs/ files (now gitignored)
johnleider Mar 13, 2026
64dcc27
feat(useNotifications): add Z/E/R generics matching framework plugin …
johnleider Mar 13, 2026
001b884
Merge remote-tracking branch 'origin/master' into feat/notifications-…
johnleider Mar 16, 2026
dc26101
refactor(useNotifications): use createPluginContext, fix fallback and…
johnleider Mar 16, 2026
1a25bd8
refactor(useNotifications): rename notify to send
johnleider Mar 17, 2026
8f7e57c
Merge remote-tracking branch 'origin/master' into feat/notifications-…
johnleider Mar 17, 2026
131655b
fix(useNotifications): fix readAll/archiveAll tests that only created…
johnleider Mar 17, 2026
cc51d95
feat(useNotifications): integrate createQueue for toast display surface
johnleider Mar 17, 2026
e359f49
docs(useNotifications): fix inaccurate docs for queue integration
johnleider Mar 17, 2026
f25d9a1
feat(useNotifications): add Novu adapter
johnleider Mar 17, 2026
00ab8a6
feat(useNotifications): make Novu severity mapping configurable
johnleider Mar 17, 2026
0dc364e
docs(useNotifications): wire Snackbar to notifications.queue, fix proxy
johnleider Mar 17, 2026
44289f1
feat(Snackbar): stacked toast UI with hover-to-expand and queue integ…
johnleider Mar 18, 2026
4b9ba0e
feat(Snackbar): auto-wire Close via Root context, remove severity/lab…
johnleider Mar 18, 2026
b3e353c
feat(Snackbar): add SnackbarQueue, update barrel and example
johnleider Mar 19, 2026
2f37d87
docs(Snackbar): add queue anatomy, fix stale severity/proxy refs, upd…
johnleider Mar 19, 2026
f3860c7
docs(Snackbar): split usage/queue examples, single anatomy with both …
johnleider Mar 19, 2026
e05eec3
docs(Snackbar): add spacing between anatomy sub-components
johnleider Mar 19, 2026
786d53d
docs(Snackbar): move ARIA role and inline rendering under Recipes
johnleider Mar 19, 2026
df5370a
fix(Snackbar): prevent upward animation when items pushed beyond stac…
johnleider Mar 19, 2026
efcfeb0
fix(Snackbar): fix useId regeneration, remove stale severity prop, cl…
johnleider Mar 19, 2026
447255b
fix(useNotifications,Snackbar): fix memory leak, add event tests, add…
johnleider Mar 19, 2026
12305d1
feat(Snackbar): add SnackbarContent and SnackbarAction sub-components
johnleider Mar 23, 2026
1674c87
Merge remote-tracking branch 'origin/master' into feat/notifications-…
johnleider Mar 23, 2026
ae5f256
fix(Snackbar,useNotifications): dynamic context keys, inspection fixe…
johnleider Mar 23, 2026
a710fb5
fix(Snackbar,useNotifications): seed for historical items, focus paus…
johnleider Mar 23, 2026
d066252
fix(Snackbar,useNotifications): override onboard/register, slotProps.…
johnleider Mar 23, 2026
054d7df
refactor(useNotifications): rename enrich to hydrate
johnleider Mar 23, 2026
9675fa2
docs: detect destructured const exports in API whitelist generator
johnleider Mar 23, 2026
27c20f7
fix(Snackbar,useNotifications): drop seed/Action, Extensible severity…
johnleider Mar 23, 2026
b23730d
docs(Snackbar): clean anatomy — structure only, no props or slot bind…
johnleider Mar 23, 2026
99127e0
fix(Snackbar,useNotifications): Extensible severity, barrel JSDoc, do…
johnleider Mar 23, 2026
d50d50d
chore(useNotifications,Snackbar): fix stale seed comments, widen role…
johnleider Mar 23, 2026
15c200f
fix(Snackbar): move all imports to regular script block in dual-scrip…
johnleider Mar 23, 2026
866b562
fix(Snackbar): remove Close fallback — require Root context like Dial…
johnleider Mar 23, 2026
a1d7e44
chore(Snackbar): remove stale comment from SnackbarRoot
johnleider Mar 23, 2026
c2866e3
Merge remote-tracking branch 'origin/master' into feat/notifications-…
johnleider Mar 23, 2026
9ac18c6
chore: remove unused @mdi/js from playground, add coverage tests
johnleider Mar 23, 2026
c31cc3c
fix(useNotifications): wrap noop tests with expect().not.toThrow()
johnleider Mar 23, 2026
63ed230
test(useNotifications,Snackbar): improve coverage across composable a…
johnleider Mar 23, 2026
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
13 changes: 13 additions & 0 deletions apps/docs/build/generate-api-whitelist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ async function getComposableData (): Promise<{
}
}

// Find destructured const exports (plugin trinities, etc.)
// Matches: export const [createXContext, createXPlugin, useX] =
const destructuredExports = content.matchAll(/export\s+const\s+\[([^\]]+)\]/g)
for (const match of destructuredExports) {
const bindings = match[1].split(',').map(s => s.trim())
for (const binding of bindings) {
if (COMPOSABLE_PATTERN.test(binding)) {
names.add(binding)
toDir[binding] = dir
}
}
}

// Also add the directory name itself (the primary export)
names.add(dir)
toDir[dir] = dir
Expand Down
17 changes: 14 additions & 3 deletions apps/docs/src/components/skillz/SkillzResume.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
// Framework
import { useBreakpoints } from '@vuetify/v0'
import { useBreakpoints, useNotifications, useProxyRegistry } from '@vuetify/v0'

// Composables
import { useAsk } from '@/composables/useAsk'
Expand All @@ -12,13 +12,23 @@
import { toRef } from 'vue'
import { useRoute, useRouter } from 'vue-router'

// Types
import type { NotificationTicket } from '@vuetify/v0'

// Stores
import { useSkillzStore } from '@/stores/skillz'

const store = useSkillzStore()
const route = useRoute()
const router = useRouter()

const notifications = useNotifications()
const proxy = useProxyRegistry<NotificationTicket>(notifications)

const ticket = toRef(() =>
proxy.values.find(t => t.data?.type === 'skillz'),
)

const isSkillzPage = toRef(() => route.path.startsWith('/skillz/'))
const ask = useAsk()
const breakpoints = useBreakpoints()
Expand Down Expand Up @@ -50,18 +60,19 @@
if (pending) {
store.dismiss(pending.tour.id)
}
ticket.value?.dismiss()
}
</script>

<template>
<Teleport to="body">
<Transition name="slide-up">
<div
v-if="store.pendingTour && !isSkillzPage"
v-if="ticket && !isSkillzPage"
class="fixed top-16 inset-x-0 mx-auto w-max z-50 flex items-center gap-3 px-4 py-3 bg-surface border border-divider rounded-xl shadow-xl"
>
<span class="text-sm text-on-surface">
Continue <strong>{{ store.pendingTour.tour.name }}</strong>?
{{ ticket.subject }}
</span>

<div class="flex gap-2">
Expand Down
44 changes: 44 additions & 0 deletions apps/docs/src/examples/components/snackbar/basic.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { shallowRef } from 'vue'
import { Snackbar } from '@vuetify/v0'

const visible = shallowRef(false)

function onShow () {
visible.value = true
}

function onDismiss () {
visible.value = false
}
</script>

<template>
<div class="relative flex items-center justify-center min-h-48 p-6 bg-background rounded-lg border border-divider overflow-hidden">
<button
class="px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium"
@click="onShow"
>
Show Snackbar
</button>

<Snackbar.Portal class="absolute bottom-4 right-4" :teleport="false">
<Snackbar.Root
v-if="visible"
class="flex items-center gap-3 px-4 py-2.5 rounded-lg shadow-lg text-sm bg-surface border border-divider"
role="status"
@dismiss="onDismiss"
>
<Snackbar.Content>
Changes saved successfully
</Snackbar.Content>

<Snackbar.Close class="p-1 -mr-1 opacity-50 hover:opacity-100">
<svg aria-hidden="true" class="w-4 h-4" viewBox="0 0 24 24">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" fill="currentColor" />
</svg>
</Snackbar.Close>
</Snackbar.Root>
</Snackbar.Portal>
</div>
</template>
154 changes: 154 additions & 0 deletions apps/docs/src/examples/components/snackbar/queue.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<script setup lang="ts">
import { computed, shallowRef } from 'vue'
import { Snackbar, useNotifications } from '@vuetify/v0'
import type { NotificationSeverity } from '@vuetify/v0'

const notifications = useNotifications()

const severities: NotificationSeverity[] = ['info', 'success', 'warning', 'error']
const index = shallowRef(0)

const messages: Record<NotificationSeverity, string> = {
info: 'Deployment started for production',
success: 'Changes saved successfully',
warning: 'API rate limit at 80%',
error: 'Build failed — check logs',
}

const classes: Record<NotificationSeverity, string> = {
info: 'bg-info text-on-info',
success: 'bg-success text-on-success',
warning: 'bg-warning text-on-warning',
error: 'bg-error text-on-error',
}

function onShow () {
const severity = severities[index.value % severities.length]
index.value++
notifications.send({ subject: messages[severity], severity, timeout: 4000 })
}

// Stacking behavior — consumer owns this
const hovered = shallowRef(false)

const ITEM_H = 44
const GAP = 8
const PEEK = 16
const MAX_STACK = 3

const containerHeight = computed(() => {
const n = Math.min(notifications.queue.values().length, MAX_STACK)
if (!n) return 0
return hovered.value
? notifications.queue.values().length * ITEM_H + (notifications.queue.values().length - 1) * GAP
: ITEM_H + (n - 1) * PEEK
})

function itemStyle (i: number) {
if (hovered.value) {
return {
bottom: `${i * (ITEM_H + GAP)}px`,
left: 0,
right: 0,
transform: 'none',
opacity: 1,
pointerEvents: 'auto' as const,
}
}

if (i >= MAX_STACK) {
const depth = MAX_STACK - 1
return {
bottom: 0,
left: `${depth * 8}px`,
right: `${depth * 8}px`,
transform: `translateY(${-depth * PEEK}px) scale(${1 - depth * 0.04})`,
transformOrigin: 'bottom center',
opacity: 0,
pointerEvents: 'none' as const,
zIndex: -1,
}
}

return {
bottom: 0,
left: `${i * 8}px`,
right: `${i * 8}px`,
transform: `translateY(${-i * PEEK}px) scale(${1 - i * 0.04})`,
transformOrigin: 'bottom center',
opacity: Math.max(0, 1 - i * 0.2),
zIndex: MAX_STACK - i,
pointerEvents: i === 0 ? 'auto' as const : 'none' as const,
}
}

let leaveTimer: ReturnType<typeof setTimeout> | null = null

function onEnter () {
if (leaveTimer) {
clearTimeout(leaveTimer)
leaveTimer = null
}
hovered.value = true
}

function onLeave () {
leaveTimer = setTimeout(() => {
hovered.value = false
leaveTimer = null
}, 150)
}
</script>

<template>
<div class="flex flex-col items-center gap-6 min-h-48 p-6 bg-background rounded-lg border border-divider">
<button
class="px-4 py-2 bg-primary text-on-primary rounded-md text-sm font-medium"
@click="onShow"
>
Show Toast
</button>

<p class="text-sm opacity-40">
Cycles through info → success → warning → error
</p>
</div>

<Snackbar.Portal
class="fixed bottom-4 right-4 w-72"
@mouseenter="onEnter"
@mouseleave="onLeave"
>
<Snackbar.Queue v-slot="{ items }">
<div
class="relative transition-all duration-300 ease-out"
:style="{ height: `${containerHeight}px` }"
>
<div
v-for="(item, i) in items"
:key="item.id"
class="absolute left-0 right-0 transition-all duration-300 ease-out"
:style="itemStyle(i)"
>
<Snackbar.Root
:id="item.id"
class="flex items-center gap-3 px-4 py-2.5 rounded-lg shadow-lg text-sm"
:class="classes[item.severity ?? 'info']"
>
<Snackbar.Content class="flex-1">
{{ item.subject }}
</Snackbar.Content>
<Snackbar.Close
v-show="hovered || i === 0"
class="p-1 -mr-1 opacity-70 hover:opacity-100 shrink-0"
>
<svg aria-hidden="true" class="w-4 h-4" viewBox="0 0 24 24">
<path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z" fill="currentColor" />
</svg>
</Snackbar.Close>
</Snackbar.Root>
</div>
</div>
</Snackbar.Queue>
</Snackbar.Portal>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
<script setup lang="ts">
import { useNotifications } from './context'
import type { AppNotificationInput } from './context'

const notifications = useNotifications()

const scenarios: AppNotificationInput[] = [
// Banner — persistent, system-wide, max 1 visible
{
subject: 'Your trial expires in 3 days',
severity: 'warning',
timeout: -1,
data: { type: 'banner' },
},
// Toast — auto-dismiss after 4s
{
subject: 'Changes saved',
severity: 'success',
timeout: 4000,
data: { type: 'toast' },
},
// Inbox — persistent, full lifecycle
{
subject: 'Build failed on main',
body: 'CI pipeline failed — 3 tests broken in auth module',
severity: 'error',
timeout: -1,
data: { type: 'inbox' },
},
// Inbox — snoozeable
{
subject: 'Review your security settings',
body: 'Two-factor authentication is not enabled',
severity: 'info',
timeout: -1,
data: { type: 'inbox' },
},
// Toast — ephemeral confirmation
{
subject: 'Deployment complete',
severity: 'success',
timeout: 4000,
data: { type: 'toast' },
},
// Inline — contextual, embedded in page
{
subject: 'API rate limit approaching — 80% of quota consumed',
severity: 'warning',
timeout: -1,
data: { type: 'inline' },
},
// Inbox — collaboration
{
subject: 'PR #142 review requested',
body: 'Alex requested your review on feat/notifications',
severity: 'info',
timeout: -1,
data: { type: 'inbox' },
},
]

let index = 0

function simulate () {
notifications.send(scenarios[index % scenarios.length]!)
index++
}
</script>

<template>
<button
class="px-3 py-1.5 bg-primary text-on-primary rounded text-sm font-medium"
@click="simulate"
>
Simulate Event
</button>
</template>
Loading
Loading