@gitlab/ui
Version:
GitLab UI Components
190 lines (186 loc) • 6.62 kB
JavaScript
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 };