styleui-components
Version:
Lightweight, modular UI component library with zero dependencies
264 lines (220 loc) • 9.76 kB
JavaScript
(function() {
if (!window.UI) window.UI = {};
/**
* Creates an interactive timeline widget with a draggable playhead.
* @param {object} [config={}] - Configuration options.
* @param {number} [config.duration=100] - Total length of the timeline (frames, seconds, etc.).
* @param {number} [config.current=0] - Initial playhead position.
* @param {Array<{time:number,color?:string}>} [config.markers] - Optional array of marker objects.
* @param {function} [config.onchange] - Callback fired with new position whenever the playhead moves.
* @returns {HTMLElement} Timeline element.
*/
UI.timeline = function(config = {}) {
const {
duration = 100,
current = 0,
markers = [],
tickInterval = 1,
majorTickInterval = 50,
labelInterval = 10,
startRange = 0,
endRange = duration,
snapIncrement = 1,
onchange,
onrangechange,
onMarkerHover
} = config;
// Helper function (hoisted)
function clamp(val, min, max) {
return Math.min(Math.max(val, min), max);
}
const snap = (val) => Math.round(val / snapIncrement) * snapIncrement;
const container = document.createElement('div');
container.className = 'timeline';
const track = document.createElement('div');
track.className = 'timeline-track';
container.appendChild(track);
// Progress (filled) bar
const progressBar = document.createElement('div');
progressBar.className = 'timeline-progress';
track.appendChild(progressBar);
// Draggable handle / playhead
const handle = document.createElement('div');
handle.className = 'timeline-handle';
// Insert playhead icon using Lucide
handle.innerHTML = `
<div class="timeline-playhead-label"></div>
<div class="timeline-playhead-line"></div>
`;
// Set width to represent exactly one frame
const framePct = 100 / duration;
handle.style.width = framePct + '%';
track.appendChild(handle);
// Render the icon if Lucide is available
if (window.lucide && typeof window.lucide.createIcons === 'function') {
window.lucide.createIcons({ nodes: [handle] });
}
const playheadLabel = handle.querySelector('.timeline-playhead-label');
/* ---------------- Playback Range ---------------- */
let rangeStart = clamp(startRange, 0, duration);
let rangeEnd = clamp(endRange, rangeStart, duration);
const rangeBar = document.createElement('div');
rangeBar.className = 'timeline-range';
track.appendChild(rangeBar);
const startHandle = document.createElement('div');
startHandle.className = 'timeline-range-handle handle-start';
track.appendChild(startHandle);
const endHandle = document.createElement('div');
endHandle.className = 'timeline-range-handle handle-end';
track.appendChild(endHandle);
const updateRangeVisual = () => {
const startPct = (rangeStart / duration) * 100;
const endPct = (rangeEnd / duration) * 100;
rangeBar.style.left = startPct + '%';
rangeBar.style.width = (endPct - startPct) + '%';
startHandle.style.left = startPct + '%';
endHandle.style.left = endPct + '%';
};
const fireRangeChange = () => {
if (typeof onrangechange === 'function') {
onrangechange({ start: rangeStart, end: rangeEnd });
}
};
updateRangeVisual();
/* ----- Range Handle Interaction ----- */
let draggingRangeHandle = null; // 'start' or 'end'
const pointerDownRange = (type) => (e) => {
draggingRangeHandle = type;
document.addEventListener('pointermove', pointerMoveRange);
document.addEventListener('pointerup', pointerUpRange, { once: true });
e.stopPropagation();
e.preventDefault();
};
const pointerMoveRange = (e) => {
if (!draggingRangeHandle) return;
const pos = clamp(positionFromEvent(e), 0, duration);
if (draggingRangeHandle === 'start') {
rangeStart = Math.min(pos, rangeEnd - 1);
} else {
rangeEnd = Math.max(pos, rangeStart + 1);
}
updateRangeVisual();
fireRangeChange();
};
const pointerUpRange = () => {
draggingRangeHandle = null;
document.removeEventListener('pointermove', pointerMoveRange);
};
startHandle.addEventListener('pointerdown', pointerDownRange('start'));
endHandle.addEventListener('pointerdown', pointerDownRange('end'));
/* ---------------- Ruler ---------------- */
const ruler = document.createElement('div');
ruler.className = 'timeline-ruler';
track.appendChild(ruler);
if (tickInterval > 0) {
for (let t = 0; t <= duration; t += tickInterval) {
const pct = (t / duration) * 100;
// Only create ticks for intervals of 5 and 10
if (t > 0 && t % 5 === 0) {
const tick = document.createElement('div');
tick.className = 'timeline-tick';
if (t % majorTickInterval === 0) {
tick.classList.add('timeline-tick-major');
} else if (t % 10 === 0) {
tick.classList.add('timeline-tick-ten');
} else {
tick.classList.add('timeline-tick-five');
}
tick.style.left = pct + '%';
ruler.appendChild(tick);
}
if (t % labelInterval === 0) {
const label = document.createElement('span');
label.className = 'timeline-label';
label.textContent = t;
label.style.left = pct + '%';
ruler.appendChild(label);
}
}
}
/* ---------------- Markers ---------------- */
const markerSet = new Set();
const createMarker = (time, color) => {
const marker = document.createElement('div');
marker.className = 'timeline-marker';
if (color) marker.style.backgroundColor = color;
const pct = (time / duration) * 100;
marker.style.left = pct + '%';
if (typeof onMarkerHover === 'function') {
marker.addEventListener('pointerenter', () => onMarkerHover(time, marker));
marker.addEventListener('pointerleave', () => onMarkerHover(null, null));
}
track.appendChild(marker);
};
// Render initial markers
markers.forEach(({ time, color }) => {
if (time < 0 || time > duration) return;
if (markerSet.has(time)) return;
markerSet.add(time);
createMarker(time, color);
});
// ----- Helpers -----
const setPosition = (pos) => {
const clamped = clamp(snap(pos), 0, duration);
const pct = (clamped / duration) * 100;
progressBar.style.width = pct + '%';
handle.style.left = pct + '%';
if (playheadLabel) {
playheadLabel.textContent = clamped.toFixed(0);
}
if (typeof onchange === 'function') onchange(clamped);
};
// Initial render
setPosition(current);
// ----- Interaction -----
let dragging = false;
const positionFromEvent = (e) => {
const rect = track.getBoundingClientRect();
const x = e.clientX - rect.left; // distance from left edge
const pct = clamp(x / rect.width, 0, 1);
return snap(pct * duration);
};
const pointerDown = (e) => {
dragging = true;
document.addEventListener('pointermove', pointerMove);
document.addEventListener('pointerup', pointerUp, { once: true });
setPosition(positionFromEvent(e));
e.preventDefault();
};
const pointerMove = (e) => {
if (!dragging) return;
setPosition(positionFromEvent(e));
};
const pointerUp = (e) => {
dragging = false;
document.removeEventListener('pointermove', pointerMove);
setPosition(positionFromEvent(e));
};
// Click on track or drag handle
track.addEventListener('pointerdown', pointerDown);
handle.addEventListener('pointerdown', pointerDown);
// Expose imperative API
container.setPosition = setPosition;
container.getPosition = () => +progressBar.style.width.replace('%', '') * duration / 100;
container.addMarker = (time, color) => {
const clamped = clamp(Math.round(time), 0, duration);
if (markerSet.has(clamped)) return;
markerSet.add(clamped);
createMarker(clamped, color);
};
container.setRange = (start, end) => {
rangeStart = clamp(snap(start), 0, duration);
rangeEnd = clamp(snap(end), rangeStart, duration);
updateRangeVisual();
};
container.getRange = () => ({ start: rangeStart, end: rangeEnd });
// Initial range callback
fireRangeChange();
return container;
};
})();