diff --git a/accessibility-checker-extension/src/ts/contentScripts/DomPathUtils.ts b/accessibility-checker-extension/src/ts/contentScripts/DomPathUtils.ts index bafbf5f0c..fee3e6229 100644 --- a/accessibility-checker-extension/src/ts/contentScripts/DomPathUtils.ts +++ b/accessibility-checker-extension/src/ts/contentScripts/DomPathUtils.ts @@ -113,8 +113,12 @@ export default class DomPathUtils { let slotParts = parts[2].match(/(slot\[\d+\])\/([^[]*)\[(\d+)\]/)!; let slot = this.docDomPathToElement(doc, parts[1]+slotParts[1]); let count = parseInt(slotParts[3]); - for (let slotIdx=0; slotIdx < (slot as any).assignedNodes().length; ++slotIdx) { - let slotNode = (slot as any).assignedNodes()[slotIdx]; + // Use assignedElements() if available, otherwise filter assignedNodes() for element nodes + let assignedElems = (slot as any).assignedElements + ? (slot as any).assignedElements() + : (slot as any).assignedNodes().filter((n: Node) => n.nodeType === 1); + for (let slotIdx=0; slotIdx < assignedElems.length; ++slotIdx) { + let slotNode = assignedElems[slotIdx]; if (slotNode.nodeName.toLowerCase() === slotParts[2].toLowerCase()) { --count; if (count === 0) { diff --git a/accessibility-checker-extension/src/ts/devtools/components/reportSection.tsx b/accessibility-checker-extension/src/ts/devtools/components/reportSection.tsx index df8d8be0c..f0d30e10d 100644 --- a/accessibility-checker-extension/src/ts/devtools/components/reportSection.tsx +++ b/accessibility-checker-extension/src/ts/devtools/components/reportSection.tsx @@ -26,6 +26,7 @@ import { MenuItemSelectable } from "@carbon/react"; import { UtilIssue } from '../../util/UtilIssue'; +import { PathMatcher } from '../../util/PathMatcher'; import { Information @@ -190,11 +191,11 @@ export class ReportSection extends React.Component { issue.ignored = this.state.ignoredIssues.some(ignoredIssue => issueBaselineMatch(ignoredIssue, issue)); const checked = this.devtoolsAppController.getLevelFilters(); - let retVal = ( ((checked["Hidden"] && issue.ignored) || checked[UtilIssue.valueToStringSingular(issue.value) as eFilterLevel]) + let retVal = ( ((checked["Hidden"] && issue.ignored) || checked[UtilIssue.valueToStringSingular(issue.value) as eFilterLevel]) && (!this.state.focusMode || !this.state.selectedPath - || issue.path.dom.startsWith(this.state.selectedPath) - ) + || PathMatcher.matchesPath(issue.path.dom, this.state.selectedPath) + ) ); if (!checked["Hidden"] && issue.ignored) { return false; // JCH is this an override diff --git a/accessibility-checker-extension/src/ts/devtools/components/reportTreeGrid.tsx b/accessibility-checker-extension/src/ts/devtools/components/reportTreeGrid.tsx index 54f3a0ab8..96343c90d 100644 --- a/accessibility-checker-extension/src/ts/devtools/components/reportTreeGrid.tsx +++ b/accessibility-checker-extension/src/ts/devtools/components/reportTreeGrid.tsx @@ -40,6 +40,7 @@ import { ePanel, getDevtoolsController, ViewState } from '../devtoolsController' import { UtilIssue } from '../../util/UtilIssue'; import { UtilIssueReact } from '../../util/UtilIssueReact'; import { getBGController, issueBaselineMatch } from '../../background/backgroundController'; +import { PathMatcher } from '../../util/PathMatcher'; export interface IRowGroup { id: string; @@ -783,7 +784,7 @@ export class ReportTreeGrid extends React.Component { }) this.devtoolsController.addSelectedElementPathListener(async (newPath) => { this.setState( { selectedElemPath: newPath }); + this.setPath(newPath); }) this.devtoolsController.addFocusModeListener(async (newValue) => { this.setState({ focusMode: newValue }) @@ -196,10 +198,12 @@ export class ScanSection extends React.Component<{}, ScanSectionState> { } }) this.reportListener((await this.devtoolsController.getReport())!); - this.setState({ - viewState: (await this.devtoolsController.getViewState())!, + const currentPath = (await this.devtoolsController.getSelectedElementPath())!; + this.setState({ + viewState: (await this.devtoolsController.getViewState())!, storeReports: (await this.devtoolsController.getStoreReports()), - selectedElemPath: (await this.devtoolsController.getSelectedElementPath())! || "/html", + selectedElemPath: currentPath || "/html", + selectedPath: currentPath || null, focusMode: (await this.devtoolsController.getFocusMode()), storedReportsCount: (await this.devtoolsController.getStoredReportsMeta()).length, canScan: (await this.bgController.getTabInfo(tabId)).canScan @@ -259,7 +263,7 @@ export class ScanSection extends React.Component<{}, ScanSectionState> { for (const issue of issues) { let sing = UtilIssue.valueToStringSingular(issue.value); ++counts[sing as eLevel].total; - if (!this.state.selectedPath || issue.path.dom.startsWith(this.state.selectedPath)) { + if (!this.state.selectedPath || PathMatcher.matchesPath(issue.path.dom, this.state.selectedPath)) { ++counts[sing as eLevel].focused; } } @@ -268,12 +272,12 @@ export class ScanSection extends React.Component<{}, ScanSectionState> { for (const ignoredIssue of this.state.ignoredIssues) { if (!issues.some(issue => issueBaselineMatch(issue, ignoredIssue))) continue; ++counts["Hidden" as eLevel].total; - if (!this.state.selectedPath || ignoredIssue.path.dom.startsWith(this.state.selectedPath)) { + if (!this.state.selectedPath || PathMatcher.matchesPath(ignoredIssue.path.dom, this.state.selectedPath)) { ++counts["Hidden" as eLevel].focused; } let sing = UtilIssue.valueToStringSingular(ignoredIssue.value); --counts[sing as eLevel].total; - if (!this.state.selectedPath || ignoredIssue.path.dom.startsWith(this.state.selectedPath)) { + if (!this.state.selectedPath || PathMatcher.matchesPath(ignoredIssue.path.dom, this.state.selectedPath)) { --counts[sing as eLevel].focused; } } @@ -300,7 +304,7 @@ export class ScanSection extends React.Component<{}, ScanSectionState> { let retVal = (this.state.checked[UtilIssue.valueToStringSingular(issue.value) as eLevel] && (!this.state.focusMode || !this.state.selectedPath - || issue.path.dom.startsWith(this.state.selectedPath) + || PathMatcher.matchesPath(issue.path.dom, this.state.selectedPath) ) ); return retVal; diff --git a/accessibility-checker-extension/src/ts/devtools/devtoolsAppController.ts b/accessibility-checker-extension/src/ts/devtools/devtoolsAppController.ts index b9d7980bc..26c79550d 100644 --- a/accessibility-checker-extension/src/ts/devtools/devtoolsAppController.ts +++ b/accessibility-checker-extension/src/ts/devtools/devtoolsAppController.ts @@ -153,53 +153,104 @@ export class DevtoolsAppController { public hookSelectionChange() { chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { chrome.devtools.inspectedWindow.eval(`((node) => { - let countNode = (node) => { - let count = 0; - let findName = node.nodeName; - while (node) { - if (node.nodeName === findName) { - ++count; + function getIndex(n) { + if (n.assignedSlot) { + const assigned = n.assignedSlot.assignedElements + ? n.assignedSlot.assignedElements() + : Array.from(n.assignedSlot.assignedNodes()).filter(x => x.nodeType === 1); + let count = 0; + for (const el of assigned) { + if (el === n) break; + if (el.localName === n.localName) count++; } - node = node.previousElementSibling; + return "/" + n.localName + "[" + (count + 1) + "]"; + } + const parent = n.parentNode; + if (!parent) return "/" + n.localName + "[1]"; + let count = 0; + const children = parent.children; + for (let i = 0; i < children.length; i++) { + if (children[i] === n) break; + if (children[i].localName === n.localName) count++; + } + return "/" + n.localName + "[" + (count + 1) + "]"; + } + function getSlotIndex(slot) { + let count = 1; + let sib = slot.previousElementSibling; + while (sib) { + if (sib.localName === 'slot') count++; + sib = sib.previousElementSibling; } - return "/"+findName.toLowerCase()+"["+count+"]"; + return count; + } + function getIframePrefixPath(iframeElem) { + let prefix = ""; + let current = iframeElem; + while (current && current.nodeType === 1) { + prefix = getIndex(current) + prefix; + const parent = current.parentNode; + if (!parent) break; + if (parent.nodeType === 9) break; + else if (parent.nodeType === 11) { prefix = "/#document-fragment[1]" + prefix; current = parent.host; } + else if (parent.nodeType === 1) current = parent; + else break; + } + return prefix; } try { - let retVal = ""; - while (node && node.nodeType === 1) { - retVal = countNode(node)+retVal; - if (node.parentElement) { - node = node.parentElement; + if (!node || node.nodeType !== 1) return ""; + let segments = ""; + let current = node; + while (current && current.nodeType === 1) { + const assignedSlot = current.assignedSlot; + if (assignedSlot) { + segments = getIndex(current) + segments; + segments = "/slot[" + getSlotIndex(assignedSlot) + "]" + segments; + const slotParent = assignedSlot.parentNode; + if (!slotParent) break; + if (slotParent.nodeType === 11) { segments = "/#document-fragment[1]" + segments; current = slotParent.host; } + else if (slotParent.nodeType === 9) { + try { + const parentWin = slotParent.defaultView; + if (parentWin && parentWin.parent && parentWin.parent !== parentWin) { + const iframes = parentWin.parent.document.querySelectorAll("iframe"); + for (const iframe of iframes) { + try { if (iframe.contentDocument === slotParent) { segments = getIframePrefixPath(iframe) + segments; break; } } catch(e) {} + } + } + } catch(e) {} + break; + } + else if (slotParent.nodeType === 1) current = slotParent; + else break; } else { - let parentElement = null; - try { - // Check if we're in a shadow DOM - if (node.parentNode && node.parentNode.nodeType === 11) { - parentElement = node.parentNode.host; - retVal = "/#document-fragment[1]"+retVal; - } else { - // Check if we're in an iframe - let parentWin = node.ownerDocument.defaultView.parent; - let iframes = parentWin.document.documentElement.querySelectorAll("iframe"); - for (const iframe of iframes) { - try { - if (iframe.contentDocument === node.ownerDocument) { - parentElement = iframe; - break; - } - } catch (e) {} + segments = getIndex(current) + segments; + const parent = current.parentNode; + if (!parent) break; + if (parent.nodeType === 11) { segments = "/#document-fragment[1]" + segments; current = parent.host; } + else if (parent.nodeType === 9) { + try { + const parentWin = parent.defaultView; + if (parentWin && parentWin.parent && parentWin.parent !== parentWin) { + const iframes = parentWin.parent.document.querySelectorAll("iframe"); + for (const iframe of iframes) { + try { if (iframe.contentDocument === parent) { segments = getIframePrefixPath(iframe) + segments; break; } } catch(e) {} + } } - } - } catch (e) {} - node = parentElement; + } catch(e) {} + break; + } + else if (parent.nodeType === 1) current = parent; + else break; } } - return retVal; - } catch (err) { - console.error(err); - } + return segments; + } catch(err) { return ""; } })($0)`, async (result: string) => { - await this.devToolsController.setSelectedElementPath(result, true); + if (result) { + await this.devToolsController.setSelectedElementPath(result, true); + } }); }); chrome.devtools.inspectedWindow.eval(`inspect(document.documentElement);`); diff --git a/accessibility-checker-extension/src/ts/devtools/devtoolsController.ts b/accessibility-checker-extension/src/ts/devtools/devtoolsController.ts index 44b58663e..b2a453ac0 100644 --- a/accessibility-checker-extension/src/ts/devtools/devtoolsController.ts +++ b/accessibility-checker-extension/src/ts/devtools/devtoolsController.ts @@ -15,6 +15,7 @@ *****************************************************************************/ import { getBGController, issueBaselineMatch, TabChangeType } from "../background/backgroundController"; +import { PathMatcher } from "../util/PathMatcher"; import { IBasicTableRowRecord, IIssue, IMessage, IReport, IStoredReportMeta, UIIssue } from "../interfaces/interfaces"; import { CommonMessaging } from "../messaging/commonMessaging"; import { Controller, eControllerType, ListenerType } from "../messaging/controller"; @@ -431,36 +432,38 @@ export class DevtoolsController extends Controller { */ public async setSelectedElementPath(path: string | null, fromElemChange?: boolean) : Promise { return this.hook("setSelectedElementPath", { path, fromElemChange }, async () => { + const previousPath = devtoolsState!.lastElementPath; devtoolsState!.lastElementPath = path; if (fromElemChange === true) { - // This path came from the Elements panel selection changing - if (!this.programmaticInspect) { - // User clicked on this - if (Config.ELEM_FOCUS_MODE) { - await this.setFocusMode(true); - } - if (path && path !== devtoolsState!.lastElementPath) { - // if (path) { - let report = await this.getReport(); - if (report) { - let newIssue : IIssue | null = null; - for (const issue of report.results) { - if (issue.value[1] !== "PASS" && issue.path.dom === path) { - newIssue = issue; - break; - } else if (!newIssue && issue.value[1] !== "PASS" && issue.path.dom.startsWith(path)) { - newIssue = issue; - } + // This path came from the Elements panel selection changing. + // Always activate focus mode and update the selected element path. + // The programmaticInspect flag only suppresses auto-selecting a new + // issue — it must not block focus mode, because inspectPath is called + // precisely WHEN the user already has an issue selected and we want + // to keep showing the filtered view for that element. + if (Config.ELEM_FOCUS_MODE) { + await this.setFocusMode(true); + } + if (this.programmaticInspect) { + // Echo from inspectPath's own inspect(element) call — clear flag, + // do not auto-select a different issue. + this.programmaticInspect = false; + } else if (path && path !== previousPath) { + // Genuine user navigation — auto-select the nearest matching issue. + let report = await this.getReport(); + if (report) { + let newIssue : IIssue | null = null; + for (const issue of report.results) { + if (issue.value[1] !== "PASS" && issue.path.dom === path) { + newIssue = issue; + break; + } else if (!newIssue && issue.value[1] !== "PASS" && PathMatcher.matchesPath(issue.path.dom, path)) { + newIssue = issue; } - await this.setSelectedIssue(newIssue); } + if (newIssue) await this.setSelectedIssue(newIssue); } - } else { - // Side effect of inspectPath - this.programmaticInspect = false; } - } else { - // Called by some other process } setTimeout(() => { this.notifyEventListeners("DT_onSelectedElementPath", this.ctrlDest.tabId, path); diff --git a/accessibility-checker-extension/src/ts/util/PathMatcher.ts b/accessibility-checker-extension/src/ts/util/PathMatcher.ts new file mode 100644 index 000000000..2621ac4f9 --- /dev/null +++ b/accessibility-checker-extension/src/ts/util/PathMatcher.ts @@ -0,0 +1,37 @@ +/****************************************************************************** + Copyright:: 2020- IBM, Inc + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*****************************************************************************/ + +/** + * Utility class for matching DOM paths. + */ +export class PathMatcher { + /** + * Returns true if the issue element is the selected element or a descendant + * of it — i.e. the issue path equals the selected path or starts with it. + * + * Ancestor matching (selectedPath.startsWith(issuePath)) is intentionally + * excluded: when the user selects a specific element they only want to see + * issues on that element and its children, not on its ancestors. + * + * @param issuePath - The DOM path of the issue (from the scanner result) + * @param selectedPath - The DOM path of the currently selected element + */ + static matchesPath(issuePath: string, selectedPath: string): boolean { + return issuePath === selectedPath + || issuePath.startsWith(selectedPath + "/"); + } +} +