Skip to content

Commit

Permalink
Implement scrolltarget and related focus behaviors.
Browse files Browse the repository at this point in the history
  • Loading branch information
flackr committed May 10, 2024
1 parent 5e4894a commit 20115b8
Show file tree
Hide file tree
Showing 4 changed files with 223 additions and 51 deletions.
22 changes: 12 additions & 10 deletions examples/carousel/image/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,14 @@
cursor: pointer;
width: 1em;
height: 1em;
margin: 3px;
border-radius: 100%;
border: 2px solid #777;
background: transparent content-box;

&:checked {
background: Highlight content-box;
background: SelectedItem content-box;
}
&:focus {
border-color: Highlight;
}
}
Expand Down Expand Up @@ -104,14 +106,14 @@ <h1>Carousel example</h1>
The number of scroll markers also dynamically changes with the number of pages.
</p>
<ul class="carousel">
<li><figure><img src="images/loc1.jpg"><figcaption>Item 1</figcaption></figure></li>
<li><figure><img src="images/loc2.jpg"><figcaption>Item 2</figcaption></figure></li>
<li><figure><img src="images/loc3.jpg"><figcaption>Item 3</figcaption></figure></li>
<li><figure><img src="images/loc4.jpg"><figcaption>Item 4</figcaption></figure></li>
<li><figure><img src="images/loc5.jpg"><figcaption>Item 5</figcaption></figure></li>
<li><figure><img src="images/loc6.jpg"><figcaption>Item 6</figcaption></figure></li>
<li><figure><img src="images/loc7.jpg"><figcaption>Item 7</figcaption></figure></li>
<li><figure><img src="images/loc1.jpg"><figcaption>Item 8</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc1.jpg"><figcaption>Item 1</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc2.jpg"><figcaption>Item 2</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc3.jpg"><figcaption>Item 3</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc4.jpg"><figcaption>Item 4</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc5.jpg"><figcaption>Item 5</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc6.jpg"><figcaption>Item 6</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc7.jpg"><figcaption>Item 7</figcaption></figure></li>
<li tabindex="0"><figure><img src="images/loc1.jpg"><figcaption>Item 8</figcaption></figure></li>
</ul>
<h2>The code</h2>
<p>
Expand Down
55 changes: 55 additions & 0 deletions examples/scroll-marker/scrolltarget/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>scrolltarget demo</title>
<script src="../../../polyfill/polyfill.js"></script>
<style>
body {
padding-left: 200px;
}
.toc {
position: fixed;
top: 10px;
left: 10px;
box-sizing: border-box;
padding: 8px;
width: 180px;
background: rgba(255, 255, 255, 0.8);
border: 2px solid black;
border-radius: 4px;
}
section {
height: 100vh;
}
li {
cursor: pointer;
}
li:checked {
color: blue;
list-style-type: '👉';
}
</style>
</head>
<body>
<div class="toc">
<p>Table of contents:</p>
<ul>
<li scrolltarget="s1">Section 1</li>
<li scrolltarget="s2">Section 2</li>
<li scrolltarget="s3">Section 3</li>
</ul>
</div>
<section id="s1">
This is an example of how you can use the <a href="https://github.com/flackr/carousel/tree/main/scroll-marker#elements">scrolltarget</a> attribute
to create navigation points which track and take you to various points in the document.
</section>
<section id="s2">
For a given scrolling box, one marker is determined to be the <a href="https://github.com/flackr/carousel/tree/main/scroll-marker#the-active-marker">active marker</a>.
</section>
<section id="s3">
When asked to <a href="https://drafts.csswg.org/cssom-view/#document-run-the-scroll-steps">run the scroll steps</a> the active marker should be updated according to the eventual scroll location that the scroller will reach based on the current scrolling operation.
</section>
</body>
</html>
193 changes: 153 additions & 40 deletions polyfill/polyfill.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,14 +173,14 @@ function isOffsetAncestor(anc, child) {
while (child) {
if (child == anc)
return true;
child = child.offsetParent;
child = child.offsetParent || window;
}
return false;
}

