From 824df4d42173296ee0e39c3a1723e6d3ffa05de3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:01:19 +0000 Subject: [PATCH] feat(photos): keyboard a11y + shift-click range selection - Tiles are focusable (tabindex / role=button / aria-label) with a :focus-visible ring; Enter opens the lightbox (or toggles in selection mode), Space toggles selection. - Shift-click extends the selection from the last anchor across the timeline; the range is tracked in the selection Set so it spans dematerialized (off-screen) groups, with visible tiles updated at once. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M --- static/css/views/photos.css | 5 +++ static/js/features/library/photos.js | 55 +++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/static/css/views/photos.css b/static/css/views/photos.css index 33221c9a..43ab6418 100644 --- a/static/css/views/photos.css +++ b/static/css/views/photos.css @@ -113,6 +113,11 @@ border-color: var(--color-border-medium); } +.photo-tile:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + .photo-tile:hover img { transform: scale(1.03); } diff --git a/static/js/features/library/photos.js b/static/js/features/library/photos.js index 437de6ff..8a116a6d 100644 --- a/static/js/features/library/photos.js +++ b/static/js/features/library/photos.js @@ -56,6 +56,8 @@ const photosView = { _resizeHandler: null, /** @type {number} */ _resizeTimer: 0, + /** @type {string|null} Anchor id for shift-range selection */ + _selectAnchorId: null, PAGE_SIZE: 200, @@ -202,6 +204,7 @@ const photosView = { // real elements so we keep references for the observer. this._container.innerHTML = this._renderToolbar(); this._container.onclick = (e) => this._handleClick(e); + this._container.onkeydown = (e) => this._handleKeydown(e); const groups = this._groupItems(this.items); for (const [label, files] of groups) { @@ -436,7 +439,7 @@ const photosView = { const selected = this.selected.has(file.id) ? ' selected' : ''; const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null; const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`; - let h = `
`; + let h = `
`; h += `
`; const srcset = cachedThumb ? '' @@ -599,9 +602,16 @@ const photosView = { const id = tile.dataset.id; const check = target.closest('.photo-check'); + // Shift-click extends the selection from the last anchor. + if (id && e.shiftKey && this._selectAnchorId) { + this._selectRange(this._selectAnchorId, id); + return; + } + // If clicking checkbox or in selection mode, toggle select if (check || this.selected.size > 0) { this._toggleSelect(id, tile); + this._selectAnchorId = id || null; return; } @@ -612,6 +622,49 @@ const photosView = { } }, + /** + * Select every item between the anchor and the target (inclusive), in + * timeline order. Tracked in the Set so it survives dematerialized + * groups; currently-visible tiles get the class applied immediately. + * @param {string} anchorId + * @param {string} toId + */ + _selectRange(anchorId, toId) { + const a = this.items.findIndex((f) => f.id === anchorId); + const b = this.items.findIndex((f) => f.id === toId); + if (a < 0 || b < 0) return; + const lo = Math.min(a, b); + const hi = Math.max(a, b); + for (let i = lo; i <= hi; i++) this.selected.add(this.items[i].id); + this._container?.querySelectorAll('.photo-tile').forEach((el) => { + const t = /** @type {HTMLElement} */ (el); + if (t.dataset.id && this.selected.has(t.dataset.id)) t.classList.add('selected'); + }); + this._selectAnchorId = toId; + this._updateSelectionBar(); + }, + + /** + * Keyboard activation for focused tiles: Enter opens the lightbox (or + * toggles selection when in selection mode); Space toggles selection. + * @param {KeyboardEvent} e + */ + _handleKeydown(e) { + if (e.key !== 'Enter' && e.key !== ' ') return; + const target = /** @type {Element} */ (e.target); + const tile = /** @type {HTMLDivElement} */ (target.closest('.photo-tile')); + if (!tile) return; + e.preventDefault(); + const id = tile.dataset.id; + if (e.key === ' ' || this.selected.size > 0) { + this._toggleSelect(id, tile); + this._selectAnchorId = id || null; + return; + } + const idx = this.items.findIndex((f) => f.id === id); + if (idx >= 0) photosLightbox.open(this.items, idx); + }, + /** * Toggle selection of an item * @param {string} id