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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JW6ghFMDtnRYuYNzZhb47M
This commit is contained in:
Claude
2026-06-19 10:01:19 +00:00
parent e8520e485f
commit 824df4d421
2 changed files with 59 additions and 1 deletions
+5
View File
@@ -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);
}
+54 -1
View File
@@ -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 = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}">`;
let h = `<div class="photo-tile${selected}" data-id="${this._escAttr(file.id)}" data-mime="${this._escAttr(file.mime_type)}" data-name="${this._escAttr(file.name)}" tabindex="0" role="button" aria-label="${this._escAttr(file.name)}">`;
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
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