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
+108 -58
View File
@@ -2,25 +2,24 @@
* Workflows page functionality
*/
import {
createChoices,
getChoicesValues,
setChoicesValues,
type Choices,
} from "../choices";
import {
copyToClipboard,
escapeHtml,
fetchData,
formatRelativeTime,
getQueryParam,
getQueryParamValues,
showToast,
setupActionHandlers,
updateQueryParams,
} from "../utils";
import { setupModal, openFileModal } from "../modal";
} from '../utils';
import { openCardDetailsModal, setupModal } from '../modal';
import { clearSelectValues, getSelectValues, setSelectValues } from './select-utils';
import {
renderWorkflowsHtml,
sortWorkflows,
type RenderableWorkflow,
type WorkflowSortOption,
} from "./workflows-render";
} from './workflows-render';
interface Workflow extends RenderableWorkflow {
id: string;
@@ -36,141 +35,192 @@ interface WorkflowsData {
};
}
const resourceType = "workflow";
let allItems: Workflow[] = [];
let triggerSelect: Choices;
let workflowByPath = new Map<string, Workflow>();
let triggerSelectEl: HTMLSelectElement | null = null;
let currentFilters = {
triggers: [] as string[],
};
let currentSort: WorkflowSortOption = "title";
let currentSort: WorkflowSortOption = 'title';
let resourceListHandlersReady = false;
let modalReady = false;
function sortItems(items: Workflow[]): Workflow[] {
return sortWorkflows(items, currentSort);
}
function applyFiltersAndRender(): void {
const countEl = document.getElementById("results-count");
const countEl = document.getElementById('results-count');
let results = [...allItems];
if (currentFilters.triggers.length > 0) {
results = results.filter((item) =>
item.triggers.some((trigger) => currentFilters.triggers.includes(trigger))
);
results = results.filter((item) => item.triggers.some((trigger) => currentFilters.triggers.includes(trigger)));
}
results = sortItems(results);
renderItems(results);
let countText = `${results.length} workflow${results.length === 1 ? "" : "s"}`;
let countText = `${results.length} workflow${results.length === 1 ? '' : 's'}`;
if (currentFilters.triggers.length > 0) {
countText = `${results.length} of ${allItems.length} workflows (filtered by ${currentFilters.triggers.length} trigger${currentFilters.triggers.length > 1 ? "s" : ""})`;
countText = `${results.length} of ${allItems.length} workflows (filtered by ${currentFilters.triggers.length} trigger${currentFilters.triggers.length > 1 ? 's' : ''})`;
}
if (countEl) countEl.textContent = countText;
}
function renderItems(items: Workflow[]): void {
const list = document.getElementById("resource-list");
const list = document.getElementById('resource-list');
if (!list) return;
list.innerHTML = renderWorkflowsHtml(items);
}
function openWorkflowDetailsModal(path: string, trigger?: HTMLElement): void {
const item = workflowByPath.get(path);
if (!item) {
return;
}
const metaParts: string[] = [];
if (item.lastUpdated) {
metaParts.push(
`<span class="last-updated">Updated ${escapeHtml(
formatRelativeTime(item.lastUpdated)
)}</span>`
);
}
const triggerTags = item.triggers
.map((triggerName) => `<span class="resource-tag tag-trigger">${escapeHtml(triggerName)}</span>`)
.join('');
const actionsHtml = `
<button id="workflow-details-copy-path" class="btn btn-secondary" type="button" data-workflow-path="${escapeHtml(
item.path
)}">Copy Path</button>
<button class="btn btn-secondary" type="button" data-open-file-path="${escapeHtml(
item.path
)}" data-open-file-type="workflow">Source</button>
`;
openCardDetailsModal({
title: item.title,
description: item.description || 'No description',
previewIcon: '⚡',
previewText: 'Workflow trigger details and source',
metaHtml: metaParts.join(''),
tagsHtml: triggerTags,
actionsHtml,
trigger,
});
}
function setupResourceListHandlers(list: HTMLElement | null): void {
if (!list || resourceListHandlersReady) return;
list.addEventListener("click", (event) => {
list.addEventListener('click', (event) => {
const target = event.target as HTMLElement;
if (target.closest(".resource-actions")) {
if (target.closest('.resource-actions')) {
return;
}
const item = target.closest(".resource-item") as HTMLElement | null;
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);
openWorkflowDetailsModal(path, button);
}
});
document.addEventListener('click', async (event) => {
const target = event.target as HTMLElement;
const copyPathButton = target.closest(
'#workflow-details-copy-path'
) as HTMLButtonElement | null;
if (!copyPathButton) return;
const workflowPath = copyPathButton.dataset.workflowPath || '';
if (!workflowPath) return;
const success = await copyToClipboard(workflowPath);
showToast(success ? 'Path copied!' : 'Failed to copy path', success ? 'success' : 'error');
});
resourceListHandlersReady = true;
}
function syncUrlState(): void {
updateQueryParams({
q: "",
q: '',
trigger: currentFilters.triggers,
sort: currentSort === "title" ? "" : currentSort,
sort: currentSort === 'title' ? '' : currentSort,
});
}
export async function initWorkflowsPage(): Promise<void> {
const list = document.getElementById("resource-list");
const clearFiltersBtn = document.getElementById("clear-filters");
const sortSelect = document.getElementById(
"sort-select"
) as HTMLSelectElement;
const list = document.getElementById('resource-list');
const clearFiltersBtn = document.getElementById('clear-filters');
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
if (!modalReady) {
setupModal();
modalReady = true;
}
setupResourceListHandlers(list as HTMLElement | null);
const data = await fetchData<WorkflowsData>("workflows.json");
const data = await fetchData<WorkflowsData>('workflows.json');
if (!data || !data.items) {
if (list)
list.innerHTML =
'<div class="empty-state"><h3>Failed to load data</h3></div>';
if (list) list.innerHTML = '<div class="empty-state"><h3>Failed to load data</h3></div>';
return;
}
allItems = data.items;
workflowByPath = new Map(allItems.map((item) => [item.path, item]));
triggerSelect = createChoices("#filter-trigger", {
placeholderValue: "All Triggers",
});
triggerSelect.setChoices(
data.filters.triggers.map((trigger) => ({ value: trigger, label: trigger })),
"value",
"label",
true
);
triggerSelectEl = document.getElementById('filter-trigger') as HTMLSelectElement | null;
if (triggerSelectEl) {
triggerSelectEl.innerHTML = '';
data.filters.triggers.forEach((trigger) => {
const option = document.createElement('option');
option.value = trigger;
option.textContent = trigger;
triggerSelectEl?.appendChild(option);
});
}
const initialTriggers = getQueryParamValues("trigger").filter((trigger) =>
data.filters.triggers.includes(trigger)
);
const initialSort = getQueryParam("sort");
const initialTriggers = getQueryParamValues('trigger').filter((trigger) => data.filters.triggers.includes(trigger));
const initialSort = getQueryParam('sort');
if (initialTriggers.length > 0) {
currentFilters.triggers = initialTriggers;
setChoicesValues(triggerSelect, initialTriggers);
setSelectValues(triggerSelectEl, initialTriggers);
}
if (initialSort === "lastUpdated") {
if (initialSort === 'lastUpdated') {
currentSort = initialSort;
if (sortSelect) sortSelect.value = initialSort;
}
document.getElementById("filter-trigger")?.addEventListener("change", () => {
currentFilters.triggers = getChoicesValues(triggerSelect);
triggerSelectEl?.addEventListener('change', () => {
currentFilters.triggers = getSelectValues(triggerSelectEl);
applyFiltersAndRender();
syncUrlState();
});
sortSelect?.addEventListener("change", () => {
sortSelect?.addEventListener('change', () => {
currentSort = sortSelect.value as WorkflowSortOption;
applyFiltersAndRender();
syncUrlState();
});
clearFiltersBtn?.addEventListener("click", () => {
clearFiltersBtn?.addEventListener('click', () => {
currentFilters = { triggers: [] };
currentSort = "title";
triggerSelect.removeActiveItems();
if (sortSelect) sortSelect.value = "title";
currentSort = 'title';
clearSelectValues(triggerSelectEl);
if (sortSelect) sortSelect.value = 'title';
applyFiltersAndRender();
syncUrlState();
});
applyFiltersAndRender();
setupModal();
setupActionHandlers();
}
// Auto-initialize when DOM is ready
document.addEventListener("DOMContentLoaded", initWorkflowsPage);
document.addEventListener('DOMContentLoaded', initWorkflowsPage);