Skip to content

Commit fab223f

Browse files
Core, Tests - Replace motion config with CSS variables (#85)
* Core, Tests - Replace motion config with CSS variables Drop animations and transition from NotivueConfig. Apply enter, leave, clearAll, and reposition timing via MOTION_VARS_CSS inline styles and :root defaults in animations.css. Co-authored-by: Cursor <cursoragent@cursor.com> * Tests - Fix reduced-motion specs for instant dismiss Assert no motion CSS vars are applied and notifications dismiss immediately without waiting for ol or container nodes that reduced motion removes on clear. Co-authored-by: Cursor <cursoragent@cursor.com> * Core, Tests - Harden motion CSS variable handling Guard animationend handlers against bubbled child animations, handle CSS-variable animations that resolve to none, and assert custom animation variables through computed styles. Co-authored-by: Cursor <cursoragent@cursor.com> * Core - Rename default motion keyframes Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4e9c170 commit fab223f

9 files changed

Lines changed: 153 additions & 145 deletions

File tree

packages/notivue/core/animations.css

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
1-
[data-notivue-align='top'] {
2-
& .Notivue__enter,
3-
& .Notivue__leave {
4-
--notivue-ty: -200%;
5-
}
6-
}
7-
8-
[data-notivue-align='bottom'] {
9-
& .Notivue__enter,
10-
& .Notivue__leave {
11-
--notivue-ty: 200%;
12-
}
13-
}
14-
15-
.Notivue__enter {
16-
animation: Notivue__enter-kf 350ms cubic-bezier(0.5, 1, 0.25, 1);
1+
:root {
2+
--nv-enter-animation: nv-enter-anim-kf 350ms cubic-bezier(0.5, 1, 0.25, 1);
3+
--nv-leave-animation: nv-leave-anim-kf 350ms ease;
4+
--nv-clear-all-animation: nv-clear-all-anim-kf 500ms cubic-bezier(0.22, 1, 0.36, 1);
175
}
186

19-
.Notivue__leave {
20-
animation: Notivue__leave-kf 350ms ease;
7+
[data-notivue-align='top'] {
8+
--notivue-ty: -200%;
219
}
2210

23-
.Notivue__clearAll {
24-
animation: Notivue__clearAll-kf 500ms cubic-bezier(0.22, 1, 0.36, 1);
11+
[data-notivue-align='bottom'] {
12+
--notivue-ty: 200%;
2513
}
2614

27-
@keyframes Notivue__enter-kf {
15+
@keyframes nv-enter-anim-kf {
2816
0% {
2917
transform: translate3d(0, var(--notivue-ty), 0) scale(0.25);
3018
opacity: 0;
@@ -36,7 +24,7 @@
3624
}
3725
}
3826

39-
@keyframes Notivue__leave-kf {
27+
@keyframes nv-leave-anim-kf {
4028
0% {
4129
transform: translate3d(0, 0, 0) scale(1);
4230
opacity: 0.7;
@@ -48,7 +36,7 @@
4836
}
4937
}
5038

51-
@keyframes Notivue__clearAll-kf {
39+
@keyframes nv-clear-all-anim-kf {
5240
0% {
5341
opacity: 1;
5442
}

packages/notivue/core/constants.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,22 @@ export const DEFAULT_NOTIFICATION_OPTIONS = {
4343
[NotificationTypeKeys.PROMISE_REJECT]: {},
4444
} as NotivueConfigRequired['notifications']
4545

46+
export const MOTION_VARS = {
47+
enterAnimation: '--nv-enter-animation',
48+
leaveAnimation: '--nv-leave-animation',
49+
clearAllAnimation: '--nv-clear-all-animation',
50+
transformTransition: '--nv-transform-transition',
51+
} as const
52+
53+
export const DEFAULT_TRANSFORM_TRANSITION = 'transform 0.35s cubic-bezier(0.5, 1, 0.25, 1)'
54+
55+
export const MOTION_VARS_CSS = {
56+
enterAnimation: `var(${MOTION_VARS.enterAnimation})`,
57+
leaveAnimation: `var(${MOTION_VARS.leaveAnimation})`,
58+
clearAllAnimation: `var(${MOTION_VARS.clearAllAnimation})`,
59+
transformTransition: `var(${MOTION_VARS.transformTransition}, ${DEFAULT_TRANSFORM_TRANSITION})`,
60+
} as const
61+
4662
export const DEFAULT_CONFIG: NotivueConfigRequired = {
4763
pauseOnHover: true,
4864
pauseOnTouch: true,
@@ -53,10 +69,4 @@ export const DEFAULT_CONFIG: NotivueConfigRequired = {
5369
notifications: DEFAULT_NOTIFICATION_OPTIONS,
5470
limit: -1,
5571
avoidDuplicates: false,
56-
transition: 'transform 0.35s cubic-bezier(0.5, 1, 0.25, 1)',
57-
animations: {
58-
enter: CLASS_PREFIX + 'enter',
59-
leave: CLASS_PREFIX + 'leave',
60-
clearAll: CLASS_PREFIX + 'clearAll',
61-
},
6272
}

packages/notivue/core/createStore.ts

Lines changed: 49 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,9 @@ import type {
1414
NotivueStore,
1515
} from 'notivue'
1616

17-
import { ref, shallowRef, triggerRef, unref, isRef, type Ref } from 'vue'
17+
import { ref, shallowRef, triggerRef, unref, isRef, type CSSProperties, type Ref } from 'vue'
1818

19-
import { DEFAULT_CONFIG, NotificationTypeKeys as NType } from './constants'
19+
import { DEFAULT_CONFIG, MOTION_VARS_CSS, NotificationTypeKeys as NType } from './constants'
2020

2121
import {
2222
createConfigRefs,
@@ -60,7 +60,7 @@ export function createConfig(userConfig: NotivueConfig, isRunning: Readonly<Ref<
6060
const prev = config[key as K].value as Obj
6161
const next = newConfig[key as K] as any
6262

63-
config[key as K].value = mergeDeep(prev, next)
63+
config[key as K].value = mergeDeep(prev, next) as any
6464
} else {
6565
config[key as K].value = newConfig[key as K] as any
6666
}
@@ -169,12 +169,15 @@ export function createItems(config: ConfigSlice, queue: QueueSlice) {
169169
}
170170

171171
export function createElements() {
172-
type AnimationAttrs = { class: string; onAnimationend: () => void }
172+
type MotionAttrs = {
173+
style?: CSSProperties
174+
onAnimationend?: (e?: AnimationEvent) => void
175+
}
173176

174177
return {
175178
root: ref<HTMLElement | null>(null),
176-
rootAttrs: shallowRef<Partial<AnimationAttrs>>({}),
177-
setRootAttrs(newAttrs: Partial<AnimationAttrs>) {
179+
rootAttrs: shallowRef<Partial<MotionAttrs>>({}),
180+
setRootAttrs(newAttrs: Partial<MotionAttrs>) {
178181
this.rootAttrs.value = newAttrs
179182
},
180183
items: ref<HTMLElement[]>([]),
@@ -186,10 +189,10 @@ export function createElements() {
186189
} as {
187190
// Suppress TS7056
188191
root: Ref<HTMLElement | null>
189-
rootAttrs: Ref<Partial<AnimationAttrs>>
192+
rootAttrs: Ref<Partial<MotionAttrs>>
190193
items: Ref<HTMLElement[]>
191194
containers: Ref<HTMLElement[]>
192-
setRootAttrs(newAttrs: Partial<AnimationAttrs>): void
195+
setRootAttrs(newAttrs: Partial<MotionAttrs>): void
193196
getSortedItems(): HTMLElement[]
194197
}
195198
}
@@ -206,13 +209,17 @@ export function createAnimations(
206209
this.isReducedMotion.value = newVal
207210
},
208211
playLeave(id: string, { isDestroy = false, isUserTriggered = false } = {}) {
209-
const { leave = '' } = config.animations.value
210212
const item = items.get(id)
211213

214+
let isDone = false
215+
212216
window.clearTimeout(item?.timeout as number)
213217

214218
const onAnimationend = (e?: AnimationEvent) => {
215219
if (e && e.currentTarget !== e.target) return
220+
if (isDone) return
221+
222+
isDone = true
216223

217224
if (item) {
218225
const slotItem = getSlotItem(item)
@@ -229,7 +236,7 @@ export function createAnimations(
229236
items.remove(id)
230237
}
231238

232-
if (!item || !leave || isDestroy || this.isReducedMotion.value) {
239+
if (!item || isDestroy || this.isReducedMotion.value) {
233240
items.addLifecycleEvent()
234241

235242
return onAnimationend()
@@ -241,36 +248,52 @@ export function createAnimations(
241248
zIndex: -1,
242249
},
243250
animationAttrs: {
244-
class: leave,
251+
style: { animation: MOTION_VARS_CSS.leaveAnimation },
245252
onAnimationend,
246253
},
247254
})
248255

249256
items.addLifecycleEvent()
257+
258+
requestAnimationFrame(() => {
259+
const el = elements.containers.value.find((el) => el.dataset.notivueContainer === id)
260+
261+
if (el && getComputedStyle(el).animationName === 'none') onAnimationend()
262+
})
250263
},
251264
playClearAll() {
252265
items.entries.value.forEach((e) => window.clearTimeout(e.timeout as number))
253266

254-
const { clearAll = '' } = config.animations.value
267+
let isDone = false
268+
269+
const onAnimationend = (e?: AnimationEvent) => {
270+
if (e && e.currentTarget !== e.target) return
271+
if (isDone) return
272+
273+
isDone = true
255274

256-
const onAnimationend = () => {
257275
queue.clear()
258276
items.clear()
259277
}
260278

261-
if (!clearAll || this.isReducedMotion.value) return onAnimationend()
279+
if (this.isReducedMotion.value) return onAnimationend()
262280

263281
elements.setRootAttrs({
264-
class: clearAll,
282+
style: { animation: MOTION_VARS_CSS.clearAllAnimation },
265283
onAnimationend,
266284
})
285+
286+
requestAnimationFrame(() => {
287+
const root = elements.root.value
288+
289+
if (root && getComputedStyle(root).animationName === 'none') onAnimationend()
290+
})
267291
},
268292
updatePositions({ isImmediate = false } = {}) {
269293
console.log('Updating positions')
270294

271295
const isReduced = this.isReducedMotion.value || isImmediate
272296
const isTopAlign = config.position.value.startsWith('top')
273-
const leaveClass = config.animations.value.leave
274297

275298
let accPrevHeights = 0
276299

@@ -279,12 +302,12 @@ export function createAnimations(
279302
const item = items.get(id)
280303

281304
if (!el || !item) continue
282-
if (item.animationAttrs.class === leaveClass) continue
305+
if (item.animationAttrs.style?.animation === MOTION_VARS_CSS.leaveAnimation) continue
283306

284307
items.update(id, {
285308
positionStyles: {
286309
transform: `translate3d(0, ${accPrevHeights}px, 0)`,
287-
transition: isReduced ? 'none' : config.transition.value,
310+
transition: isReduced ? 'none' : MOTION_VARS_CSS.transformTransition,
288311
},
289312
})
290313

@@ -479,11 +502,15 @@ export function createNotifyProxies({
479502
createdAt,
480503
duplicateCount: 0,
481504
animationAttrs: {
482-
class: animations.isReducedMotion.value ? '' : config.animations.value.enter,
483-
onAnimationend() {
484-
if (item.animationAttrs.class === config.animations.value.enter) {
505+
style: animations.isReducedMotion.value
506+
? {}
507+
: { animation: MOTION_VARS_CSS.enterAnimation },
508+
onAnimationend(e?: AnimationEvent) {
509+
if (e && e.currentTarget !== e.target) return
510+
511+
if (item.animationAttrs.style?.animation === MOTION_VARS_CSS.enterAnimation) {
485512
items.update(entry.id, {
486-
animationAttrs: { class: '', onAnimationend: () => {} },
513+
animationAttrs: { style: {}, onAnimationend: () => {} },
487514
})
488515
}
489516
},

packages/notivue/core/types.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,6 @@ export type Position =
4848
| 'bottom-center'
4949
| 'bottom-right'
5050

51-
export interface NotivueAnimations {
52-
enter?: string
53-
leave?: string
54-
clearAll?: string
55-
}
56-
5751
export interface NotificationOptions {
5852
/** Default title (`''` hides the title). */
5953
title?: string | Ref<string>
@@ -75,9 +69,6 @@ export interface NotivueConfig {
7569
/** Stream anchor; see `Position`. */
7670
position?: Position
7771
notifications?: Partial<NotificationTypesOptions>
78-
animations?: NotivueAnimations
79-
/** Must match `transform <duration> <timing-function>`. */
80-
transition?: string
8172
teleportTo?: string | HTMLElement | false
8273
/** Use `-1` for unlimited. @default -1 */
8374
limit?: number
@@ -112,7 +103,7 @@ export interface HiddenInternalItemData {
112103
timeout: number | undefined | (() => void) | void
113104
resumedAt: number
114105
remaining: number
115-
animationAttrs: Partial<{ class: string; onAnimationend: () => void }>
106+
animationAttrs: Partial<{ style: CSSProperties; onAnimationend: (e?: AnimationEvent) => void }>
116107
positionStyles: CSSProperties
117108
}
118109

tests/Notivue/components/Notivue.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,7 @@ const config = useNotivue()
2525
const { startInstance, stopInstance } = useNotivueInstance()
2626
const { entries, queue } = useNotifications()
2727
28-
const { pauseOnTouch, pauseOnHover, teleportTo, limit, animations, enqueue, avoidDuplicates } =
29-
toRefs(cyProps)
28+
const { pauseOnTouch, pauseOnHover, teleportTo, limit, enqueue, avoidDuplicates } = toRefs(cyProps)
3029
3130
const autoClearCount = ref(0)
3231
const manualClearCount = ref(0)
@@ -36,10 +35,8 @@ const manualClearCount = ref(0)
3635
* ==================================================================================== */
3736
3837
watchEffect(() => {
39-
if (animations?.value) config.animations.value = animations.value
4038
if (pauseOnTouch?.value) config.pauseOnTouch.value = pauseOnTouch.value
4139
if (pauseOnHover?.value) config.pauseOnHover.value = pauseOnHover.value
42-
if (animations?.value) config.animations.value = animations.value
4340
if (teleportTo?.value) config.teleportTo.value = teleportTo.value
4441
if (limit?.value) config.limit.value = limit.value
4542
if (enqueue?.value) config.enqueue.value = enqueue.value

0 commit comments

Comments
 (0)