function commonOffsetParent(e1, e2) {
while (!isOffsetAncestor(e1, e2)) {
e1 = e1.offsetParent;
e1 = e1.offsetParent || window;
}
return e1;
}
Expand All @@ -194,12 +194,12 @@ function relativeOffset(e1, e2) {
while (e1 != ancestor) {
offset.offsetLeft -= e1.offsetLeft;
offset.offsetTop -= e1.offsetTop;
e1 = e1.offsetParent;
e1 = e1.offsetParent || window;
}
while (e2 != ancestor) {
offset.offsetLeft += e2.offsetLeft;
offset.offsetTop += e2.offsetTop;
e2 = e2.offsetParent;
e2 = e2.offsetParent || window;
}
return offset;
}
Expand Down Expand Up @@ -313,6 +313,8 @@ function updateSelectors(selector, forPseudo) {
// and target that particular subtree.
replace(/.*[^ >+,]*::scroll-marker$/g, forPseudo?'scroll-markers>.scroll-marker::before':'scroll-markers>.scroll-marker').
replace(/.*[^ >+,]*::scroll-marker:checked$/g, forPseudo?'scroll-markers>.scroll-marker:checked::before':'scroll-markers>.scroll-marker:checked').
replace(/.*[^ >+,]*::scroll-marker:focus$/g, forPseudo?'scroll-markers>.scroll-marker:focus::before':'scroll-markers>.scroll-marker:focus').
replace(/:checked/g, ':is(.checked,:checked)').
replace(/.*[^ >+,]*::scroll-markers$/g, 'scroll-markers');
}

Expand Down Expand Up @@ -350,26 +352,30 @@ let markerVars = new Set();
let flowSelectors = new Set();

function handleScroll() {
const markers = this.scrollMarkerArea?.children;
const scrollerElement = this == window ? document.documentElement : this;
const markers = scrollerElement.scrollMarkers;
if (!markers || markers.length == 0)
return;
const behavior = getComputedStyle(this.scrollMarkerArea).scrollBehavior;
for (const marker of markers) {
const element = marker.originatingElement;
const element = marker.scrollTargetElement;
if (!element) continue;
let position = relativeOffset(this, element);
position.offsetLeft -= this.scrollLeft;
position.offsetTop -= this.scrollTop;
position.offsetLeft -= scrollerElement.scrollLeft;
position.offsetTop -= scrollerElement.scrollTop;
// TODO: Consider snap-align, scroll-margin and scroll-padding.
let intersection = [
Math.max(0, position.offsetLeft), Math.max(0, position.offsetTop),
Math.min(position.offsetLeft + element.offsetWidth, this.clientWidth),
Math.min(position.offsetTop + element.offsetHeight, this.clientHeight)
Math.min(position.offsetLeft + element.offsetWidth, scrollerElement.clientWidth),
Math.min(position.offsetTop + element.offsetHeight, scrollerElement.clientHeight)
];
let area = (intersection[2] - intersection[0]) * (intersection[3] - intersection[1]);
if (area > 0 && area >= element.offsetWidth * element.offsetHeight * 0.5) {
marker.checked = true;
// TODO: This should not scroll ancestor scrollers.
marker.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'auto'});
setActiveMarker(marker, false);
const markerScroller = ancestorScroller(marker);
if (markerScroller != document.scrollingElement && markerScroller != scrollerElement) {
// TODO: This should not scroll ancestor scrollers.
marker.scrollIntoView({block: 'nearest', inline: 'nearest', behavior: 'auto'});
}
break;
}
}
Expand All @@ -379,19 +385,20 @@ function resetHandleScroll() {
this.onscroll = handleScroll;
}

