Consolidate listing pages with unified grid cards and modal system (#2101)

* Prototype extension details modal

- Add detail popup modal for extension cards with full metadata and gallery
- Implement image gallery with thumbnail strip and main image selection
- Add modal styling and positioning in global.css
- Connect card click handlers to open modal with extension data

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix accessibility issues with modal focus restoration

- Add missing listing-cards-page class to agents.astro page root
- Pass focusable button element to openCardDetailsModal instead of article
- Fixes focus restoration for keyboard users when closing modal
- Applied fix across all listing pages (agents, instructions, hooks, plugins, skills, workflows)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address remaining PR review feedback

- Fix extension modal ARIA state by setting aria-current to "true" and removing it when inactive
- Use focusable .resource-preview as modal trigger for extension thumbnail/click/keyboard paths
- Extract shared multi-select helpers into pages/select-utils.ts and reuse across instructions/hooks/plugins/workflows
- Remove unused card-model.ts to avoid dead/overlapping type definitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Aaron Powell
2026-06-24 09:47:39 +10:00
committed by GitHub
parent ec8cb2a8ae
commit 8cdeb2d2ed
25 changed files with 1777 additions and 737 deletions
+86 -23
View File
@@ -2,20 +2,18 @@
* Instructions page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from '../choices';
import {
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
setupDropdownCloseHandlers,
getVSCodeInstallUrl,
setupActionHandlers,
setupDropdownCloseHandlers,
updateQueryParams,
} from '../utils';
import { setupModal, openFileModal } from '../modal';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderInstructionsHtml,
sortInstructions,
@@ -37,12 +35,13 @@ interface InstructionsData {
};
}
const resourceType = 'instruction';
let allItems: Instruction[] = [];
let extensionSelect: Choices;
let instructionByPath = new Map<string, Instruction>();
let extensionSelectEl: HTMLSelectElement | null = null;
let currentFilters = { extensions: [] as string[] };
let currentSort: InstructionSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Instruction[]): Instruction[] {
return sortInstructions(items, currentSort);
@@ -53,11 +52,14 @@ function applyFiltersAndRender(): void {
let results = [...allItems];
if (currentFilters.extensions.length > 0) {
results = results.filter(item => {
if (currentFilters.extensions.includes('(none)') && (!item.extensions || item.extensions.length === 0)) {
results = results.filter((item) => {
if (
currentFilters.extensions.includes('(none)') &&
(!item.extensions || item.extensions.length === 0)
) {
return true;
}
return item.extensions?.some(ext => currentFilters.extensions.includes(ext));
return item.extensions?.some((ext) => currentFilters.extensions.includes(ext));
});
}
@@ -78,6 +80,53 @@ function renderItems(items: Instruction[]): void {
list.innerHTML = renderInstructionsHtml(items);
}
function openInstructionDetailsModal(path: string, trigger?: HTMLElement): void {
const item = instructionByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
const applyToText = Array.isArray(item.applyTo) ? item.applyTo.join(', ') : item.applyTo;
if (applyToText) {
metaParts.push(`<span class="resource-tag">applies to: ${escapeHtml(applyToText)}</span>`);
}
metaParts.push(
...(item.extensions || []).map(
(extension) => `<span class="resource-tag tag-extension">${escapeHtml(extension)}</span>`
)
);
if (item.lastUpdated) {
metaParts.push(`<span class="last-updated">Updated ${escapeHtml(formatRelativeTime(item.lastUpdated))}</span>`);
}
const vscodeUrl = getVSCodeInstallUrl('instructions', path, false);
const insidersUrl = getVSCodeInstallUrl('instructions', path, true);
const actions = [
vscodeUrl
? `<a class="btn btn-primary btn-small" href="${escapeHtml(vscodeUrl)}" target="_blank" rel="noopener noreferrer">Install (VS Code)</a>`
: '',
insidersUrl
? `<a class="btn btn-secondary btn-small" href="${escapeHtml(insidersUrl)}" target="_blank" rel="noopener noreferrer">Install (Insiders)</a>`
: '',
`<button class="btn btn-secondary btn-small" type="button" data-open-file-path="${escapeHtml(
path
)}" data-open-file-type="instruction">Source</button>`,
].filter(Boolean);
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '📋',
previewText: 'Instruction metadata and install options',
metaHtml: metaParts.join(''),
actionsHtml: actions.join(''),
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
@@ -88,9 +137,10 @@ function setupResourceListHandlers(list: HTMLElement | null): void {
}
const item = target.closest('.resource-item') as HTMLElement | null;
const button = item?.querySelector('.resource-preview') as HTMLElement | undefined;
const path = item?.dataset.path;
if (path) {
openFileModal(path, resourceType);
openInstructionDetailsModal(path, button);
}
});
@@ -108,7 +158,12 @@ function syncUrlState(): void {
export async function initInstructionsPage(): Promise<void> {
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement;
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
@@ -119,24 +174,33 @@ export async function initInstructionsPage(): Promise<void> {
}
allItems = data.items;
instructionByPath = new Map(allItems.map((item) => [item.path, item]));
extensionSelect = createChoices('#filter-extension', { placeholderValue: 'All Extensions' });
extensionSelect.setChoices(data.filters.extensions.map(e => ({ value: e, label: e })), 'value', 'label', true);
extensionSelectEl = document.getElementById('filter-extension') as HTMLSelectElement | null;
if (extensionSelectEl) {
extensionSelectEl.innerHTML = '';
data.filters.extensions.forEach((ext) => {
const option = document.createElement('option');
option.value = ext;
option.textContent = ext;
extensionSelectEl?.appendChild(option);
});
}
const initialExtensions = getQueryParamValues('extension').filter(extension => data.filters.extensions.includes(extension));
const initialExtensions = getQueryParamValues('extension').filter((extension) => data.filters.extensions.includes(extension));
const initialSort = getQueryParam('sort');
if (initialExtensions.length > 0) {
currentFilters.extensions = initialExtensions;
setChoicesValues(extensionSelect, initialExtensions);
setSelectValues(extensionSelectEl, initialExtensions);
}
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById('filter-extension')?.addEventListener('change', () => {
currentFilters.extensions = getChoicesValues(extensionSelect);
extensionSelectEl?.addEventListener('change', () => {
currentFilters.extensions = getSelectValues(extensionSelectEl);
applyFiltersAndRender();
syncUrlState();
});
@@ -150,14 +214,13 @@ export async function initInstructionsPage(): Promise<void> {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { extensions: [] };
currentSort = 'title';
extensionSelect.removeActiveItems();
clearSelectValues(extensionSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
setupDropdownCloseHandlers();
setupActionHandlers();
}