feat(images): card selection checkbox and the concept selection bar

Every Images card's overlay starts with the concept's "Select <filename>"
checkbox before the star. Checked image keys form the page's selection,
kept across Load more, infinite scroll and bounded re-mounting and cleared
whenever the collection is replaced. The #bulkbar between the filter row
and the gallery shows "N selected" with Set category, Export metadata and
Clear (the first two are wired by the next tasks); Clear empties it.

The interim D8 star rule is removed, and the concept comparison now
measures the overlay with its checkbox and the bar with one card checked.
This commit is contained in:
Vito Sansevero
2026-10-20 13:54:09 -07:00
parent 86d82186ee
commit d7f1b9987f
8 changed files with 297 additions and 37 deletions
+13 -7
View File
@@ -440,8 +440,8 @@ own Load more has loaded all 24 fixture images (D37: it loads 12 per batch, prod
zoom, the 2nd card opens the viewer titled "<title> · <filename>", Escape returns focus
to it (production only: the concept re-renders its grid and drops focus), Info on the
3rd card moves `.selected`, searches for "alpine" and for one image, and Load more with
its toast. `mapImagesConcept` applies D3, D8 and D16 (follow-up PRs: no checkbox, no
Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
its toast. `mapImagesConcept` applies D3, D8 (the selection bar's real actions only),
D16 (follow-up PR: no Save view), D9, D10, D14, D15, D17, D29, D32, D35 (singular counts) and D36 (categories
sorted alphabetically). Removed toolbar and filter-row items flow again from the
concept's own boxes (`reflow`), checked against the concept page with those items
removed at runtime. Masonry tops and heights are compared unrounded, within one layout
@@ -500,9 +500,15 @@ Recorded deviations on this page (spec §8):
- **D5**, **D29**: no Activity button and no prototype copy (shell).
- **D7**: the star is a real persisted favorite with "Added to favorites." /
"Removed from favorites." and a rollback toast on failure.
- **D8 (follow-up)**: no select checkbox or bulk bar in PR C; a follow-up PR adds them
with real actions and removes the interim `.image-overlays > .star-button:only-child`
rule that keeps the star top-right.
- **D8**: every Images card has the concept's select checkbox before the star; the
`#bulkbar` shows "N selected" and only the real actions in the concept's order (Set
category, Export metadata, Clear). Add tags and Add to collection wait for image tags
and collections; "Remove links…" is dropped. The selection survives Load more and
bounded re-mounting and is cleared whenever the collection is replaced (search,
category, sort, Filters, source change, rescan, reload). `images selection bar matches
the concept` compares the bar and the checked card with one card checked; the omitted
actions flow again from the concept's own boxes, checked against the concept page with
them removed at runtime.
- **D9**: no Tag button until the image-annotations slice.
- **D10**: the card title is the linked positive's display title, else the filename;
the `h3` keeps the relative path in `title`.
@@ -967,8 +973,8 @@ page lifecycle recovery retain their separate behavioral tests.
Initial production acceptance requires maintainer inspection of paired PNGs and
geometry; never update snapshots automatically or mask content to bless a gap.
Data-dependent text/images/counts and the clearly labeled saved/frozen/current
distinctions are documented in the comparison helper. The Images card select
checkbox (#249 D8 follow-up) and tag button (#249 D9) and shared role-block
distinctions are documented in the comparison helper. The Images card tag
button (#249 D9) and shared role-block
spacing/padding/glyph polish remain outside #209. The
responsive production Viewer keeps its toolbar in the media panel and prompt details readable rather than reproducing the concept's
mobile toolbar collision. These are explicit remaining visual differences, not
+8 -3
View File
@@ -92,9 +92,10 @@ export async function mutate(page,{css=null,remove=[]}={}) {
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, caption, grid, cards, continuation, panel, library, history,
* noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7) measure
* the concept's Images markup; `cards` holds the first six. Library and history stay null until PRs E and F.
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
* measure the concept's Images markup; `cards` holds the first six; `bulkbar` is the selection bar's place and, while
* anything is checked, its `.bulkbar` row. Library and history stay null until PRs E and F.
*/
export function inventoryOf(page,scope='main') {
return page.evaluate(measureInventory,scope);
@@ -329,9 +330,13 @@ export function measureInventory(scope) {
box:when(panel,()=>{const box=relative(panel,gallery);return {left:box.left,top:box.top,width:box.width,...css(panel,'position','top')};})};
}
const continuation=body?.querySelector(':scope > .infinite-continuation');
function bulkbarOf(bar) {
return {id:bar.id||null,...relative(bar,main),children:bar.children.length,bar:when(bar.querySelector(':scope > .bulkbar'),rowOf)};
}
return {chrome:chromeOf(),heading:headingOf(),
toolbar:when(main?.querySelector(':scope > search.toolbar'),rowOf),
filterRow:when(main?.querySelector(':scope > .filter-row'),rowOf),
bulkbar:when(main?.querySelector(':scope > #bulkbar'),bulkbarOf),
caption:when(body?.querySelector(':scope > .section-caption'),captionOf),
grid:when(grid,gridOf),
cards:grid?[...grid.querySelectorAll(':scope > .media-card')].slice(0,6).map(cardOf):null,
+64 -18
View File
@@ -86,22 +86,32 @@ function withoutItems(row,isRemoved) {
return reflow(row,row.items.filter(item=>!isRemoved(item)));
}
/** D8: the selection bar's real actions only; Add tags, Add to collection and Remove links… are left out. */
const OMITTED_BULK=Object.freeze(['Add tags','Add to collection','Remove links…']);
const OMITTED_BULK_SELECTORS=Object.freeze(['bulk-tags','bulk-collection','bulk-remove'].map(action=>`#bulkbar [data-action="${action}"]`));
/**
* D8 (follow-up PR): the overlay row without its checkbox label. The concept stretches the star to the label's line box
* (`align-items: normal`); alone, the row and the star are the star's own height: its min-height, or its line plus
* padding and border when taller. Its place stays the concept's top-right (`rightInset`).
* `withoutItems` for a row with padding and a border (the `.bulkbar`): its items flow again inside the content box,
* measured from the border box as the inventory records them. The concept's border is one width on every side
* (`border: 1px solid`), so the right border stands for the left one.
*/
function withoutCheckbox(overlays) {
// A list row hides its overlays (concept `display: none`).
if(!overlays.shown)return overlays;
const items=overlays.items.filter(item=>item.tag!=='label');
const own=item=>Math.max(parseFloat(item.minHeight)||0,parseFloat(item.lineHeight)+['paddingTop','paddingBottom','borderTopWidth','borderBottomWidth']
.reduce((sum,key)=>sum+parseFloat(item[key]),0));
const height=Math.max(...items.filter(item=>item.shown).map(own));
// D8 (interim): alone, the star is pushed to the right by `margin-left: auto`, whose used value is the overlay's
// free width; the checkbox PR removes that rule and this line.
const pushed=item=>items.length===1?{marginLeft:`${overlays.width-item.width}px`}:{};
return {...overlays,height,items:items.map(item=>item.shown?{...item,height,...pushed(item)}:item)};
function withoutPaddedItems(row,isRemoved) {
const [left,right,top,bottom]=[['paddingLeft','borderRightWidth'],['paddingRight','borderRightWidth'],
['paddingTop','borderTopWidth'],['paddingBottom','borderBottomWidth']].map(keys=>keys.reduce((sum,key)=>sum+parseFloat(row[key]),0));
const shift=(item,x,y)=>item.left===undefined?item:{...item,left:item.left+x,top:item.top+y,
exact:item.exact&&{...item.exact,left:item.exact.left+x}};
const inner={...row,width:row.width-left-right,exact:{width:row.exact.width-left-right},items:row.items.map(item=>shift(item,-left,-top))};
const flowed=withoutItems(inner,isRemoved);
return {...flowed,width:row.width,exact:row.exact,height:flowed.height+top+bottom,items:flowed.items.map(item=>shift(item,left,top))};
}
/** D8: the bar without the omitted actions, laid out again from the concept's boxes; what follows moves up by as much. */
function mapBulkbar(bulkbar,rise) {
if(!bulkbar)return {bulkbar,shrink:0};
if(!bulkbar.bar)return {bulkbar:{...bulkbar,top:bulkbar.top-rise},shrink:0};
const bar=withoutPaddedItems(bulkbar.bar,item=>OMITTED_BULK.includes(item.text));
const shrink=bulkbar.bar.height-bar.height;
return {bulkbar:{...bulkbar,top:bulkbar.top-rise,height:bulkbar.height-shrink,bar:{...bar,top:bar.top-rise}},shrink};
}
/**
@@ -165,8 +175,6 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
return {...card,
// D29: the concept's alt adds its demo-only " · sample illustration N" to the card title.
img:{...card.img,alt:card.img.alt.replace(/ · sample illustration \d+$/,'')},
// D8 (follow-up PR): no selection checkbox yet; the star keeps the concept's top-right place.
overlays:withoutCheckbox(card.overlays),
info:{...card.info,...listRow.info,copy:{...card.info.copy,...listRow.copy},p:{...card.info.p,...listRow.p},
// D10: the title keeps the file's relative path (the fixture manifest's) in `title`.
h3:{...card.info.h3,title:paths.get(filename)??null},
@@ -178,7 +186,8 @@ export function mapImagesConcept(reference,{manifest,now=new Date()}) {
const caption={...reference.caption,spans:reference.caption.spans.map((span,index)=>index===1
?{...span,text:captionDay(manifest.newest_modified,now)}:{...span,text:countCopy(span.text)})};
const continuation=mapContinuation(reference.continuation);
return {...reference,toolbar,filterRow,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise}};
const {bulkbar,shrink}=mapBulkbar(reference.bulkbar,rise);
return {...reference,toolbar,filterRow,bulkbar,cards,caption,continuation,panel:{...reference.panel,top:reference.panel.top-rise-shrink}};
}
/** D32: the continuation's text and Load more may wrap at extreme zoom; D35: its count uses the singular for one. */
@@ -204,7 +213,7 @@ function mapImportConcept(reference) {
?{...row,description:'Find files added or removed in the output folders.',buttons:['Rescan']}:row)};
}
const SECTIONS=['heading','toolbar','filterRow','caption','grid','cards','continuation','panel','noPageScroll'];
const SECTIONS=['heading','toolbar','filterRow','bulkbar','caption','grid','cards','continuation','panel','noPageScroll'];
const pick=inventory=>stripExact(inventory);
// The concept's own sample categories, in its first-appearance order (app.js 348): what D36 reorders.
@@ -471,6 +480,43 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`images page ge
}
});
// D8 follow-up (image selection, Task 1): with the first card checked, the concept's bar (app.js 430-434) and the
// checked card, against production's bar of real actions. The concept page itself, without the omitted actions, is the
// oracle for the re-laid-out bar.
for(const [width,height] of SIZES)for(const theme of THEMES)test(`images selection bar matches the concept ${width}x${height} ${theme}`,async({page},testInfo)=>{
await page.setViewportSize({width,height});
const manifest=testInfo.manifest;
const check=async()=>{
await page.locator(CARDS).first().locator('.image-overlays > label > input[type="checkbox"]').check();
await expect(page.locator('#bulkbar > .bulkbar > strong')).toHaveText('1 selected');
await page.evaluate(()=>document.activeElement?.blur());await page.mouse.move(0,0);await settle(page);
};
await openConceptLoaded(page,theme,'no-preference');
const empty=await inventoryOf(page,'#main');
await check();
const reference=await inventoryOf(page,'#main');
const restore=await mutate(page,{remove:[...OMITTED_BULK_SELECTORS]});
const removed=(await inventoryOf(page,'#main')).bulkbar;
await restore();
await captureCanonicalState(page,testInfo,`concept-images-selected-${width}-${theme}`);
await openProductionLoaded(page,theme,'no-preference');
const emptyActual=await inventoryOf(page,'#workspace-main');
await check();
const actual=await inventoryOf(page,'#workspace-main');
await captureCanonicalState(page,testInfo,`production-images-selected-${width}-${theme}`);
// The concept itself: an empty bar, then "1 selected" and its six actions.
expect.soft(empty.bulkbar,'concept empty bar').toMatchObject({id:'bulkbar',height:0,children:0,bar:null});
expect.soft(reference.bulkbar.bar.items.map(item=>item.text),'concept bar').toEqual(['1 selected','Add tags','Set category',
'Add to collection','Export metadata','Remove links…','Clear']);
expect.soft(diffInventory(stripExact(mapBulkbar(reference.bulkbar,0).bulkbar.bar),stripExact(removed.bar)),'bar without the omitted actions').toEqual([]);
expect.soft(actual.bulkbar.bar.items.map(item=>item.text),'production bar').toEqual(['1 selected','Set category','Export metadata','Clear']);
expect.soft(compare(empty,emptyActual,manifest,['bulkbar','cards','panel']),'nothing checked').toEqual([]);
expect.soft(compare(reference,actual,manifest,['bulkbar','cards','panel']),'one card checked').toEqual([]);
// The bar comparison notices a change to the production bar.
await mutate(page,{css:'.bulkbar { padding: 4px }'});
expect(compare(reference,await inventoryOf(page,'#workspace-main'),manifest,['bulkbar']).map(item=>item.path)).toContain('bulkbar.bar.paddingTop');
});
// The comparison is only worth something if it notices a change (spec §7 mutation check): one CSS mutation (the
// `.media-info` padding) and one DOM mutation (the Inspect buttons removed), each applied after an exact match.
test('images comparison detects mutations',async({page},testInfo)=>{
+130 -3
View File
@@ -1014,10 +1014,11 @@ async function cardInventory(page, concept) {
const walk = new Function(`return (${walkSource})()`)();
return nodes.map(card => {
const copy = card.cloneNode(true);
// D8: no selection checkbox; D9: no tag button until image annotations. The D7 star is compared.
if (concept) for (const node of copy.querySelectorAll('.image-overlays > label, [aria-label^="Tag or auto-tag "]')) node.remove();
// D9: no tag button until image annotations. The D8 checkbox and the D7 star are compared.
if (concept) for (const node of copy.querySelectorAll('[aria-label^="Tag or auto-tag "]')) node.remove();
const open = card.querySelector('.image-open'), img = open.querySelector('img');
const star = card.querySelector('.image-overlays > button.star-button');
const box = card.querySelector('.image-overlays > label > input');
return { filename: open.getAttribute('aria-label').replace(/^Open /, ''),
// The concept's alt is the card title plus its demo-only " · sample illustration N" (m5).
alt: img.alt.replace(/ · sample illustration \d+$/, ''),
@@ -1026,6 +1027,7 @@ async function cardInventory(page, concept) {
ratio: card.style.getPropertyValue('--media-ratio').replace(/\s/g, ''),
inspect: card.querySelector('.image-card-actions > button').getAttribute('aria-label'),
star: star && { text: star.textContent, pressed: star.getAttribute('aria-pressed'), label: star.getAttribute('aria-label') },
select: box && { type: box.type, checked: box.checked, label: box.getAttribute('aria-label') },
paths: walk(copy, `article.${[...card.classList].filter(value => value !== 'selected').sort().join('.')}`, []) };
});
}, { walkSource: CARD_PATHS.toString(), concept });
@@ -1187,7 +1189,9 @@ async function zoomedGallery(page, request, { cssZoom }) {
});
await page.keyboard.press('ArrowDown');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-open`)).toBeFocused();
// The concept's card order: image, the D7 star, then Inspect.
// The concept's card order: image, the D8 checkbox, the D7 star, then Inspect.
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays input[type="checkbox"]`)).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator(`article[aria-posinset="${columns + 1}"] .image-overlays .star-button`)).toBeFocused();
await page.keyboard.press('Tab');
@@ -1564,3 +1568,126 @@ test('toolbar categories are alphabetical regardless of server order', async ({
const category = page.getByRole('combobox', { name: 'Filter by category', exact: true });
await expect(category.locator('option')).toHaveText(['All categories', 'Alpha', 'beta', 'zeta']);
});
// #249 D8 follow-up (image selection, Task 1): the concept's card checkbox and selection bar (app.js 398, 426-434).
const bulkbar = page => page.locator('#workspace-main > #bulkbar');
const checkbox = (card, filename) => card.getByRole('checkbox', { name: `Select ${filename}`, exact: true });
const barButtons = page => bulkbar(page).locator('.bulkbar > button');
test('card checkboxes select images into the concept bar and Clear empties it', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
// The concept's place: an empty bar between the filter row and the gallery layout.
await expect(bulkbar(page)).toBeEmpty();
expect(await bulkbar(page).evaluate(bar => [bar.previousElementSibling.className, bar.nextElementSibling.classList.contains('gallery-layout')]))
.toEqual(['filter-row', true]);
// Each overlay starts with the checkbox label, then the star.
expect(await page.locator('.image-overlays').evaluateAll(rows => rows.map(row => [...row.children].map(child =>
`${child.tagName.toLowerCase()}${child.querySelector(':scope > input') ? `>${child.firstElementChild.type}` : ''}`))))
.toEqual([['label>checkbox', 'button'], ['label>checkbox', 'button']]);
const [first, second] = [cards(page).first(), cards(page).last()];
const selectedBefore = await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')));
await checkbox(first, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await expect(barButtons(page)).toHaveText(['Set category', 'Export metadata', 'Clear']);
// Toggling neither opens the viewer, nor inspects, nor moves focus.
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(checkbox(first, 'same.png')).toBeFocused();
expect(await cards(page).evaluateAll(nodes => nodes.map(node => node.classList.contains('selected')))).toEqual(selectedBefore);
// The count updates in place: the same bar and count nodes.
await bulkbar(page).locator('.bulkbar > strong').evaluate(node => { node.dataset.kept = 'yes'; });
await checkbox(second, 'same.png').check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveAttribute('data-kept', 'yes');
// Set category and Export metadata are wired by later tasks; until then they do nothing.
for (const name of ['Set category', 'Export metadata']) {
await bulkbar(page).getByRole('button', { name, exact: true }).click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
}
await checkbox(second, 'same.png').uncheck();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await checkbox(second, 'same.png').check();
await bulkbar(page).getByRole('button', { name: 'Clear', exact: true }).click();
await expect(bulkbar(page)).toBeEmpty();
// The pressed control hid with its bar: focus moves to the page heading, not to the page.
await expect(page.locator('#workspace-main h1')).toBeFocused();
await expect(checkbox(first, 'same.png')).not.toBeChecked();
await expect(checkbox(second, 'same.png')).not.toBeChecked();
});
test('the card checkbox keeps its own Space and Tab', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const first = cards(page).first();
await first.getByRole('button', { name: 'Open same.png', exact: true }).focus();
await page.keyboard.press('Tab');
await expect(checkbox(first, 'same.png')).toBeFocused();
await page.keyboard.press('Space');
await expect(checkbox(first, 'same.png')).toBeChecked();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
await expect(page.locator('dialog[open]')).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(star(first, 'same.png')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(checkbox(first, 'same.png')).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(first.getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
// An arrow key on the checkbox still moves between cards, as from the card's other controls.
await checkbox(first, 'same.png').focus();
await page.keyboard.press('ArrowRight');
await expect(cards(page).last().getByRole('button', { name: 'Open same.png', exact: true })).toBeFocused();
await expect(checkbox(first, 'same.png')).toBeChecked();
});
test('the selection survives scrolling, re-mounting and appended batches, and a search clears it', async ({ page, request }) => {
await page.addInitScript(() => { window.IntersectionObserver = undefined; });
await expect(await request.post('/__test__/reset', { data: { extra_images: 200 } })).toBeOK();
await page.goto(workspace);
await expect(cards(page)).toHaveCount(40);
const firstKey = await cards(page).first().getAttribute('data-image-key');
await cards(page).first().getByRole('checkbox', { name: /^Select / }).check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
const more = page.getByRole('button', { name: 'Load more images', exact: true });
for (const loaded of [80, 120, 160, 200, 202]) {
await more.click();
await expect(caption(page)).toContainText(`${loaded} of 202 images`);
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('1 selected');
}
// A card from a later batch joins the kept selection.
const late = page.locator('article[data-image-key][data-index="200"]');
await late.getByRole('checkbox', { name: /^Select / }).check();
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
// Bounded rendering unmounted the first card; scrolled back, it is mounted again with its box checked.
await expect(page.locator(`article[data-image-key="${firstKey}"]`)).toHaveCount(0);
await page.evaluate(() => window.scrollTo(0, 0));
const again = page.locator(`article[data-image-key="${firstKey}"]`);
await expect(again).toBeVisible();
await expect(again.getByRole('checkbox', { name: /^Select / })).toBeChecked();
await expect(cards(page).nth(1).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
// Returning to Images keeps the restored collection and its selection.
await page.evaluate(() => { window.location.hash = '/overview'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Overview');
await page.evaluate(() => { window.location.hash = '/images'; });
await expect(page.locator('#workspace-main h1')).toHaveText('Images');
await expect(bulkbar(page).locator('.bulkbar > strong')).toHaveText('2 selected');
// A search replaces the collection, and with it the selection.
await search(page).fill('0001');
await expect(caption(page)).not.toContainText('202 images');
await expect(bulkbar(page)).toBeEmpty();
await search(page).fill('');
await expect(caption(page)).toContainText('of 202 images');
await expect(bulkbar(page)).toBeEmpty();
await expect(page.locator(`article[data-image-key="${firstKey}"]`).getByRole('checkbox', { name: /^Select / })).not.toBeChecked();
});
test('the card checkbox does not open the viewer', async ({ page }) => {
await page.goto(workspace);
await expect(cards(page)).toHaveCount(2);
const first = cards(page).first();
// A press on the label around the box, as well as on the box itself.
await first.locator('.image-overlays > label').click({ position: { x: 1, y: 1 } });
await checkbox(first, 'same.png').click();
await expect(page.locator('dialog[open]')).toHaveCount(0);
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
});
+19
View File
@@ -313,3 +313,22 @@ test('withItem patches a saved browsing state without touching the other items',
assert.equal(state.items[1].favorite, false);
assert.equal(withItem(state, '1:b', {favorite:true}), state);
});
test('toggleSelection returns a new frozen set and leaves the given one untouched', async () => {
const { toggleSelection } = await source;
const empty = Object.freeze(new Set());
const one = toggleSelection(empty, '0:a', true);
assert.ok(Object.isFrozen(one)); assert.deepEqual([...one], ['0:a']); assert.equal(empty.size, 0);
const two = toggleSelection(one, '1:a', true);
assert.deepEqual([...two], ['0:a', '1:a']); assert.deepEqual([...one], ['0:a']);
assert.equal(toggleSelection(two, '0:a', true), two, 'an already selected key keeps the same set');
assert.equal(toggleSelection(one, '9:z', false), one, 'an unselected key keeps the same set');
assert.deepEqual([...toggleSelection(two, '0:a', false)], ['1:a']);
});
test('selectionLabel counts the selected images as the concept bar does', async () => {
const { selectionLabel } = await source;
assert.equal(selectionLabel(1), '1 selected');
assert.equal(selectionLabel(2), '2 selected');
assert.equal(selectionLabel(1234), '1,234 selected');
});
+13
View File
@@ -230,3 +230,16 @@ export function cardSubtitle(image) {
export function mediaRatio(image) {
return hasSize(image) ? `${image.width}/${image.height}` : '4/3';
}
/** The card checkboxes' selection (#249 D8): a new frozen set with `key` added or removed; unchanged, the same set. */
export function toggleSelection(selected, key, on) {
if (selected.has(key) === on) return selected;
const next = new Set(selected);
if (on) next.add(key); else next.delete(key);
return Object.freeze(next);
}
/** The concept selection bar's count (app.js updateBulk), grouped like the page's other counts. */
export function selectionLabel(count) {
return `${count.toLocaleString('en-US')} selected`;
}
+50 -3
View File
@@ -1,5 +1,5 @@
import { createScope, createButton } from '../shared/toolkit/core.js';
import { createImageStream, withItem, imageKey, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { createImageStream, withItem, imageKey, toggleSelection, selectionLabel, countLine, captionDate, scopeLabel, cardTitle, cardSubtitle, mediaRatio } from './images-state.mjs';
import { layoutImages, visibleImages, gridMetrics, MINIMUM_CARD_WIDTH } from './image-layout.mjs';
import { mountFileInspector } from './file-inspector.mjs';
import { createInspectionController } from './inspection-state.mjs';
@@ -105,10 +105,12 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
let roots = [], folders = [];
const rendered = new Map();
const itemIndexes = new Map();
// The checked cards (#249 D8): image keys, kept across appended batches and cleared with the collection.
let checked = visit?.checked || Object.freeze(new Set());
const filters = { root: 'all', q: '', category: '', tags: [], subfolder: '', media: 'all', sort: 'newest', ...visit?.filters };
filters.tags = [...filters.tags];
scope.cleanup(() => {
if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, scrollY: window.scrollY } : null;
if (!embedded) lastVisit = state ? { filters: {...filters, tags: [...filters.tags]}, state, layout, density, selection, checked, scrollY: window.scrollY } : null;
cancelAnimationFrame(restoreFrame);
});
main.classList.add('images-main');
@@ -174,6 +176,19 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
filterRow.append(chip, group);
}
main.append(filterRow);
// The concept's selection bar (app.js renderImages, updateBulk), between the filter row and the gallery layout.
// One bar and count, mounted while anything is checked, so the count updates in place.
const bulkbar = embedded ? null : el('div');
const bulk = el('div', undefined, 'bulkbar'), bulkCount = el('strong');
if (bulkbar) {
bulkbar.id = 'bulkbar';
// Set category and Export metadata are wired by the next selection tasks; until then they do nothing.
const clear = createButton('Clear');
// Clear hides the bar it sits in, so focus moves to the page heading instead of falling to the page.
scope.listen(clear, 'click', () => { clearChecked(); focusFallback.focus({preventScroll:true}); });
bulk.append(bulkCount, createButton('Set category'), createButton('Export metadata'), clear);
main.append(bulkbar);
}
const body = el('div', undefined, 'gallery-layout image-workspace');
const collection = el('section', undefined, 'gallery-body');
collection.setAttribute('aria-label', 'Images');
@@ -263,8 +278,16 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
const open = createButton('', 'image-open');
open.setAttribute('aria-label', `Open ${image.filename}`);
open.append(mediaElement(image, true));
// D7: the star, wherever the listing reports favorites (not on Library-embedded media); D8: no checkbox yet.
// D8: the selection checkbox, then D7: the star, wherever the listing reports favorites (neither on
// Library-embedded media, which has no selection bar).
const overlays = el('div', undefined, 'image-overlays');
if (bulkbar) {
const label = el('label'), box = el('input');
box.type = 'checkbox'; box.checked = checked.has(key);
box.setAttribute('aria-label', `Select ${image.filename}`);
label.append(box);
overlays.append(label);
}
if (typeof image.favorite === 'boolean') {
const star = createButton('', 'star-button');
star.setAttribute('aria-label', `Favorite ${image.filename}`);
@@ -323,6 +346,27 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
deliverFavorite(key, saved ? favorite : !favorite);
showWorkspaceToast(saved ? FAVORITE_DONE[favorite] : FAVORITE_FAILED[favorite]);
}
/** The concept's bar: empty while nothing is checked, else "N selected" and the real actions in its order. */
function renderBulk() {
if (!bulkbar) return;
if (!checked.size) { bulk.remove(); return; }
setText(bulkCount, selectionLabel(checked.size));
if (bulk.parentNode !== bulkbar) bulkbar.append(bulk);
}
function clearChecked() {
if (!checked.size) return;
checked = Object.freeze(new Set());
for (const box of grid.querySelectorAll('.image-overlays input[type="checkbox"]')) box.checked = false;
renderBulk();
}
scope.listen(grid, 'change', event => {
const box = event.target;
if (!box.matches('.image-overlays input[type="checkbox"]')) return;
const key = box.closest('article[data-image-key]')?.dataset.imageKey;
if (key === undefined) return;
checked = toggleSelection(checked, key, box.checked);
renderBulk();
});
scope.listen(grid, 'click', event => {
const card = event.target.closest('article[data-image-key]');
if (!card) return;
@@ -551,6 +595,8 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
}
selection = null;
focusAfterLoad = null;
// The ruled selection lifetime: a replaced collection may no longer hold the checked files.
clearChecked();
for (const video of details.querySelectorAll('video')) releaseVideo(video);
updateScope();
stream.reset(filters, {load:!recovering});
@@ -687,6 +733,7 @@ export function mountImages(main, api, parentScope, {embedded = false, fetchPage
// The page's one discovery alert is written once both first loads settled (#249 final review m6).
if (!embedded) Promise.all([loadOptions(), loadCategories()]).then(() => { if (!scope.signal.aborted) renderLoadStatus(); });
if (visit) {
renderBulk();
renderState(stream.state);
const selected = stream.state.items.find(image => imageKey(image) === selection);
if (selected) inspect(selected);
-3
View File
@@ -68,9 +68,6 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
/* #249 D18: the concept zooms a hovered card image (1.03 over 0.2s; its reduced-motion rule makes that instant). A touch
device has no real hover, so a tap must not leave the zoom stuck. */
@media (hover: none) { .image-open:hover img { transform: none; /* D18 */ } }
/* #249 D8 (interim until the checkbox follow-up PR): without the concept's selection checkbox the star is the overlay's
only child; this keeps it at the concept's top-right. The follow-up that ships the checkbox deletes this rule. */
.image-overlays > .star-button:only-child { margin-left: auto; /* D8 */ }
/* Images cards (#249 C3). #249 D3: bounded rendering mounts only the visible cards and places each one itself, in every
layout (the concept places only masonry cards), so the grid reserves the collection's height and every card is