UNPKG

@gitlab/ui

Version:
190 lines (186 loc) • 6.62 kB
import { COVERAGE_RAMP } from './palette'; import { createPieces, advancePiece } from './particles'; import { createField } from './field'; import { CANVAS_Z_INDEX, DEFAULT_DURATION, FADE_OUT_DURATION, MAX_FRAME_DELTA } from './constants'; const noop = () => {}; const RUNNING_ATTRIBUTE = 'data-confetti'; const measureStage = boundary => { const rect = boundary instanceof Element ? boundary.getBoundingClientRect() : { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight }; return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; }; const stagePoint = (element, stage) => { if (!(element instanceof Element)) { return null; } const rect = element.getBoundingClientRect(); return { x: rect.left + rect.width / 2 - stage.x, y: rect.top + rect.height / 2 - stage.y }; }; const createCanvas = () => { const canvas = document.createElement('canvas'); canvas.setAttribute('aria-hidden', 'true'); canvas.setAttribute(RUNNING_ATTRIBUTE, ''); Object.assign(canvas.style, { position: 'fixed', inset: '0', width: '100%', height: '100%', pointerEvents: 'none', zIndex: String(CANVAS_Z_INDEX) }); return canvas; }; /** * Bursts a dithered field of pixel blocks, lets it fall, then cleans up after itself. * * Every option has a default, so `confetti()` on its own fires from the bottom corners of the * viewport. The blocks shrink as they fall and as they fade, and the ones furthest back start * smaller, which is what gives the field its depth. * * Only one burst runs at a time: a call made while another is still on screen does nothing and * returns a no-op, so a double click cannot stack two canvases. * * Does nothing when the user prefers reduced motion, or outside a browser. * * The canvas is `aria-hidden`, so nobody using a screen reader will know it fired. When the * confetti is the only sign that something worked, the caller has to convey that success in the * accessibility tree as well. * * @param {Object} [options] * @param {Number} [options.count] Number of pieces. Defaults to a count scaled to the area it * plays in, so a small `boundary` gets proportionally fewer. * @param {Number} [options.duration=3000] How long the animation runs, in milliseconds. * @param {String[]} [options.colors] CSS colors for the coverage ramp, sparse to dense: the first * paints the thinnest edge of a cluster and the last its densest center. Any length works, and the * first four double as the chromatic fringe colors. Defaults to the brand purple, red and orange * color tokens. * @param {Element} [options.origin] Element to burst from. Defaults to the bottom corners. * @param {Element} [options.boundary] Element to confine the confetti to. Defaults to the viewport. * @example * const stop = confetti({ origin: button, colors: ['#7759c2', '#a989f5', '#fca326'] }); * @returns {Function} Stops the animation and removes the canvas early. */ const confetti = function () { var _window$matchMedia, _window; let _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, count = _ref.count, _ref$duration = _ref.duration, duration = _ref$duration === void 0 ? DEFAULT_DURATION : _ref$duration, colors = _ref.colors, origin = _ref.origin, boundary = _ref.boundary; if (typeof window === 'undefined' || (_window$matchMedia = (_window = window).matchMedia) !== null && _window$matchMedia !== void 0 && _window$matchMedia.call(_window, '(prefers-reduced-motion: reduce)').matches || document.querySelector(`[${RUNNING_ATTRIBUTE}]`)) { return noop; } const canvas = createCanvas(); const context = canvas.getContext('2d'); if (!context) { return noop; } const stage = measureStage(boundary); const resize = () => { const ratio = window.devicePixelRatio || 1; canvas.width = window.innerWidth * ratio; canvas.height = window.innerHeight * ratio; context.setTransform(ratio, 0, 0, ratio, 0, 0); }; const fadeOutDuration = Math.min(FADE_OUT_DURATION, duration); const field = createField({ ramp: colors !== null && colors !== void 0 && colors.length ? colors : COVERAGE_RAMP, duration }); const cleared = { x: 0, y: 0, width: 0, height: 0 }; let lastColor = null; const drawBlock = _ref2 => { let x = _ref2.x, y = _ref2.y, size = _ref2.size, color = _ref2.color; if (color !== lastColor) { lastColor = color; context.fillStyle = color; } context.fillRect(x, y, size, size); }; const clearStage = () => { const moved = stage.x !== cleared.x || stage.y !== cleared.y || stage.width !== cleared.width || stage.height !== cleared.height; if (moved) { context.clearRect(0, 0, window.innerWidth, window.innerHeight); Object.assign(cleared, stage); } else { context.clearRect(stage.x, stage.y, stage.width, stage.height); } }; let pieces = createPieces({ stage, origin: stagePoint(origin, stage), count }); let frame = null; let startedAt = null; let previousAt = null; const stop = () => { if (frame !== null) { window.cancelAnimationFrame(frame); frame = null; } window.removeEventListener('resize', resize); canvas.remove(); }; const render = now => { var _startedAt, _previousAt; (_startedAt = startedAt) !== null && _startedAt !== void 0 ? _startedAt : startedAt = now; const elapsed = now - startedAt; const delta = Math.min(now - ((_previousAt = previousAt) !== null && _previousAt !== void 0 ? _previousAt : now), MAX_FRAME_DELTA); previousAt = now; if (elapsed >= duration) { stop(); return; } Object.assign(stage, measureStage(boundary)); clearStage(); lastColor = null; context.globalAlpha = Math.min(1, (duration - elapsed) / fadeOutDuration); context.save(); context.beginPath(); context.rect(stage.x, stage.y, stage.width, stage.height); context.clip(); context.translate(stage.x, stage.y); pieces = pieces.map(piece => advancePiece(piece, { elapsed, delta })); field.resize(stage); pieces.forEach(piece => { if (elapsed >= piece.delay) { field.stamp(piece, elapsed); } }); field.paint(drawBlock); context.restore(); frame = window.requestAnimationFrame(render); }; resize(); window.addEventListener('resize', resize); document.body.append(canvas); frame = window.requestAnimationFrame(render); return stop; }; export { confetti };