mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
Add a confetti component (#3068)
This commit is contained in:
1 parent
91fe638d33
commit
e63fe34e9c
9 files changed
+1014
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@tabler/core": minor
|
||||
"@tabler/preview": minor
|
||||
"@tabler/docs": minor
|
||||
---
|
||||
|
||||
Added a `Confetti` component with `data-bs-toggle="confetti"`, a demo page and docs.
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* --------------------------------------------------------------------------
|
||||
* Tabler confetti.ts
|
||||
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
|
||||
* --------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import BaseComponent from './bootstrap/base-component'
|
||||
import EventHandler from './bootstrap/dom/event-handler'
|
||||
import SelectorEngine from './bootstrap/dom/selector-engine'
|
||||
import { isDisabled } from './bootstrap/util/index'
|
||||
import type { ComponentConfig as BaseConfig, ElementSelector } from './bootstrap/types'
|
||||
|
||||
type ComponentConfig = {
|
||||
/** how many pieces one burst pours out */
|
||||
count: number
|
||||
/** ms during which pieces keep pouring; the first frames get the most */
|
||||
duration: number
|
||||
/** how fast the pour thins out; higher means a sharper splash at the start */
|
||||
decay: number
|
||||
/** share of the viewport height at the bottom where pieces fade out */
|
||||
fade: number
|
||||
/** multiplier for the fall speed */
|
||||
speed: number
|
||||
/** piece colours; `null` reads the Tabler palette from CSS custom properties */
|
||||
colors: string[] | null
|
||||
}
|
||||
|
||||
type ComponentConfigInput = Partial<Omit<ComponentConfig, 'colors'>> & {
|
||||
colors?: string[] | string | null
|
||||
}
|
||||
|
||||
type Particle = {
|
||||
emitter: Emitter
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
color: string
|
||||
vx: number
|
||||
vy: number
|
||||
speed: number
|
||||
angle: number
|
||||
spin: number
|
||||
flip: number
|
||||
flipSpeed: number
|
||||
sway: number
|
||||
alpha: number
|
||||
}
|
||||
|
||||
type Emitter = {
|
||||
element: HTMLElement
|
||||
config: ComponentConfig
|
||||
colors: string[]
|
||||
startedAt: number
|
||||
emitted: number
|
||||
/** `stop()` was called: nothing new is poured, what is in the air still falls */
|
||||
stopped: boolean
|
||||
/** every piece has landed and `end` was fired */
|
||||
done: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Constants
|
||||
*/
|
||||
|
||||
const NAME = 'confetti'
|
||||
const DATA_KEY = `bs.${NAME}`
|
||||
const EVENT_KEY = `.${DATA_KEY}`
|
||||
|
||||
const EVENT_START = `start${EVENT_KEY}`
|
||||
const EVENT_END = `end${EVENT_KEY}`
|
||||
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}.data-api`
|
||||
|
||||
const CLASS_NAME_CANVAS = `${NAME}-canvas`
|
||||
|
||||
const SELECTOR_DATA_TOGGLE = `[data-bs-toggle="${NAME}"], [data-tblr-toggle="${NAME}"]`
|
||||
|
||||
// Palette names looked up as `--tblr-<name>` on the root element; the hex
|
||||
// values are the fallback for a page that loads the script without the CSS.
|
||||
const PALETTE: [string, string][] = [
|
||||
['blue', '#066fd1'],
|
||||
['azure', '#4299e1'],
|
||||
['indigo', '#4263eb'],
|
||||
['purple', '#ae3ec9'],
|
||||
['pink', '#d6336c'],
|
||||
['red', '#d63939'],
|
||||
['orange', '#f76707'],
|
||||
['yellow', '#f59f00'],
|
||||
['lime', '#74b816'],
|
||||
['green', '#2fb344'],
|
||||
['teal', '#0ca678'],
|
||||
['cyan', '#17a2b8'],
|
||||
]
|
||||
|
||||
// The physics below is tuned for this viewport height; taller windows get
|
||||
// proportionally faster pieces so the fall takes the same time everywhere.
|
||||
const REFERENCE_HEIGHT = 800
|
||||
const FRAME_MS = 1000 / 60
|
||||
const MAX_DPR = 2
|
||||
|
||||
const Default: ComponentConfig = {
|
||||
count: 220,
|
||||
duration: 3500,
|
||||
decay: 3.5,
|
||||
fade: 0.15,
|
||||
speed: 1,
|
||||
colors: null,
|
||||
}
|
||||
|
||||
const DefaultType: Record<keyof ComponentConfig, string> = {
|
||||
count: 'number',
|
||||
duration: 'number',
|
||||
decay: 'number',
|
||||
fade: 'number',
|
||||
speed: 'number',
|
||||
colors: '(array|string|null)',
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage
|
||||
*
|
||||
* One fixed canvas shared by every burst on the page. It is created on the
|
||||
* first burst and removed once the last piece has landed, so an idle page
|
||||
* carries no extra element and no running animation frame.
|
||||
*/
|
||||
|
||||
const stage = {
|
||||
canvas: null as HTMLCanvasElement | null,
|
||||
context: null as CanvasRenderingContext2D | null,
|
||||
particles: [] as Particle[],
|
||||
emitters: [] as Emitter[],
|
||||
frame: 0,
|
||||
lastTick: 0,
|
||||
|
||||
add(emitter: Emitter): void {
|
||||
this.emitters.push(emitter)
|
||||
this._mount()
|
||||
|
||||
if (!this.frame) {
|
||||
this.lastTick = performance.now()
|
||||
this.frame = requestAnimationFrame((now) => this._tick(now))
|
||||
}
|
||||
},
|
||||
|
||||
_mount(): void {
|
||||
if (this.canvas) {
|
||||
return
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.className = CLASS_NAME_CANVAS
|
||||
canvas.setAttribute('aria-hidden', 'true')
|
||||
Object.assign(canvas.style, {
|
||||
position: 'fixed',
|
||||
inset: '0',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
zIndex: '9999',
|
||||
pointerEvents: 'none',
|
||||
display: 'block',
|
||||
})
|
||||
|
||||
document.body.append(canvas)
|
||||
this.canvas = canvas
|
||||
this.context = canvas.getContext('2d')
|
||||
this._resize()
|
||||
window.addEventListener('resize', this._onResize)
|
||||
},
|
||||
|
||||
_unmount(): void {
|
||||
cancelAnimationFrame(this.frame)
|
||||
this.frame = 0
|
||||
window.removeEventListener('resize', this._onResize)
|
||||
this.canvas?.remove()
|
||||
this.canvas = null
|
||||
this.context = null
|
||||
this.particles = []
|
||||
this.emitters = []
|
||||
},
|
||||
|
||||
_onResize: (): void => {
|
||||
stage._resize()
|
||||
},
|
||||
|
||||
_resize(): void {
|
||||
if (!this.canvas || !this.context) {
|
||||
return
|
||||
}
|
||||
|
||||
// The backing store is scaled for HiDPI screens; the CSS size above keeps
|
||||
// the element at the viewport size, so the drawing stays sharp and 1:1.
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, MAX_DPR)
|
||||
this.canvas.width = window.innerWidth * dpr
|
||||
this.canvas.height = window.innerHeight * dpr
|
||||
this.context.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
},
|
||||
|
||||
_spawn(emitter: Emitter): Particle {
|
||||
const { colors, config } = emitter
|
||||
const speed = (window.innerHeight / REFERENCE_HEIGHT) * config.speed
|
||||
|
||||
return {
|
||||
emitter,
|
||||
x: Math.random() * window.innerWidth,
|
||||
y: -20 - Math.random() * 30,
|
||||
w: 6 + Math.random() * 3.6,
|
||||
h: 10 + Math.random() * 4.4,
|
||||
color: colors[Math.floor(Math.random() * colors.length)] ?? '#000',
|
||||
vx: (Math.random() - 0.5) * 1.2,
|
||||
vy: (1.5 + Math.random() * 2.1) * speed,
|
||||
speed,
|
||||
angle: Math.random() * Math.PI * 2,
|
||||
spin: (Math.random() - 0.5) * 0.25,
|
||||
flip: Math.random() * Math.PI * 2,
|
||||
flipSpeed: 0.12 + Math.random() * 0.15,
|
||||
sway: Math.random() * Math.PI * 2,
|
||||
alpha: 1,
|
||||
}
|
||||
},
|
||||
|
||||
// Pour: the share of pieces emitted by time t follows 1 - e^(-k t), so the
|
||||
// first frames get the most and the tail thins out to single pieces.
|
||||
_pour(emitter: Emitter, now: number): void {
|
||||
const { count, duration, decay } = emitter.config
|
||||
const elapsed = now - emitter.startedAt
|
||||
|
||||
if (emitter.stopped || elapsed >= duration) {
|
||||
return
|
||||
}
|
||||
|
||||
const t = elapsed / duration
|
||||
const target = Math.round((count * (1 - Math.exp(-decay * t))) / (1 - Math.exp(-decay)))
|
||||
|
||||
while (emitter.emitted < target) {
|
||||
this.particles.push(this._spawn(emitter))
|
||||
emitter.emitted++
|
||||
}
|
||||
},
|
||||
|
||||
_tick(now: number): void {
|
||||
const context = this.context
|
||||
if (!context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Steps are scaled to the real frame time so a 120 Hz screen or a
|
||||
// throttled tab animate at the same pace as 60 Hz.
|
||||
const step = Math.min((now - this.lastTick) / FRAME_MS, 3)
|
||||
this.lastTick = now
|
||||
|
||||
const width = window.innerWidth
|
||||
const height = window.innerHeight
|
||||
context.clearRect(0, 0, width, height)
|
||||
|
||||
for (const emitter of this.emitters) {
|
||||
this._pour(emitter, now)
|
||||
}
|
||||
|
||||
for (let i = this.particles.length - 1; i >= 0; i--) {
|
||||
const p = this.particles[i]!
|
||||
const fadeFrom = height * (1 - p.emitter.config.fade)
|
||||
|
||||
p.sway += 0.05 * step
|
||||
p.vy = Math.min(p.vy + 0.025 * p.speed * step, 4.2 * p.speed)
|
||||
p.x += (p.vx + Math.sin(p.sway) * 0.7) * step
|
||||
p.y += p.vy * step
|
||||
p.angle += p.spin * step
|
||||
p.flip += p.flipSpeed * step
|
||||
|
||||
if (p.y > fadeFrom) {
|
||||
p.alpha = Math.max(0, 1 - (p.y - fadeFrom) / (height - fadeFrom))
|
||||
}
|
||||
|
||||
if (p.alpha === 0) {
|
||||
this.particles.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
context.save()
|
||||
context.globalAlpha = p.alpha
|
||||
context.translate(p.x, p.y)
|
||||
context.rotate(p.angle)
|
||||
// The flip squashes the piece along one axis, which reads as tumbling.
|
||||
context.scale(1, Math.cos(p.flip))
|
||||
context.fillStyle = p.color
|
||||
context.fillRect(-p.w / 2, -p.h / 2, p.w, p.h)
|
||||
context.restore()
|
||||
}
|
||||
|
||||
this._settle(now)
|
||||
|
||||
if (this.emitters.length === 0) {
|
||||
this._unmount()
|
||||
return
|
||||
}
|
||||
|
||||
this.frame = requestAnimationFrame((next) => this._tick(next))
|
||||
},
|
||||
|
||||
// An emitter is finished once it pours nothing more and none of its pieces
|
||||
// is still in the air; `end` fires on its element and it leaves the stage.
|
||||
_settle(now: number): void {
|
||||
for (let i = this.emitters.length - 1; i >= 0; i--) {
|
||||
const emitter = this.emitters[i]!
|
||||
const pouring = !emitter.stopped && now - emitter.startedAt < emitter.config.duration
|
||||
const airborne = this.particles.some((p) => p.emitter === emitter)
|
||||
|
||||
if (pouring || airborne) {
|
||||
continue
|
||||
}
|
||||
|
||||
this.emitters.splice(i, 1)
|
||||
emitter.done = true
|
||||
EventHandler.trigger(emitter.element, EVENT_END)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Class definition
|
||||
*
|
||||
* Pours a short shower of confetti over the page, like a bucket emptied from
|
||||
* the top edge: dense at first, then thinning out. Nothing is drawn when the
|
||||
* user prefers reduced motion, but the events still fire.
|
||||
*/
|
||||
|
||||
class Confetti extends BaseComponent {
|
||||
declare _element: HTMLElement
|
||||
declare _config: ComponentConfig
|
||||
_emitter: Emitter | null = null
|
||||
|
||||
constructor(element: ElementSelector, config?: ComponentConfigInput) {
|
||||
super(element, config as BaseConfig | undefined)
|
||||
}
|
||||
|
||||
// Getters
|
||||
static get Default(): ComponentConfig {
|
||||
return Default
|
||||
}
|
||||
|
||||
static get DefaultType(): Record<keyof ComponentConfig, string> {
|
||||
return DefaultType
|
||||
}
|
||||
|
||||
static get NAME(): string {
|
||||
return NAME
|
||||
}
|
||||
|
||||
// Public
|
||||
burst(): void {
|
||||
const startEvent = EventHandler.trigger(this._element, EVENT_START)
|
||||
|
||||
if (startEvent?.defaultPrevented) {
|
||||
return
|
||||
}
|
||||
|
||||
// A second burst while one is pouring restarts the pour; pieces already
|
||||
// in the air keep falling.
|
||||
this.stop()
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
EventHandler.trigger(this._element, EVENT_END)
|
||||
return
|
||||
}
|
||||
|
||||
this._emitter = {
|
||||
element: this._element,
|
||||
config: this._config,
|
||||
colors: this._colors(),
|
||||
startedAt: performance.now(),
|
||||
emitted: 0,
|
||||
stopped: false,
|
||||
done: false,
|
||||
}
|
||||
|
||||
stage.add(this._emitter)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this._emitter && !this._emitter.done) {
|
||||
this._emitter.stopped = true
|
||||
}
|
||||
|
||||
this._emitter = null
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
// Private
|
||||
_configAfterMerge(config: BaseConfig): BaseConfig {
|
||||
if (typeof config.colors === 'string') {
|
||||
config.colors = config.colors
|
||||
.split(',')
|
||||
.map((color) => color.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
_colors(): string[] {
|
||||
const { colors } = this._config
|
||||
if (colors && colors.length > 0) {
|
||||
return colors
|
||||
}
|
||||
|
||||
// The palette is read at burst time, so a theme switched at runtime is
|
||||
// picked up without re-creating the instance.
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
return PALETTE.map(([name, fallback]) => style.getPropertyValue(`--tblr-${name}`).trim() || fallback)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data API implementation
|
||||
*
|
||||
* A trigger bursts on its `data-bs-target` when it has one, otherwise on
|
||||
* itself, so the events fire where the page can listen for them.
|
||||
*/
|
||||
|
||||
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
|
||||
if (['A', 'AREA'].includes(this.tagName)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
if (isDisabled(this)) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = SelectorEngine.getElementFromSelector(this) || this
|
||||
const instance = Confetti.getOrCreateInstance(target) as Confetti
|
||||
instance.burst()
|
||||
})
|
||||
|
||||
export default Confetti
|
||||
@@ -1,4 +1,5 @@
|
||||
import './src/autosize'
|
||||
import './src/confetti'
|
||||
import './src/countup'
|
||||
import './src/input-mask'
|
||||
import './src/dropdown'
|
||||
@@ -17,6 +18,7 @@ export * from './src/bootstrap'
|
||||
|
||||
// Tabler's own components
|
||||
export { default as Autosize } from './src/autosize'
|
||||
export { default as Confetti } from './src/confetti'
|
||||
export { default as CountUp } from './src/countup'
|
||||
export { default as InputMask } from './src/input-mask'
|
||||
export { default as Sortable } from './src/sortable'
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
|
||||
import { clearFixture, getFixture } from '../helpers/fixture'
|
||||
import Confetti from '../../src/confetti'
|
||||
|
||||
describe('Confetti', () => {
|
||||
let fixtureEl: HTMLElement
|
||||
|
||||
beforeAll(() => {
|
||||
fixtureEl = getFixture()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Let every burst land so one test's canvas never leaks into the next.
|
||||
for (const element of fixtureEl.querySelectorAll<HTMLElement>('button, div')) {
|
||||
Confetti.getInstance(element)?.dispose()
|
||||
}
|
||||
|
||||
clearFixture()
|
||||
vi.restoreAllMocks()
|
||||
return vi.waitFor(() => expect(canvas()).toBeNull())
|
||||
})
|
||||
|
||||
const button = (): HTMLElement => fixtureEl.querySelector('button')!
|
||||
const canvas = (): HTMLCanvasElement | null => document.querySelector('.confetti-canvas')
|
||||
|
||||
// A short pour with fast pieces: the whole shower is over well within a second.
|
||||
const QUICK = { count: 5, duration: 20, speed: 60 }
|
||||
|
||||
const reduceMotion = (matches: boolean): void => {
|
||||
vi.spyOn(window, 'matchMedia').mockImplementation((query: string) => ({ matches, media: query }) as MediaQueryList)
|
||||
}
|
||||
|
||||
describe('NAME', () => {
|
||||
it('should return plugin name', () => {
|
||||
expect(Confetti.NAME).toBe('confetti')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Default', () => {
|
||||
it('should expose the defaults and their types', () => {
|
||||
expect(Confetti.Default.count).toBe(220)
|
||||
expect(Confetti.Default.colors).toBeNull()
|
||||
expect(Confetti.DefaultType.colors).toBe('(array|string|null)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('burst', () => {
|
||||
it('should mount one shared canvas and remove it after the pieces land', async () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
reduceMotion(false)
|
||||
|
||||
const instance = new Confetti(button(), QUICK)
|
||||
instance.burst()
|
||||
|
||||
const mounted = canvas()!
|
||||
expect(mounted).not.toBeNull()
|
||||
expect(mounted.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(mounted.style.pointerEvents).toBe('none')
|
||||
|
||||
instance.burst()
|
||||
expect(document.querySelectorAll('.confetti-canvas')).toHaveLength(1)
|
||||
|
||||
await vi.waitFor(() => expect(canvas()).toBeNull(), { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should fire start and end events', async () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
reduceMotion(false)
|
||||
|
||||
const start = vi.fn()
|
||||
const end = vi.fn()
|
||||
button().addEventListener('start.bs.confetti', start)
|
||||
button().addEventListener('end.bs.confetti', end)
|
||||
|
||||
new Confetti(button(), QUICK).burst()
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
expect(end).not.toHaveBeenCalled()
|
||||
|
||||
await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1), { timeout: 3000 })
|
||||
})
|
||||
|
||||
it('should not start when the start event is prevented', () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
reduceMotion(false)
|
||||
|
||||
button().addEventListener('start.bs.confetti', (event) => event.preventDefault())
|
||||
new Confetti(button(), QUICK).burst()
|
||||
|
||||
expect(canvas()).toBeNull()
|
||||
})
|
||||
|
||||
it('should skip the animation but still end when the user prefers reduced motion', () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
reduceMotion(true)
|
||||
|
||||
const end = vi.fn()
|
||||
button().addEventListener('end.bs.confetti', end)
|
||||
new Confetti(button(), QUICK).burst()
|
||||
|
||||
expect(canvas()).toBeNull()
|
||||
expect(end).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('stop', () => {
|
||||
it('should stop pouring and let the pieces in the air land', async () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
reduceMotion(false)
|
||||
|
||||
const end = vi.fn()
|
||||
button().addEventListener('end.bs.confetti', end)
|
||||
|
||||
const instance = new Confetti(button(), { count: 50, duration: 60_000, speed: 60 })
|
||||
instance.burst()
|
||||
expect(canvas()).not.toBeNull()
|
||||
|
||||
instance.stop()
|
||||
await vi.waitFor(() => expect(end).toHaveBeenCalledTimes(1), { timeout: 3000 })
|
||||
expect(canvas()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('colors', () => {
|
||||
it('should accept a comma separated list from a data attribute', () => {
|
||||
fixtureEl.innerHTML = '<button type="button" data-bs-colors=" #f00, #0f0 ,#00f"></button>'
|
||||
|
||||
const instance = new Confetti(button())
|
||||
expect(instance._config.colors).toEqual(['#f00', '#0f0', '#00f'])
|
||||
})
|
||||
|
||||
it('should read the Tabler palette from custom properties by default', () => {
|
||||
fixtureEl.innerHTML = '<button type="button"></button>'
|
||||
document.documentElement.style.setProperty('--tblr-blue', 'rgb(1, 2, 3)')
|
||||
|
||||
const colors = new Confetti(button())._colors()
|
||||
document.documentElement.style.removeProperty('--tblr-blue')
|
||||
|
||||
expect(colors).toHaveLength(12)
|
||||
expect(colors[0]).toBe('rgb(1, 2, 3)')
|
||||
// No CSS is loaded in the test page: the other names fall back to hex.
|
||||
expect(colors[1]).toBe('#4299e1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('data-api', () => {
|
||||
it('should burst on click without instantiating manually', async () => {
|
||||
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="confetti" data-bs-count="5" data-bs-duration="20" data-bs-speed="60"></button>'
|
||||
reduceMotion(false)
|
||||
|
||||
button().click()
|
||||
|
||||
const instance = Confetti.getInstance(button()) as Confetti
|
||||
expect(instance).not.toBeNull()
|
||||
expect(instance._config.count).toBe(5)
|
||||
expect(canvas()).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should burst on the target of a data-tblr-toggle trigger and prevent the default on a link', () => {
|
||||
fixtureEl.innerHTML = '<a href="#" data-tblr-toggle="confetti" data-tblr-target="#party"></a><div id="party"></div>'
|
||||
reduceMotion(true)
|
||||
|
||||
const start = vi.fn()
|
||||
const target = fixtureEl.querySelector('#party')!
|
||||
target.addEventListener('start.bs.confetti', start)
|
||||
|
||||
const event = new MouseEvent('click', { bubbles: true, cancelable: true })
|
||||
fixtureEl.querySelector('a')!.dispatchEvent(event)
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(start).toHaveBeenCalledTimes(1)
|
||||
expect(Confetti.getInstance(fixtureEl.querySelector('a')!)).toBeNull()
|
||||
})
|
||||
|
||||
it('should ignore a disabled trigger', () => {
|
||||
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="confetti" disabled></button>'
|
||||
reduceMotion(true)
|
||||
|
||||
const start = vi.fn()
|
||||
button().addEventListener('start.bs.confetti', start)
|
||||
button().click()
|
||||
|
||||
expect(start).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
title: Confetti
|
||||
summary: Confetti pours a short shower of colored pieces over the page. Use it once, for a moment worth celebrating, like a finished setup, a first order or a signup.
|
||||
description: Celebrate a success with a confetti shower triggered from a button or from JavaScript, with the amount, length, speed and colors set by data attributes.
|
||||
added-in: '1.6.0'
|
||||
related: [/ui/components/toast, /ui/components/modal, /ui/components/empty]
|
||||
source: core/js/src/confetti.ts
|
||||
---
|
||||
import Example from '@components/Example.astro';
|
||||
|
||||
## Overview
|
||||
|
||||
Confetti is a small reward for the user. The pieces pour from the top edge like a bucket emptied over the page, dense at first and thinning out, then fall, tumble and fade out near the bottom. The whole shower takes a few seconds and clears by itself.
|
||||
|
||||
The pieces are drawn on one canvas that covers the page and ignores the mouse, so nothing under it stops working. The canvas is added on the first burst and removed once the last piece has landed.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn btn-primary" data-bs-toggle="confetti">Celebrate</button>
|
||||
</Example>
|
||||
|
||||
## Usage
|
||||
|
||||
Add `data-bs-toggle="confetti"` to a button or a link. A click pours one shower. A second click while the first one is still pouring starts over; the pieces already in the air keep falling.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn" data-bs-toggle="confetti">Celebrate</button>
|
||||
<a href="#" class="btn btn-link" data-bs-toggle="confetti">Celebrate</a>
|
||||
</Example>
|
||||
|
||||
Use confetti for a moment, not for a page. One burst when a setup is finished or an order is placed feels earned; a burst on every click does not. Prefer one trigger per screen, and never pour on page load without a reason the user can see.
|
||||
|
||||
### Amount and length
|
||||
|
||||
Set `data-bs-count` for the number of pieces and `data-bs-duration` for how long they keep pouring, in milliseconds. The pour is always dense at the start and thins out towards the end, whatever its length.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn btn-outline-primary" data-bs-toggle="confetti" data-bs-count="60" data-bs-duration="800">A few</button>
|
||||
<button type="button" class="btn btn-outline-primary" data-bs-toggle="confetti" data-bs-count="600" data-bs-duration="6000">A lot</button>
|
||||
</Example>
|
||||
|
||||
### Colors
|
||||
|
||||
By default the pieces use the Tabler palette, read from the `--tblr-blue`, `--tblr-red` and the other color custom properties at the time of the burst, so a changed theme is picked up. Pass a comma-separated list in `data-bs-colors` to use your own colors.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn btn-orange" data-bs-toggle="confetti" data-bs-colors="#f76707, #f59f00, #d63939, #d6336c">Warm</button>
|
||||
<button type="button" class="btn btn-azure" data-bs-toggle="confetti" data-bs-colors="#066fd1, #4299e1, #4263eb, #17a2b8">Cool</button>
|
||||
</Example>
|
||||
|
||||
### Speed
|
||||
|
||||
Set `data-bs-speed` to scale how fast the pieces fall. `1` is the default. A lower value floats, a higher one drops. The fall is scaled to the height of the window as well, so it takes about the same time on a phone and on a large screen.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn btn-ghost-primary" data-bs-toggle="confetti" data-bs-speed="0.5">Float</button>
|
||||
<button type="button" class="btn btn-ghost-primary" data-bs-toggle="confetti" data-bs-speed="2">Drop</button>
|
||||
</Example>
|
||||
|
||||
### Target
|
||||
|
||||
Point `data-bs-target` at another element to run the shower from there. The element does not change how the shower looks; it is where the events fire and where the instance is stored, so a page can listen in one place.
|
||||
|
||||
<Example centered>
|
||||
<button type="button" class="btn btn-green" data-bs-toggle="confetti" data-bs-target="#confetti-order">Place order</button>
|
||||
<span id="confetti-order" class="badge bg-green-lt">Order #1042</span>
|
||||
</Example>
|
||||
|
||||
## Options
|
||||
|
||||
Every option is a `data-bs-*` attribute on the element, or a key in the config object passed to the constructor.
|
||||
|
||||
| Option | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `count` | `220` | How many pieces one burst pours out. |
|
||||
| `duration` | `3500` | How long the pieces keep pouring, in milliseconds. The fall adds a few seconds after that. |
|
||||
| `decay` | `3.5` | How fast the pour thins out. A higher value gives a sharper splash at the start and a longer thin tail. |
|
||||
| `fade` | `0.15` | The share of the window height at the bottom where the pieces fade out. |
|
||||
| `speed` | `1` | Multiplier for the fall speed. |
|
||||
| `colors` | Tabler palette | The colors of the pieces. A comma-separated list or an array of CSS colors. |
|
||||
|
||||
## JavaScript
|
||||
|
||||
Tabler wraps the element in a `Confetti` component that works like the Bootstrap components: it is created once per element and stored on it. The data API creates one for the trigger, or for its `data-bs-target`, on the first click.
|
||||
|
||||
Create the component yourself to burst from code, for example once a form has been saved. Any element can hold it, the body included:
|
||||
|
||||
```js
|
||||
const confetti = tabler.Confetti.getOrCreateInstance(document.body, { count: 150 });
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
await save(form);
|
||||
confetti.burst();
|
||||
});
|
||||
```
|
||||
|
||||
| Method | Description |
|
||||
| --- | --- |
|
||||
| `burst()` | Pours one shower. Fires `start.bs.confetti` first; cancel it with `preventDefault()` to stop the burst. |
|
||||
| `stop()` | Stops pouring. The pieces already in the air keep falling. |
|
||||
| `dispose()` | Stops pouring and removes the component from the element. |
|
||||
| `getInstance(element)` | Static. Returns the component for the element, or `null`. |
|
||||
| `getOrCreateInstance(element, config)` | Static. Returns the component for the element and creates it when needed. |
|
||||
|
||||
| Event | Description |
|
||||
| --- | --- |
|
||||
| `start.bs.confetti` | Fired on the element before a burst. Cancelable. |
|
||||
| `end.bs.confetti` | Fired on the element once the last piece of that burst has landed. |
|
||||
|
||||
```js
|
||||
document.getElementById('order').addEventListener('end.bs.confetti', () => {
|
||||
window.location.href = '/orders/1042';
|
||||
});
|
||||
```
|
||||
|
||||
## Accessibility
|
||||
|
||||
- The canvas is hidden from assistive technology with `aria-hidden` and ignores the mouse, so the page stays usable while the pieces fall.
|
||||
- The shower is decoration. Say what happened in text as well, in a toast, an alert or a heading, so users who do not see the animation get the same message.
|
||||
- When the user asks for reduced motion, nothing is drawn. The `start` and `end` events still fire, so code that waits for the end keeps working.
|
||||
- Keep the shower short and rare. A few seconds after a real success is a reward; frequent or long showers get in the way.
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||
import CaptureScript from '@shared/components/CaptureScript.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import CardTitle from '@ui/CardTitle.astro'
|
||||
import CardSubtitle from '@ui/CardSubtitle.astro'
|
||||
import DocsLink from '@ui/DocsLink.astro'
|
||||
import Button from '@ui/Button.astro'
|
||||
import ButtonList from '@ui/ButtonList.astro'
|
||||
import Badge from '@ui/Badge.astro'
|
||||
---
|
||||
|
||||
<DefaultLayout title="Confetti" pageMenu="base.confetti" description="Confetti pours a short shower of colored pieces over the page to celebrate a finished task, a signup or a first success.">
|
||||
<DocsLink slot="page-header-actions" path="/ui/components/confetti" />
|
||||
<CaptureScript>
|
||||
<!-- BEGIN SCRIPT OF CONFETTI ON LOAD -->
|
||||
<script is:inline>
|
||||
/*
|
||||
One burst when the page opens. The instance lives on the body, so no element
|
||||
on the page has to own it.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
tabler.Confetti.getOrCreateInstance(document.body).burst()
|
||||
})
|
||||
</script>
|
||||
<!-- END SCRIPT OF CONFETTI ON LOAD -->
|
||||
</CaptureScript>
|
||||
<div class="row row-cards">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Basic</CardTitle>
|
||||
<CardSubtitle>Add <code>data-bs-toggle="confetti"</code> to a button. A click pours the shower; it thins out and clears by itself.</CardSubtitle>
|
||||
<Button text="Celebrate" data-bs-toggle="confetti" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Amount and length</CardTitle>
|
||||
<CardSubtitle>Set <code>data-bs-count</code> for the number of pieces and <code>data-bs-duration</code> for how long they keep pouring, in milliseconds.</CardSubtitle>
|
||||
<ButtonList>
|
||||
<Button text="A few" data-bs-toggle="confetti" data-bs-count="60" data-bs-duration="800" />
|
||||
<Button text="A lot" data-bs-toggle="confetti" data-bs-count="600" data-bs-duration="6000" />
|
||||
</ButtonList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Colors</CardTitle>
|
||||
<CardSubtitle>By default the pieces use the Tabler palette. Pass a comma-separated list in <code>data-bs-colors</code> to use your own.</CardSubtitle>
|
||||
<ButtonList>
|
||||
<Button text="Warm" data-bs-toggle="confetti" data-bs-colors="#f76707, #f59f00, #d63939, #d6336c" />
|
||||
<Button text="Cool" data-bs-toggle="confetti" data-bs-colors="#066fd1, #4299e1, #4263eb, #17a2b8" />
|
||||
<Button text="Mono" data-bs-toggle="confetti" data-bs-colors="#182433, #667382, #dce1e7" />
|
||||
</ButtonList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Speed</CardTitle>
|
||||
<CardSubtitle>Set <code>data-bs-speed</code> to scale the fall. <code>1</code> is the default, a lower value floats, a higher one drops.</CardSubtitle>
|
||||
<ButtonList>
|
||||
<Button text="Float" data-bs-toggle="confetti" data-bs-speed="0.5" />
|
||||
<Button text="Default" data-bs-toggle="confetti" />
|
||||
<Button text="Drop" data-bs-toggle="confetti" data-bs-speed="2" />
|
||||
</ButtonList>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Target and events</CardTitle>
|
||||
<CardSubtitle>Point <code>data-bs-target</code> at another element. <code>start.bs.confetti</code> and <code>end.bs.confetti</code> fire on that element, not on the trigger.</CardSubtitle>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<Button text="Ship it" data-bs-toggle="confetti" data-bs-target="#confetti-shipped" data-bs-count="150" data-bs-duration="1500" />
|
||||
<Badge id="confetti-shipped" light text="Idle" />
|
||||
</div>
|
||||
<CaptureScript>
|
||||
<!-- BEGIN SCRIPT OF CONFETTI EVENTS -->
|
||||
<script is:inline>
|
||||
/*
|
||||
Shows the confetti events on the target element: "Pouring" while the shower
|
||||
runs and "Done" once the last piece has landed.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const badge = document.getElementById('confetti-shipped')
|
||||
|
||||
badge.addEventListener('start.bs.confetti', () => {
|
||||
badge.textContent = 'Pouring…'
|
||||
})
|
||||
|
||||
badge.addEventListener('end.bs.confetti', () => {
|
||||
badge.textContent = 'Done'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<!-- END SCRIPT OF CONFETTI EVENTS -->
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>From JavaScript</CardTitle>
|
||||
<CardSubtitle>Call <code>burst()</code> on a <code>tabler.Confetti</code> instance when something else finishes, for example a form that was saved.</CardSubtitle>
|
||||
<form id="confetti-form" class="d-flex align-items-end gap-2">
|
||||
<div class="flex-fill">
|
||||
<label class="form-label" for="confetti-project">Project name</label>
|
||||
<input type="text" id="confetti-project" class="form-control" value="Tabler" autocomplete="off" />
|
||||
</div>
|
||||
<Button type="submit" text="Create" />
|
||||
</form>
|
||||
<CaptureScript>
|
||||
<!-- BEGIN SCRIPT OF CONFETTI FORM -->
|
||||
<script is:inline>
|
||||
/*
|
||||
Bursts confetti from code once the form is "saved". The instance lives on
|
||||
the form, so the events fire there.
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const form = document.getElementById('confetti-form')
|
||||
const confetti = tabler.Confetti.getOrCreateInstance(form, { count: 150, duration: 1500 })
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
confetti.burst()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<!-- END SCRIPT OF CONFETTI FORM -->
|
||||
</CaptureScript>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</DefaultLayout>
|
||||
@@ -0,0 +1,102 @@
|
||||
---
|
||||
// Confetti frozen mid-shower over a success card. The component animates on a
|
||||
// canvas with requestAnimationFrame, which a screenshot cannot wait for, so the
|
||||
// inline script below takes over the clock before tabler.js runs: it queues
|
||||
// animation frames instead of scheduling them, seeds Math.random, bursts once
|
||||
// the component is loaded and then replays a fixed number of frames by hand.
|
||||
// The canvas keeps its last frame, and the clock is handed back so the
|
||||
// layout's ready signal (and a click on the button) still work.
|
||||
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
|
||||
import Card from '@ui/Card.astro'
|
||||
import CardBody from '@ui/CardBody.astro'
|
||||
import Button from '@ui/Button.astro'
|
||||
---
|
||||
|
||||
<ScreenshotLayout title="Confetti" columns={1}>
|
||||
<Card class="card-gradient">
|
||||
<CardBody class="p-4 text-center">
|
||||
<div class="mb-3">
|
||||
<img src="./static/illustrations/light/good-info.png" alt="" height="160" class="img-light" />
|
||||
<img src="./static/illustrations/dark/good-info.png" alt="" height="160" class="img-dark" />
|
||||
</div>
|
||||
<h3 class="card-title mb-1">Your workspace is ready</h3>
|
||||
<p class="text-secondary mb-4">Invite your team and start your first project.</p>
|
||||
<Button text="Go to dashboard" color="primary" block id="shot-confetti" data-bs-toggle="confetti" />
|
||||
</CardBody>
|
||||
</Card>
|
||||
</ScreenshotLayout>
|
||||
|
||||
<script is:inline>
|
||||
;(() => {
|
||||
const FRAMES = 150
|
||||
const FRAME_MS = 1000 / 60
|
||||
|
||||
const realFrame = window.requestAnimationFrame
|
||||
const realCancel = window.cancelAnimationFrame
|
||||
const realNow = performance.now.bind(performance)
|
||||
const realRandom = Math.random
|
||||
|
||||
let now = 0
|
||||
let queue = []
|
||||
let seed = 20260915
|
||||
|
||||
window.requestAnimationFrame = (callback) => queue.push(callback)
|
||||
window.cancelAnimationFrame = () => {}
|
||||
performance.now = () => now
|
||||
// Linear congruential generator: the same pieces in the same places on
|
||||
// every capture, in light and dark mode alike.
|
||||
Math.random = () => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0
|
||||
return seed / 4294967296
|
||||
}
|
||||
|
||||
const restore = () => {
|
||||
window.requestAnimationFrame = realFrame
|
||||
window.cancelAnimationFrame = realCancel
|
||||
performance.now = realNow
|
||||
Math.random = realRandom
|
||||
}
|
||||
|
||||
document.addEventListener(
|
||||
'DOMContentLoaded',
|
||||
() => {
|
||||
const Confetti = window.tabler?.Confetti
|
||||
if (!Confetti) {
|
||||
restore()
|
||||
return
|
||||
}
|
||||
|
||||
Confetti.getOrCreateInstance('#shot-confetti', { count: 300 }).burst()
|
||||
|
||||
for (let frame = 0; frame < FRAMES; frame++) {
|
||||
now += FRAME_MS
|
||||
const callbacks = queue
|
||||
queue = []
|
||||
for (const callback of callbacks) {
|
||||
callback(now)
|
||||
}
|
||||
}
|
||||
|
||||
// The pending frame stays unscheduled: the canvas holds this picture.
|
||||
// A click on the button (for the human viewing the page) hands that
|
||||
// frame to the real scheduler first, so the shower resumes and the
|
||||
// new burst runs live.
|
||||
const pending = queue
|
||||
queue = []
|
||||
restore()
|
||||
|
||||
document.getElementById('shot-confetti')?.addEventListener(
|
||||
'click',
|
||||
() => {
|
||||
for (const callback of pending) {
|
||||
realFrame(callback)
|
||||
}
|
||||
pending.length = 0
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})()
|
||||
</script>
|
||||
@@ -192,6 +192,10 @@
|
||||
"title": "Chat",
|
||||
"url": "/ui/components/chat"
|
||||
},
|
||||
{
|
||||
"title": "Confetti",
|
||||
"url": "/ui/components/confetti"
|
||||
},
|
||||
{
|
||||
"title": "Data grid",
|
||||
"url": "/ui/components/datagrid"
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
"url": "colors.html",
|
||||
"title": "Colors"
|
||||
},
|
||||
"confetti": {
|
||||
"title": "Confetti",
|
||||
"url": "confetti.html"
|
||||
},
|
||||
"datagrid": {
|
||||
"url": "datagrid.html",
|
||||
"title": "Data grid"
|
||||
|
||||
Reference in new issue
Block a user