Packages
noora
0.83.2
0.84.1
0.84.0
0.83.2
0.83.1
0.83.0
0.82.7
0.82.6
0.82.5
0.82.4
0.82.3
0.82.2
0.82.1
0.82.0
0.81.4
0.81.3
0.81.2
0.81.1
0.81.0
0.80.1
0.80.0
0.79.1
0.79.0
0.78.1
0.78.0
0.77.4
0.77.3
0.77.2
0.77.1
0.77.0
0.76.0
0.75.0
0.74.0
0.73.0
0.72.0
0.71.0
0.70.0
0.69.1
0.69.0
0.68.0
0.67.0
0.66.0
0.65.0
0.64.2
0.64.1
0.64.0
0.63.2
0.63.1
0.63.0
0.62.0
0.61.0
0.60.0
0.59.0
0.58.0
0.57.0
0.56.1
0.56.0
0.55.0
0.54.0
0.53.1
0.53.0
0.52.1
0.52.0
0.51.0
0.50.1
0.50.0
0.49.0
0.48.0
0.47.0
0.46.0
0.45.0
0.44.3
0.44.2
0.44.1
0.44.0
0.43.0
0.42.0
0.41.0
0.40.6
0.40.5
0.40.4
0.40.3
0.40.2
0.40.1
0.40.0
0.39.1
0.39.0
0.38.0
0.37.0
0.36.0
0.35.0
0.34.0
0.33.0
0.32.1
0.32.0
0.31.0
0.30.0
0.29.2
0.29.1
0.29.0
0.28.5
0.28.4
0.28.3
0.28.2
0.28.1
0.28.0
0.27.0
0.26.1
0.26.0
0.25.0
0.24.0
0.23.1
0.23.0
0.22.1
0.22.0
0.21.0
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.0
0.14.0
0.13.0
0.12.1
0.12.0
0.11.2
0.11.1
0.11.0
0.10.0
0.9.0
0.8.0
0.7.0
0.6.1
0.6.0
0.5.1
0.5.0
0.4.0
0.3.2
0.3.1
0.3.0
0.2.2
0.2.0
0.1.0
0.1.0-rc.2
0.1.0-rc.1
0.1.0-alpha.6
0.1.0-alpha.5
0.1.0-alpha.4
0.1.0-alpha.3
0.1.0-alpha.2
0.1.0-alpha.1
A component library for Phoenix LiveView applications
Current section
Files
Jump to
Current section
Files
js/ScrollArea.js
// Overlay scrollbar for a scrollable viewport. The native scrollbar is hidden
// (so it never reserves a gutter or shifts content) and replaced by an
// absolutely positioned track + thumb. The thumb is sized and moved from the
// viewport's scroll metrics, and supports dragging and track-click paging.
// The track only renders for fine pointers; touch scrolling is untouched.
const MIN_THUMB_HEIGHT = 24;
export default {
mounted() {
this.viewport = this.el.querySelector(':scope > [data-part="viewport"]');
this.track = this.el.querySelector(':scope > [data-part="scrollbar"]');
this.thumb = this.track?.querySelector('[data-part="thumb"]');
if (!this.viewport || !this.track || !this.thumb) return;
this.update = this.update.bind(this);
this.onThumbDown = this.onThumbDown.bind(this);
this.onThumbMove = this.onThumbMove.bind(this);
this.onThumbUp = this.onThumbUp.bind(this);
this.onTrackDown = this.onTrackDown.bind(this);
this.onTrackWheel = this.onTrackWheel.bind(this);
this.viewport.addEventListener("scroll", this.update, { passive: true });
// Group expand/collapse animations change scrollHeight over time.
this.viewport.addEventListener("animationend", this.update);
this.thumb.addEventListener("pointerdown", this.onThumbDown);
this.thumb.addEventListener("pointermove", this.onThumbMove);
this.thumb.addEventListener("pointerup", this.onThumbUp);
this.thumb.addEventListener("pointercancel", this.onThumbUp);
this.track.addEventListener("pointerdown", this.onTrackDown);
// The track overlays the viewport's edge and isn't itself scrollable, so
// wheel events over it would fall through to the page scroll.
this.track.addEventListener("wheel", this.onTrackWheel, { passive: false });
this.resizeObserver = new ResizeObserver(this.update);
this.resizeObserver.observe(this.el);
this.resizeObserver.observe(this.viewport);
this.mutationObserver = new MutationObserver(this.update);
this.mutationObserver.observe(this.viewport, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-state", "hidden", "style"],
});
this.update();
},
updated() {
this.update();
},
metrics() {
const { scrollHeight, clientHeight, scrollTop } = this.viewport;
const trackHeight = this.track.clientHeight;
const thumbHeight = Math.max(
(clientHeight / scrollHeight) * trackHeight,
MIN_THUMB_HEIGHT,
);
return {
maxScroll: scrollHeight - clientHeight,
maxThumbY: trackHeight - thumbHeight,
thumbHeight,
scrollTop,
};
},
update() {
if (!this.viewport || !this.thumb) return;
const { scrollHeight, clientHeight, scrollTop } = this.viewport;
const scrollable = scrollHeight > clientHeight + 1;
this.el.toggleAttribute("data-scrollable", scrollable);
this.el.toggleAttribute(
"data-at-end",
!scrollable || scrollTop + clientHeight >= scrollHeight - 1,
);
if (!scrollable) return;
const m = this.metrics();
const y = m.maxScroll > 0 ? (m.scrollTop / m.maxScroll) * m.maxThumbY : 0;
this.thumb.style.height = `${m.thumbHeight}px`;
this.thumb.style.transform = `translateY(${y}px)`;
},
onThumbDown(event) {
event.preventDefault();
event.stopPropagation();
this.thumb.setPointerCapture(event.pointerId);
this.dragStartY = event.clientY;
this.dragStartScroll = this.viewport.scrollTop;
this.track.setAttribute("data-dragging", "");
},
onThumbMove(event) {
if (!this.track.hasAttribute("data-dragging")) return;
const m = this.metrics();
if (m.maxThumbY <= 0) return;
const delta = event.clientY - this.dragStartY;
this.viewport.scrollTop =
this.dragStartScroll + delta * (m.maxScroll / m.maxThumbY);
},
onThumbUp(event) {
this.thumb.releasePointerCapture(event.pointerId);
this.track.removeAttribute("data-dragging");
},
onTrackDown(event) {
if (event.target !== this.track) return;
const m = this.metrics();
if (m.maxThumbY <= 0) return;
const rect = this.track.getBoundingClientRect();
const y = event.clientY - rect.top - m.thumbHeight / 2;
this.viewport.scrollTop = (y / m.maxThumbY) * m.maxScroll;
},
onTrackWheel(event) {
event.preventDefault();
this.viewport.scrollTop += event.deltaY;
},
cleanup() {
this.resizeObserver?.disconnect();
this.mutationObserver?.disconnect();
this.viewport?.removeEventListener("scroll", this.update);
this.viewport?.removeEventListener("animationend", this.update);
this.track?.removeEventListener("wheel", this.onTrackWheel);
},
beforeDestroy() {
this.cleanup();
},
destroyed() {
this.cleanup();
},
};