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:
@@ -113,6 +113,11 @@
|
|||||||
border-color: var(--color-border-medium);
|
border-color: var(--color-border-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.photo-tile:focus-visible {
|
||||||
|
outline: 2px solid var(--color-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.photo-tile:hover img {
|
.photo-tile:hover img {
|
||||||
transform: scale(1.03);
|
transform: scale(1.03);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const photosView = {
|
|||||||
_resizeHandler: null,
|
_resizeHandler: null,
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
_resizeTimer: 0,
|
_resizeTimer: 0,
|
||||||
|
/** @type {string|null} Anchor id for shift-range selection */
|
||||||
|
_selectAnchorId: null,
|
||||||
|
|
||||||
PAGE_SIZE: 200,
|
PAGE_SIZE: 200,
|
||||||
|
|
||||||
@@ -202,6 +204,7 @@ const photosView = {
|
|||||||
// real elements so we keep references for the observer.
|
// real elements so we keep references for the observer.
|
||||||
this._container.innerHTML = this._renderToolbar();
|
this._container.innerHTML = this._renderToolbar();
|
||||||
this._container.onclick = (e) => this._handleClick(e);
|
this._container.onclick = (e) => this._handleClick(e);
|
||||||
|
this._container.onkeydown = (e) => this._handleKeydown(e);
|
||||||
|
|
||||||
const groups = this._groupItems(this.items);
|
const groups = this._groupItems(this.items);
|
||||||
for (const [label, files] of groups) {
|
for (const [label, files] of groups) {
|
||||||
@@ -436,7 +439,7 @@ const photosView = {
|
|||||||
const selected = this.selected.has(file.id) ? ' selected' : '';
|
const selected = this.selected.has(file.id) ? ' selected' : '';
|
||||||
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
|
const cachedThumb = isVideo && this._videoThumbCache.has(file.id) ? this._videoThumbCache.get(file.id) : null;
|
||||||
const thumbUrl = cachedThumb || `/api/files/${file.id}/thumbnail/preview`;
|
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>`;
|
h += `<div class="photo-check"><i class="fas fa-check"></i></div>`;
|
||||||
const srcset = cachedThumb
|
const srcset = cachedThumb
|
||||||
? ''
|
? ''
|
||||||
@@ -599,9 +602,16 @@ const photosView = {
|
|||||||
const id = tile.dataset.id;
|
const id = tile.dataset.id;
|
||||||
const check = target.closest('.photo-check');
|
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 clicking checkbox or in selection mode, toggle select
|
||||||
if (check || this.selected.size > 0) {
|
if (check || this.selected.size > 0) {
|
||||||
this._toggleSelect(id, tile);
|
this._toggleSelect(id, tile);
|
||||||
|
this._selectAnchorId = id || null;
|
||||||
return;
|
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
|
* Toggle selection of an item
|
||||||
* @param {string} id
|
* @param {string} id
|
||||||
|
|||||||
Reference in New Issue
Block a user