Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ hSQLite Editor targets WCAG 2.2 Level AA for the latest release. Accessibility i
- Controls require programmatic names; placeholders are not labels.
- Dialogs require an accessible name, appropriate description, focus containment, and focus restoration.
- Status and error feedback must be exposed without moving focus unnecessarily.
- SQL tabs keep form controls outside `role="tab"`. F2 starts an inline rename, Enter commits, Escape cancels, and both paths restore focus to the same tab. Rename and close commands for the active tab live outside the tablist and remain keyboard reachable.
- SQL tabs keep form controls outside `role="tab"`. F2 starts an inline rename, Enter commits, Escape cancels, and both paths restore focus to the same tab. Rename and close commands are siblings of their tab, appear on hover or focus, and remain keyboard reachable in that order. The New tab command follows the tablist in the same horizontal scroll sequence. Closing a tab restores focus to the resulting active tab. Closing all tabs requires a count-specific destructive confirmation whose initial focus is Cancel.
- Error toasts use atomic assertive announcements; informational toasts use atomic polite announcements. Toasts never take focus, and their close buttons have localized accessible names.
- Results-grid sorting, row selection, current-row movement, and column operations require keyboard equivalents or an accessible command surface.
- SQL Map table and field selectors expose names derived from the visible database identifiers. Relationship controls support Enter and Space, preserve pressed state while a source is selected, and announce source, cancellation, blocked validation, and completion within the open dialog.
Expand Down
334 changes: 280 additions & 54 deletions index.html

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions scripts/validate-accessibility.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -86,10 +86,12 @@ if (!tabButtonTemplate || /<input\b/.test(tabButtonTemplate)) {
}
const requiredTabContracts = [
[/renameField\.innerHTML[\s\S]*<input class="sql-tab-title-input"/, "sibling tab-rename field"],
[/(?:aria-label=.*tabs\.renameNamedLabel|setAttribute\("aria-label",\s*t\("tabs\.renameNamedLabel)/, "tab-specific rename-button name"],
[/setAttribute\("aria-label",\s*t\("tabs\.closeNamedLabel/, "tab-specific close-button name"],
[/renameButton\.setAttribute\("aria-label",\s*t\("tabs\.renameNamedLabel/, "tab-specific rename-button name"],
[/closeButton\.setAttribute\("aria-label",\s*t\("tabs\.closeNamedLabel/, "tab-specific close-button name"],
[/if \(renameField\) item\.appendChild\(renameField\)/, "rename field outside the tab button"],
[template, /id="sqlTabs"[^>]*role="tablist"[^>]*><\/div>\s*<button id="renameActiveSqlTabBtn" class="ui-button ui-button-icon ui-button-sm sql-tab-header-action"/, "active-tab commands outside the tablist"]
[/inlineActions\.appendChild\(renameButton\)[\s\S]*inlineActions\.appendChild\(closeButton\)/, "rename-before-close command order"],
[/sqlTabActionsLayer\.appendChild\(inlineActions\)/, "tab commands outside the tablist"],
[template, /id="sqlTabsStrip"[^>]*>[\s\S]*?<div class="sql-tabs" id="sqlTabs"[^>]*role="tablist"[\s\S]*?<div class="sql-tab-actions-layer" id="sqlTabActionsLayer"><\/div>[\s\S]*?<button id="newSqlTabBtn"/, "new-tab command immediately after the tab stack"]
];
for (const contract of requiredTabContracts) {
const [sourceOrPattern, patternOrLabel, optionalLabel] = contract;
Expand All @@ -98,7 +100,7 @@ for (const contract of requiredTabContracts) {
const label = optionalLabel || patternOrLabel;
if (!pattern.test(source)) failures.push(`Missing ${label}.`);
}
if (/sql-tab-actions|actions\.setAttribute\("aria-hidden"|(?:closeEl|renameEl)\.tabIndex\s*=\s*-1/.test(sqlTabsSource)) {
if (/actions\.setAttribute\("aria-hidden"|(?:closeButton|renameButton)\.tabIndex\s*=\s*-1/.test(sqlTabsSource)) {
failures.push("Visible SQL tab actions must remain available to keyboard and assistive-technology users.");
}

Expand Down
95 changes: 85 additions & 10 deletions scripts/validate-browser-quality.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const locales = [
sourceAnnouncement: "Source selected: qa_virtual_a.code.", relationCreated: "Virtual relationship created for this session.",
blockedAnnouncement: "Virtual relationships between columns in the same table are not supported.",
populationTitle: "Populate table for QA", exportTitle: "Export result", historyTitle: "Query history",
favoritesTitle: "Favorite queries", closeTabTitle: "Close tab?", renameTabPrefix: "Rename tab", closeTabPrefix: "Close tab", missingTableCause: "a table or view name was not found",
favoritesTitle: "Favorite queries", closeTabTitle: "Close tab?", closeAllTabsTitle: "Close all 2 tabs?", renameTabPrefix: "Rename tab", closeTabPrefix: "Close tab", missingTableCause: "a table or view name was not found",
settingsTitle: "Settings", starterSqlLabel: "Starter SQL for new tabs", starterSqlHelp: "ON inserts the starter SQL. OFF starts new tabs empty.",
helpTitle: "hSQLite Editor help", suggestImprovement: "Suggest improvement: open the GitHub Feature request form in a new tab",
helpPrefix: "https://learn.microsoft.com/en-us/", csvFilename: "sqlite_result.csv"
Expand All @@ -25,7 +25,7 @@ const locales = [
sourceAnnouncement: "Origem selecionada: qa_virtual_a.code.", relationCreated: "Relacionamento virtual criado para a sessão atual.",
blockedAnnouncement: "Relacionamentos virtuais entre colunas da mesma tabela não são suportados.",
populationTitle: "Popular tabela para QA", exportTitle: "Exportar resultado", historyTitle: "Histórico de consultas",
favoritesTitle: "Consultas favoritas", closeTabTitle: "Fechar aba?", renameTabPrefix: "Renomear aba", closeTabPrefix: "Fechar aba", missingTableCause: "uma tabela ou view não foi encontrada",
favoritesTitle: "Consultas favoritas", closeTabTitle: "Fechar aba?", closeAllTabsTitle: "Fechar todas as 2 abas?", renameTabPrefix: "Renomear aba", closeTabPrefix: "Fechar aba", missingTableCause: "uma tabela ou view não foi encontrada",
settingsTitle: "Configurações", starterSqlLabel: "SQL inicial para novas abas", starterSqlHelp: "ON insere o SQL inicial. OFF inicia novas abas vazias.",
helpTitle: "Ajuda do hSQLite Editor", suggestImprovement: "Sugerir melhoria: abrir o formulário Feature request do GitHub em uma nova aba",
helpPrefix: "https://learn.microsoft.com/pt-br/", csvFilename: "resultado_sqlite.csv"
Expand All @@ -38,15 +38,15 @@ const locales = [
sourceAnnouncement: "Origen seleccionado: qa_virtual_a.code.", relationCreated: "Relación virtual creada para esta sesión.",
blockedAnnouncement: "No se admiten relaciones virtuales entre columnas de la misma tabla.",
populationTitle: "Poblar tabla para QA", exportTitle: "Exportar resultado", historyTitle: "Historial de consultas",
favoritesTitle: "Consultas favoritas", closeTabTitle: "¿Cerrar pestaña?", renameTabPrefix: "Renombrar pestaña", closeTabPrefix: "Cerrar pestaña", missingTableCause: "no se encontró una tabla o vista",
favoritesTitle: "Consultas favoritas", closeTabTitle: "¿Cerrar pestaña?", closeAllTabsTitle: "¿Cerrar las 2 pestañas?", renameTabPrefix: "Renombrar pestaña", closeTabPrefix: "Cerrar pestaña", missingTableCause: "no se encontró una tabla o vista",
settingsTitle: "Configuración", starterSqlLabel: "SQL inicial para nuevas pestañas", starterSqlHelp: "ON inserta el SQL inicial. OFF inicia las pestañas nuevas vacías.",
helpTitle: "Ayuda de hSQLite Editor", suggestImprovement: "Sugerir una mejora: abrir el formulario Feature request de GitHub en una pestaña nueva",
helpPrefix: "https://learn.microsoft.com/es-es/", csvFilename: "resultado_sqlite.csv"
}
];
const viewports = [
{ name: "desktop", width: 1440, height: 1000 },
{ name: "mobile", width: 390, height: 844 }
{ name: "desktop", width: 1440, height: 1000, hasTouch: false },
{ name: "mobile", width: 390, height: 844, hasTouch: true }
];
const failures = [];
const results = [];
Expand Down Expand Up @@ -102,7 +102,13 @@ try {

for (const locale of locales) {
for (const viewport of viewports) {
const context = await browser.newContext({ locale: locale.tag, viewport, reducedMotion: "reduce" });
const context = await browser.newContext({
locale: locale.tag,
viewport,
hasTouch: viewport.hasTouch,
isMobile: viewport.hasTouch,
reducedMotion: "reduce"
});
const page = await context.newPage();
const runtimeErrors = [];
page.on("pageerror", error => runtimeErrors.push(error.message));
Expand Down Expand Up @@ -496,6 +502,28 @@ try {
saveCurrentTabState();
});
const activeTab = page.locator('.sql-tab[aria-selected="true"]');
await page.mouse.move(1, 1);
if (viewport.hasTouch) {
const activeTouchActions = await page.locator(".sql-tab-actions-portal.active").evaluate(element => {
const styles = getComputedStyle(element);
const action = element.querySelector(".sql-tab-inline-action");
return {
visibility: styles.visibility,
opacity: Number(styles.opacity),
actionPointerEvents: action ? getComputedStyle(action).pointerEvents : "",
coarsePointer: matchMedia("(pointer: coarse)").matches,
noHover: matchMedia("(hover: none)").matches
};
});
assert(
activeTouchActions.visibility === "visible"
&& activeTouchActions.opacity === 1
&& activeTouchActions.actionPointerEvents === "auto"
&& activeTouchActions.coarsePointer
&& activeTouchActions.noHover,
`${locale.tag}/${viewport.name}: active-tab actions are not persistently available for coarse touch input (${JSON.stringify(activeTouchActions)}).`
);
}
const originalTabTitle = (await activeTab.innerText()).trim();
await activeTab.focus();
await page.keyboard.press("F2");
Expand Down Expand Up @@ -525,21 +553,63 @@ try {
assert((await activeTab.innerText()).trim() === "browser-renamed", `${locale.tag}/${viewport.name}: Enter did not commit the tab title.`);
assert(await activeTab.evaluate(element => element === document.activeElement), `${locale.tag}/${viewport.name}: Enter did not restore focus to the renamed tab.`);

const closeTabAction = page.locator("#closeActiveSqlTabBtn");
const renameTabAction = page.locator("#renameActiveSqlTabBtn");
const closeTabAction = page.locator(".sql-tab-actions-portal.active [data-tab-close-id]");
const renameTabAction = page.locator(".sql-tab-actions-portal.active [data-tab-rename-id]");
const closeAllTabsAction = page.locator("#closeAllTabsBtn");
const newTabPlacement = await page.locator("#newSqlTabBtn").evaluate(element => {
const lastTabItem = document.querySelector("#sqlTabs .sql-tab-item:last-child");
if (!lastTabItem) return null;
const tabRect = lastTabItem.getBoundingClientRect();
const buttonRect = element.getBoundingClientRect();
return {
parentId: element.parentElement?.id || "",
gap: Math.round(buttonRect.left - tabRect.right),
verticallyAligned: Math.abs(buttonRect.top + buttonRect.height / 2 - (tabRect.top + tabRect.height / 2)) <= 2
};
});
assert(
newTabPlacement?.parentId === "sqlTabsStrip"
&& newTabPlacement.gap >= 0
&& newTabPlacement.gap <= 8
&& newTabPlacement.verticallyAligned,
`${locale.tag}/${viewport.name}: New tab is not immediately aligned after the last open tab.`
);
assert(
String(await closeTabAction.getAttribute("aria-label") || "").startsWith(locale.closeTabPrefix),
`${locale.tag}/${viewport.name}: close-tab action name is not localized.`
);
assert(
await closeTabAction.evaluate(element => element.closest('[role="tab"]') === null)
&& await renameTabAction.evaluate(element => element.closest('[role="tab"]') === null),
`${locale.tag}/${viewport.name}: a tab action is nested inside role=tab.`
);
assert(
String(await renameTabAction.getAttribute("aria-label") || "").startsWith(locale.renameTabPrefix),
`${locale.tag}/${viewport.name}: rename-tab action name is not localized.`
);
await activeTab.focus();
await page.keyboard.press("Tab");
assert(await renameTabAction.evaluate(element => element === document.activeElement), `${locale.tag}/${viewport.name}: rename-tab action is not keyboard reachable.`);
assert(await renameTabAction.evaluate(element => element === document.activeElement), `${locale.tag}/${viewport.name}: inline rename-tab action is not keyboard reachable.`);
await page.keyboard.press("Tab");
assert(await closeTabAction.evaluate(element => element === document.activeElement), `${locale.tag}/${viewport.name}: close-tab action is not keyboard reachable.`);
assert(await closeTabAction.evaluate(element => element === document.activeElement), `${locale.tag}/${viewport.name}: inline close-tab action is not keyboard reachable.`);

await closeAllTabsAction.focus();
await page.keyboard.press("Enter");
await page.locator("#closeAllTabsConfirmModal").waitFor({ state: "visible" });
assert(
await page.getByRole("dialog", { name: locale.closeAllTabsTitle }).count() === 1,
`${locale.tag}/${viewport.name}: Close all confirmation does not identify the exact tab count.`
);
assert(
await page.locator("#cancelCloseAllTabsBtn").evaluate(element => element === document.activeElement),
`${locale.tag}/${viewport.name}: Close all confirmation did not put initial focus on Cancel.`
);
const tabCountBeforeCloseAllCancel = await page.locator(".sql-tab").count();
await page.locator("#cancelCloseAllTabsBtn").click();
assert(
await page.locator(".sql-tab").count() === tabCountBeforeCloseAllCancel,
`${locale.tag}/${viewport.name}: canceling Close all removed tabs.`
);

const tabAxe = await new AxeBuilder({ page })
.include("#sqlTabs")
Expand All @@ -558,6 +628,11 @@ try {
await page.keyboard.press("Delete");
await page.locator("#confirmCloseTabBtn").click();
assert(await page.locator(".sql-tab").count() === tabCountBeforeCancel - 1, `${locale.tag}/${viewport.name}: confirming close-tab did not remove one tab.`);
await page.waitForFunction(() => document.activeElement?.matches('.sql-tab[aria-selected="true"]'));
assert(
await page.locator('.sql-tab[aria-selected="true"]').evaluate(element => element === document.activeElement),
`${locale.tag}/${viewport.name}: confirmed close did not restore focus to the resulting active tab.`
);

if (viewport.name === "desktop") {
await page.setViewportSize({ width: Math.floor(viewport.width / 2), height: Math.floor(viewport.height / 2) });
Expand Down
37 changes: 34 additions & 3 deletions scripts/validate-runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1304,6 +1304,9 @@ function assertSqlTabLifecycleFlows(runtime, context) {
const closeTabConfirmModal = runtime.elementsById.get("closeTabConfirmModal");
const closeTabConfirmText = runtime.elementsById.get("closeTabConfirmText");
const closeTabPreview = runtime.elementsById.get("closeTabPreview");
const closeAllTabsConfirmModal = runtime.elementsById.get("closeAllTabsConfirmModal");
const closeAllTabsTitle = runtime.elementsById.get("closeAllTabsTitle");
const closeAllTabsConfirmText = runtime.elementsById.get("closeAllTabsConfirmText");
const starterSqlToggle = runtime.elementsById.get("starterSqlToggle");

const baseTab = context.createEmptyTab("Lifecycle Base", "select * from clientes");
Expand Down Expand Up @@ -1354,16 +1357,44 @@ function assertSqlTabLifecycleFlows(runtime, context) {
`Runtime smoke expected new-tab status feedback, got ${runtime.elementsById.get("status")?.textContent || "empty"}.`
);

const renderedCloseAction = runtime.elementsById.get("closeActiveSqlTabBtn");
const renderedRenameAction = runtime.elementsById.get("renameActiveSqlTabBtn");
const renderedCloseAction = runtime.elementsById.get("sqlTabActionsLayer")?.querySelector(
`[data-tab-close-id="${lifecycleTabsAfterAdd[1].id}"]`
);
const renderedRenameAction = runtime.elementsById.get("sqlTabActionsLayer")?.querySelector(
`[data-tab-rename-id="${lifecycleTabsAfterAdd[1].id}"]`
);
const renderedCloseAllAction = runtime.elementsById.get("closeAllTabsBtn");

assert(renderedCloseAction && renderedRenameAction, "Runtime smoke expected close and rename controls outside the SQL tablist.");
assert(renderedCloseAction && renderedRenameAction, "Runtime smoke expected inline close and rename controls for SQL tabs.");
assert(!renderedCloseAction.disabled && !renderedRenameAction.disabled, "Runtime smoke expected active-tab actions to be enabled when two tabs exist.");
assert(
renderedCloseAction.getAttribute("aria-label")?.includes(lifecycleTabsAfterAdd[1].title)
&& renderedRenameAction.getAttribute("aria-label")?.includes(lifecycleTabsAfterAdd[1].title),
"Runtime smoke expected active-tab action names to identify the current tab."
);
assert(
renderedCloseAction.closest('[role="tab"]') === null && renderedRenameAction.closest('[role="tab"]') === null,
"Runtime smoke expected inline tab actions to remain outside role=tab."
);
assert(
renderedCloseAllAction && !renderedCloseAllAction.disabled,
"Runtime smoke expected Close all to be enabled when two tabs exist."
);

context.requestCloseAllSqlTabs();
assert(
closeAllTabsConfirmModal?.style?.display === "flex",
"Runtime smoke expected Close all to open its destructive confirmation modal."
);
assert(
/2/.test(closeAllTabsTitle?.textContent || "") && /2/.test(closeAllTabsConfirmText?.textContent || ""),
"Runtime smoke expected Close all confirmation copy to include the exact tab count."
);
context.cancelCloseAllSqlTabs();
assert(
context.getSqlTabsItems().length === 2,
"Runtime smoke expected canceling Close all to preserve every SQL tab."
);

context.requestCloseSqlTab(baseTab.id);
assert(
Expand Down
Loading