function addMarker(elem, usedProps) {
function addPseudoMarker(elem, usedProps) {
if (elem.markerElement) {
return;
}
const scroller = eventTarget(ancestorScroller(elem));
if (!scroller.scrollMarkerArea)
return;
let marker = document.createElement('input');
let marker = document.createElement('button');
elem.pseudoElements = elem.pseudoElements || [];
elem.pseudoElements.push(marker);
elem.markerElement = marker;
marker.originatingElement = elem;
marker.className = 'scroll-marker';
marker.scrollTargetElement = elem;

// Copy attributes from originating element so attr functions work.
const EXCLUDED = ['name', 'type', 'class', 'style'];
for (let name of elem.getAttributeNames()) {
Expand All @@ -404,28 +411,9 @@ function addMarker(elem, usedProps) {
for (let name of markerVars) {
marker.style.setProperty(name, cs.getPropertyValue(name));
}
marker.setAttribute('type', 'radio');
// TODO: Name radio buttons something unique per scrollable area.
marker.setAttribute('name', 'scroll-marker');
marker.addEventListener('input', () => {
const scroller = eventTarget(ancestorScroller(elem));
let target = relativeOffset(scroller, elem);
target.offsetLeft = Math.max(0, Math.min(scroller.scrollWidth - scroller.clientWidth, target.offsetLeft));
target.offsetTop = Math.max(0, Math.min(scroller.scrollHeight - scroller.clientHeight, target.offsetTop));
if (target.offsetLeft != scroller.scrollLeft || target.offsetTop != scroller.scrollTop) {
scroller.onscroll = undefined;
scroller.scrollTo({
top: target.offsetTop,
left: target.offsetLeft,
behavior: 'smooth'
});
}
});

marker.className = 'scroll-marker';
scroller.scrollMarkerArea.appendChild(marker);
// TODO: Sort markers by DOM order.
scroller.onscroll = handleScroll;
scroller.onscrollend = resetHandleScroll;
handleScroll.apply(scroller);
}

function update() {
Expand All @@ -440,6 +428,10 @@ function update() {
.scroll-marker {
appearance: none;
display: block;
/* button resets */
border: 0;
padding: 0;
background: none;
}
scroll-markers {
contain: size;
Expand Down Expand Up @@ -615,7 +607,11 @@ scroll-markers {
// Process elements with scroll-markers
let markers = [];
for (let elem of getElems(markerSelectors)) {
addMarker(elem);
addPseudoMarker(elem);
}
for (let elem of document.querySelectorAll('[scrolltarget]')) {
let target = document.getElementById(elem.getAttribute('scrolltarget'));
elem.scrollTargetElement = target;
}

// Process fragmented elements.
Expand All @@ -624,6 +620,121 @@ scroll-markers {
}
}

const SCROLL_MARKER_HANDLERS = {
'focus': function(evt) {
const elem = this.scrollTargetElement;
const scroller = eventTarget(ancestorScroller(elem));
setActiveMarker(this, true);
},
'click': function(evt) {
this.scrollTargetElement.focus();
},
'keydown': function(evt) {
const DIRS = {
'ArrowUp': -1,
'ArrowLeft': -1,
'ArrowDown': 1,
'ArrowRight': 1,
};
const dir = DIRS[evt.code];
if (!dir)
return;
const elem = this.scrollTargetElement;
const scrollerElement = ancestorScroller(elem);
const markers = scrollerElement.scrollMarkers;
let index = markers.indexOf(this);
if (index == -1)
return;
evt.preventDefault();
index = (markers.length + index + dir) % markers.length;
markers[index].focus();
}
};

function addScrollMarker(marker) {
const elem = marker.scrollTargetElement;
if (!elem || !elem.isConnected)
return;
const scrollerElement = ancestorScroller(elem)
const scroller = eventTarget(scrollerElement);
if (scrollerElement.scrollMarkers && scrollerElement.scrollMarkers.indexOf(marker) != -1) {
return;
}
elem.setAttribute('tabindex', -1);

for (let eventType in SCROLL_MARKER_HANDLERS) {
marker.addEventListener(eventType, SCROLL_MARKER_HANDLERS[eventType]);
}
// TODO: Sort markers by DOM order.
scrollerElement.scrollMarkers = scrollerElement.scrollMarkers || [];
scrollerElement.scrollMarkers.push(marker);
scrollerElement.scrollMarkers = scrollerElement.scrollMarkers.sort((a, b) => {
// https://developer.mozilla.org/en-US/docs/Web/API/Node/compareDocumentPosition
return a.compareDocumentPosition(b) == 2; // DOCUMENT_POSITION_PRECEDING
});
scroller.onscroll = handleScroll;
scroller.onscrollend = resetHandleScroll;
handleScroll.apply(scroller);
}

function removeScrollMarker(marker) {
const elem = marker.scrollTargetElement;
const scrollerElement = ancestorScroller(elem)
const scroller = eventTarget(scrollerElement);
if (!elem || !elem.isConnected)
return;
elem.removeAttribute('tabindex');
for (let eventType in SCROLL_MARKER_HANDLERS) {
marker.removeEventListener(eventType, SCROLL_MARKER_HANDLERS[eventType]);
}
scrollerElement.scrollMarkers.splice(scrollerElement.scrollMarkers.indexOf(marker), 1);
}

function setActiveMarker(marker, scrollTo) {
const elem = marker.scrollTargetElement;
if (!elem)
return;
const scrollerElement = ancestorScroller(elem);
const scroller = eventTarget(scrollerElement);
const markers = scrollerElement.scrollMarkers;
if (scrollTo) {
let target = relativeOffset(scroller, elem);
const scrollerElement = scroller == window ? document.documentElement : scroller;
target.offsetLeft = Math.max(0, Math.min(scrollerElement.scrollWidth - scrollerElement.clientWidth, target.offsetLeft));
target.offsetTop = Math.max(0, Math.min(scrollerElement.scrollHeight - scrollerElement.clientHeight, target.offsetTop));
if (target.offsetLeft != scrollerElement.scrollLeft || target.offsetTop != scrollerElement.scrollTop) {
scroller.onscroll = undefined;
scroller.scrollTo({
top: target.offsetTop,
left: target.offsetLeft,
behavior: 'smooth'
});
}
}
for (const m of markers) {
if (m == marker) continue;
m.checked = false;
m.classList.remove('checked');
m.setAttribute('tabindex', -1);
}
marker.checked = true;
marker.classList.add('checked');
marker.setAttribute('tabindex', 0);
}

Object.defineProperty(Element.prototype, 'scrollTargetElement', {
configurable: true, enumerable: true,
get: function() { return this.__scrollTargetElement; },
set: function(y) {
removeScrollMarker(this);
this.__scrollTargetElement = y;
addScrollMarker(this);
}
});
Object.defineProperty(Element.prototype, '__scrollTargetElement', {
configurable: true, writable: true, enumerable: true, value :""
});

Element.prototype.originalInsertBefore = Element.prototype.insertBefore;
Element.prototype.originalRemoveChild = Element.prototype.removeChild;
Element.prototype.insertBefore = function(node, child) {
Expand All @@ -648,13 +759,15 @@ Element.prototype.insertBefore = function(node, child) {
}
let marker = cs.getPropertyValue('--scroll-marker');
if (marker == 'yes') {
addMarker(node);
addPseudoMarker(node);
}
addScrollMarker(node);
}
Element.prototype.appendChild = function(node) {
this.insertBefore(node, null);
}
Element.prototype.removeChild = function(node) {
removeScrollMarker(node);
// Remove pseudo elements generated by this element, e.g. the scroll marker for the fragment.
if (node.pseudoElements) {
for (let pseudo of node.pseudoElements) {
Expand Down
Loading

0 comments on commit 20115b8

Please sign in to comment.