From a77e578c7b82eba88a2c70916c30f10cf43c6078 Mon Sep 17 00:00:00 2001 From: Marat Abdullin Date: Wed, 18 Mar 2026 00:53:41 +0100 Subject: [PATCH] UI with the issue groupping. --- src/rules/atomic.ts | 1 + src/rules/badfocus.ts | 1 + src/rules/base.ts | 6 + src/rules/contrast.ts | 1 + src/rules/existingid.ts | 1 + src/rules/find.ts | 1 + src/rules/focuslost.ts | 1 + src/rules/label.ts | 1 + src/rules/nestedInteractive.ts | 1 + src/rules/requiredparent.ts | 1 + src/rules/tabindex.ts | 1 + src/ui/chevrondown.svg | 5 + src/ui/chevronright.svg | 5 + src/ui/domBuilder.ts | 52 +- src/ui/ui.css | 158 +++- src/ui/ui.ts | 746 +++++++++++----- tests/notificationsUI/notificationsUI.html | 30 + tests/notificationsUI/notificationsUI.test.ts | 795 ++++++++++++++++++ tests/notificationsUI/notificationsUI.ts | 24 + tests/utils.ts | 3 +- 20 files changed, 1601 insertions(+), 233 deletions(-) create mode 100644 src/ui/chevrondown.svg create mode 100644 src/ui/chevronright.svg create mode 100644 tests/notificationsUI/notificationsUI.html create mode 100644 tests/notificationsUI/notificationsUI.test.ts create mode 100644 tests/notificationsUI/notificationsUI.ts diff --git a/src/rules/atomic.ts b/src/rules/atomic.ts index f758f6e..2a547ee 100644 --- a/src/rules/atomic.ts +++ b/src/rules/atomic.ts @@ -9,6 +9,7 @@ export class AtomicRule extends ValidationRule { type = ValidationRuleType.Error; name = "atomic"; anchored = true; + groupName = "Atomic violation"; accept(element: HTMLElement): boolean { return matchesSelector(element, focusableElementSelector); diff --git a/src/rules/badfocus.ts b/src/rules/badfocus.ts index 6dbc17c..1de5177 100644 --- a/src/rules/badfocus.ts +++ b/src/rules/badfocus.ts @@ -10,6 +10,7 @@ export class BadFocusRule extends ValidationRule { type = ValidationRuleType.Error; name = "bad-focus"; anchored = false; + groupName = "Bad focus"; private _lastFocusStack: string[] | undefined; private _lastBlurStack: string[] | undefined; diff --git a/src/rules/base.ts b/src/rules/base.ts index a43d7f7..6e95b94 100644 --- a/src/rules/base.ts +++ b/src/rules/base.ts @@ -88,6 +88,12 @@ export abstract class ValidationRule< */ abstract anchored: boolean; + /** + * A short friendly group name for grouping issues in the UI. + * If undefined, the issue will not be grouped. + */ + groupName: string | undefined; + /** * Window is set when the rule is added to the AbleDOM instance. */ diff --git a/src/rules/contrast.ts b/src/rules/contrast.ts index fe96c04..d28b4c8 100644 --- a/src/rules/contrast.ts +++ b/src/rules/contrast.ts @@ -74,6 +74,7 @@ export class ContrastRule extends ValidationRule { type = ValidationRuleType.Error; name = "ContrastRule"; anchored = true; + groupName = "Insufficient color contrast"; accept(element: HTMLElement): boolean { if (!isElementVisible(element)) { diff --git a/src/rules/existingid.ts b/src/rules/existingid.ts index 4878ef3..f63a86a 100644 --- a/src/rules/existingid.ts +++ b/src/rules/existingid.ts @@ -9,6 +9,7 @@ export class ExistingIdRule extends ValidationRule { type = ValidationRuleType.Error; name = "existing-id"; anchored = true; + groupName = "Referenced element not found"; accept(element: HTMLElement): boolean { return ( diff --git a/src/rules/find.ts b/src/rules/find.ts index 327999e..eafbff7 100644 --- a/src/rules/find.ts +++ b/src/rules/find.ts @@ -8,6 +8,7 @@ export class FindElementRule extends ValidationRule { type = ValidationRuleType.Warning; name = "find-element"; anchored = true; + groupName = "Element found"; private _conditions: { [name: string]: (element: HTMLElement) => boolean } = {}; diff --git a/src/rules/focuslost.ts b/src/rules/focuslost.ts index 30d8d3a..af3da7f 100644 --- a/src/rules/focuslost.ts +++ b/src/rules/focuslost.ts @@ -10,6 +10,7 @@ export class FocusLostRule extends ValidationRule { type = ValidationRuleType.Error; name = "focus-lost"; anchored = false; + groupName = "Focus lost"; private _focusLostTimeout = 2000; // For now reporting lost focus after 2 seconds of it being lost. private _clearScheduledFocusLost: (() => void) | undefined; diff --git a/src/rules/label.ts b/src/rules/label.ts index 3535ed8..bfdb588 100644 --- a/src/rules/label.ts +++ b/src/rules/label.ts @@ -29,6 +29,7 @@ export class FocusableElementLabelRule extends ValidationRule { type = ValidationRuleType.Error; name = "FocusableElementLabelRule"; anchored = true; + groupName = "Missing text label"; private _isAriaHidden(element: HTMLElement): boolean { return element.ownerDocument.evaluate( diff --git a/src/rules/nestedInteractive.ts b/src/rules/nestedInteractive.ts index cb8c7c0..07ab63f 100644 --- a/src/rules/nestedInteractive.ts +++ b/src/rules/nestedInteractive.ts @@ -32,6 +32,7 @@ export class NestedInteractiveElementRule extends ValidationRule { type = ValidationRuleType.Error; name = "NestedInteractiveElementRule"; anchored = true; + groupName = "Nested interactive element"; private _isAriaHidden(element: HTMLElement): boolean { return element.ownerDocument.evaluate( diff --git a/src/rules/requiredparent.ts b/src/rules/requiredparent.ts index 49b376b..1f75148 100644 --- a/src/rules/requiredparent.ts +++ b/src/rules/requiredparent.ts @@ -21,6 +21,7 @@ export class RequiredParentRule extends ValidationRule { type = ValidationRuleType.Error; name = "aria-required-parent"; anchored = true; + groupName = "Missing required parent"; private parentRequirements: Map = new Map([ [ diff --git a/src/rules/tabindex.ts b/src/rules/tabindex.ts index 5842671..71a98f1 100644 --- a/src/rules/tabindex.ts +++ b/src/rules/tabindex.ts @@ -47,6 +47,7 @@ export class TabIndexRule extends ValidationRule { type = ValidationRuleType.Warning; name = "tabindex"; anchored = true; + groupName = "Positive tabindex"; accept(element: HTMLElement): boolean { return element.hasAttribute("tabindex"); diff --git a/src/ui/chevrondown.svg b/src/ui/chevrondown.svg new file mode 100644 index 0000000..93d24d2 --- /dev/null +++ b/src/ui/chevrondown.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/ui/chevronright.svg b/src/ui/chevronright.svg new file mode 100644 index 0000000..65d28f0 --- /dev/null +++ b/src/ui/chevronright.svg @@ -0,0 +1,5 @@ + + + diff --git a/src/ui/domBuilder.ts b/src/ui/domBuilder.ts index 6479ef1..b37bb6b 100644 --- a/src/ui/domBuilder.ts +++ b/src/ui/domBuilder.ts @@ -15,7 +15,7 @@ export interface TextNodeWithAbleDOMUIFlag extends Text { export class DOMBuilder { private _doc: Document | undefined; - private _stack: (HTMLElement | DocumentFragment)[]; + private _stack: (HTMLElement | DocumentFragment | null)[]; constructor(parent: HTMLElement | DocumentFragment) { this._doc = parent.ownerDocument; @@ -27,34 +27,37 @@ export class DOMBuilder { attributes?: Record, callback?: (element: HTMLElement) => void, namespace?: string, + skip?: boolean, ): DOMBuilder { const parent = this._stack[0]; - const element = ( - namespace - ? this._doc?.createElementNS(namespace, tagName) - : this._doc?.createElement(tagName) - ) as HTMLElementWithAbleDOMUIFlag; - - if (parent && element) { - element.__abledomui = true; - - if (attributes) { - for (const [key, value] of Object.entries(attributes)) { - if (key === "class") { - element.className = value; - } else if (key === "style") { - element.style.cssText = value; - } else { - element.setAttribute(key, value); + const element = skip + ? null + : ((namespace + ? this._doc?.createElementNS(namespace, tagName) + : this._doc?.createElement(tagName)) as HTMLElementWithAbleDOMUIFlag); + + if (parent !== undefined) { + if (parent && element) { + element.__abledomui = true; + + if (attributes) { + for (const [key, value] of Object.entries(attributes)) { + if (key === "class") { + element.className = value; + } else if (key === "style") { + element.style.cssText = value; + } else { + element.setAttribute(key, value); + } } } - } - if (callback) { - callback(element); - } + if (callback) { + callback(element); + } - parent.appendChild(element); + parent.appendChild(element); + } this._stack.unshift(element); } @@ -87,7 +90,8 @@ export class DOMBuilder { element( callback: (element: HTMLElement | DocumentFragment) => void, ): DOMBuilder { - callback(this._stack[0]); + const el = this._stack[0]; + el && callback(el); return this; } } diff --git a/src/ui/ui.css b/src/ui/ui.css index 03000f0..4011774 100644 --- a/src/ui/ui.css +++ b/src/ui/ui.css @@ -11,8 +11,8 @@ } #abledom-report :focus-visible { - outline: 3px solid red; mix-blend-mode: difference; + outline: 3px solid red; } #abledom-report.abledom-align-left { @@ -37,37 +37,76 @@ } .abledom-menu-container { - backdrop-filter: blur(3px); - border-radius: 8px; - box-shadow: 0px 0px 4px rgba(127, 127, 127, 0.5); - display: inline-block; margin: 2px auto 2px 0; + padding: 8px 0; } #abledom-report.abledom-align-right .abledom-menu-container { margin: 2px 0 2px auto; } +.abledom-menu-wrapper { + backdrop-filter: blur(3px); + border-radius: 20px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + .abledom-menu { background-color: rgba(140, 10, 121, 0.7); - border-radius: 8px; + border-radius: 20px; color: white; display: inline flex; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 26px; - padding: 4px; + padding: 6px; + position: relative; } .abledom-menu .issues-count { - margin: 0 8px; + cursor: pointer; display: inline-block; + margin: 0 8px; +} + +.abledom-menu-container .controls-wrapper { + display: none; + left: calc(100% - 28px); + position: absolute; + top: -9px; +} + +.abledom-menu .controls-wrapper { + padding: 8px; +} + +.abledom-menu .controls { + background: #fff; + border-radius: 4px; + border: 1px solid #ddd; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15); + display: flex; + margin-left: 30px; + padding: 6px; +} + +.abledom-menu-container:hover .controls-wrapper { + display: block; +} + +#abledom-report.abledom-align-right .controls-wrapper { + left: auto; + right: calc(100% - 28px); +} + +#abledom-report.abledom-align-right .controls { + left: auto; + margin: 0 30px 0 0; + right: calc(100%); } .abledom-menu .button { all: unset; - color: #000; - cursor: pointer; background: linear-gradient( 180deg, rgba(255, 255, 255, 1) 0%, @@ -76,6 +115,8 @@ border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.4); box-sizing: border-box; + color: #000; + cursor: pointer; line-height: 0px; margin-right: 4px; max-height: 26px; @@ -84,8 +125,8 @@ } .abledom-menu .align-button { - border-right-color: rgba(0, 0, 0, 0.4); border-radius: 0; + border-right-color: rgba(0, 0, 0, 0.4); margin: 0; } @@ -99,19 +140,19 @@ } .abledom-menu .align-button-first { - border-top-left-radius: 6px; border-bottom-left-radius: 6px; + border-top-left-radius: 6px; margin-left: 8px; } .abledom-menu .align-button-last { - border-top-right-radius: 6px; border-bottom-right-radius: 6px; border-right-color: rgba(255, 255, 255, 0.4); + border-top-right-radius: 6px; } .abledom-issues-container { - overflow: auto; max-height: calc(100vh - 100px); + overflow: auto; } #abledom-report.abledom-align-right .abledom-issues-container { @@ -123,18 +164,46 @@ border-radius: 8px; box-shadow: 0px 0px 4px rgba(127, 127, 127, 0.5); display: inline-flex; - margin: 2px 0; + margin-top: 5px; } -.abledom-issue { +.abledom-issue-group-issues { + border-bottom-left-radius: 10px; + border-left: 3px solid rgba(164, 2, 2, 0.7); + margin-left: 15px; + padding-left: 10px; +} + +#abledom-report.abledom-align-right .abledom-issue-group-issues { + border-bottom-right-radius: 10px; + border-left: none; + border-right: 3px solid rgba(164, 2, 2, 0.7); + margin-left: 0; + margin-right: 15px; + padding-left: 0; + padding-right: 10px; +} + +.abledom-issue-group-title { background-color: rgba(164, 2, 2, 0.7); - border-radius: 8px; color: white; +} + +.abledom-issue-group-count { + font-size: 14px; + margin: 0 4px; + opacity: 0.8; +} + +.abledom-issue { + border-radius: 8px; + color: #fff; display: inline flex; font-family: Arial, Helvetica, sans-serif; font-size: 16px; line-height: 26px; padding: 4px; + text-align: left; } .abledom-issue_warning { background-color: rgba(163, 82, 1, 0.7); @@ -143,10 +212,23 @@ background-color: rgba(0, 0, 255, 0.7); } +.abledom-issue-group { + margin: 5px 0 0 0; +} + +.abledom-issue-group-issues .abledom-issue { + background-color: rgba(198, 198, 198, 0.7); + border: 1px solid #ccc; + color: #000; +} + +.abledom-issue-group-issues .abledom-issue .button.close, +.abledom-issue-group-issues .abledom-issue .button.help { + color: #000; +} + .abledom-issue .button { all: unset; - color: #000; - cursor: pointer; background: linear-gradient( 180deg, rgba(255, 255, 255, 1) 0%, @@ -155,6 +237,8 @@ border-radius: 6px; border: 1px solid rgba(255, 255, 255, 0.4); box-sizing: border-box; + color: #000; + cursor: pointer; line-height: 0px; margin-right: 4px; max-height: 26px; @@ -172,3 +256,39 @@ color: #fff; margin: 0; } + +.abledom-issue .button.help { + background: none; + border-color: transparent; + color: #fff; + margin: 0 0 0 2px; +} + +.abledom-issue-group-count { + background-color: #444; + border-radius: 10px; + border: 1px solid black; + box-sizing: border-box; + color: #fff; + display: inline-block; + height: 20px; + min-width: 20px; + padding: 0 5px; + text-align: center; +} + +.abledom-issue .button.toggle { + display: flex; + line-height: 20px; +} + +.abledom-issue .button.toggle svg:nth-child(1) { + display: none; +} + +.abledom-issue .button.toggle.collapsed svg:nth-child(1) { + display: inline; +} +.abledom-issue .button.toggle.collapsed svg:nth-child(2) { + display: none; +} diff --git a/src/ui/ui.ts b/src/ui/ui.ts index 792586a..a86a9fe 100644 --- a/src/ui/ui.ts +++ b/src/ui/ui.ts @@ -42,6 +42,10 @@ import svgAlignTopRight from "./aligntopright.svg?raw"; import svgAlignBottomRight from "./alignbottomright.svg?raw"; // @ts-expect-error parsed assets import svgAlignBottomLeft from "./alignbottomleft.svg?raw"; +// @ts-expect-error parsed assets +import svgChevronDown from "./chevrondown.svg?raw"; +// @ts-expect-error parsed assets +import svgChevronRight from "./chevronright.svg?raw"; import { DOMBuilder } from "./domBuilder"; @@ -49,6 +53,10 @@ import type { HTMLElementWithAbleDOMUIFlag } from "./domBuilder"; export { HTMLElementWithAbleDOMUIFlag }; +interface HTMLElementWithIssueUI extends HTMLElementWithAbleDOMUIFlag { + __ableDOMIssueUI?: IssueUI; +} + export interface BugReportProperty { isVisible: (issue: ValidationIssue) => boolean; onClick: (issue: ValidationIssue) => void; @@ -69,6 +77,14 @@ enum UIAlignments { const pressedClass = "pressed"; +function getIssueTypeClass(type: ValidationRuleType): string { + return type === ValidationRuleType.Warning + ? " abledom-issue_warning" + : type === ValidationRuleType.Info + ? " abledom-issue_info" + : ""; +} + // interface WindowWithAbleDOMDevtools extends Window { // __ableDOMDevtools?: { // revealElement?: (element: HTMLElement) => Promise; @@ -88,9 +104,10 @@ export class IssueUI { private _wrapper: HTMLElementWithAbleDOMUIFlag | undefined; private _rule: ValidationRule; private _onToggle: ((issueUI: IssueUI, show: boolean) => void) | undefined; + private _appended = false; - static getElement(instance: IssueUI): HTMLElement | undefined { - return instance._wrapper; + static setAppended(instance: IssueUI): void { + instance._appended = true; } isHidden = false; @@ -109,41 +126,78 @@ export class IssueUI { this._wrapper = win.document.createElement( "div", ) as HTMLElementWithAbleDOMUIFlag; + + (this._wrapper as HTMLElementWithIssueUI).__ableDOMIssueUI = this; + this._wrapper.className = "abledom-issue-container-wrapper"; } issuesUI.addIssue(this); } update(issue: ValidationIssue): void { - const rule = this._rule; const wrapper = this._wrapper; - const element = issue.element; if (!wrapper) { return; } + this._appendToIssueContainer(issue); + wrapper.__abledomui = true; wrapper.textContent = ""; + this._renderIssueContent(wrapper, issue); + } + + private _appendToIssueContainer(issue: ValidationIssue): void { + if (!this._appended) { + return; + } + + const wrapper = this._wrapper; + const rule = this._rule; + + if (!wrapper) { + return; + } + + const issueContainer = this._issuesUI?.getIssueContainer( + rule.type, + rule.groupName, + issue.help, + ); + + if (!issueContainer || wrapper.parentElement === issueContainer) { + return; + } + + issueContainer.appendChild(wrapper); + + if (!this.isHidden) { + if ( + issueContainer.parentElement?.classList.contains("abledom-issue-group") + ) { + issueContainer.parentElement.style.display = "block"; + } + } else { + this.toggle(false, true); + } + + this._issuesUI?.syncGroupIssueCounts(); + } + + private _renderIssueContent( + wrapper: HTMLElementWithAbleDOMUIFlag, + issue: ValidationIssue, + ): void { + const rule = this._rule; + const element = issue.element; new DOMBuilder(wrapper) .openTag("div", { class: "abledom-issue-container" }, (container) => { - container.onmouseenter = () => { - element && this._issuesUI?.highlight(element); - }; - - container.onmouseleave = () => { - this._issuesUI?.highlight(null); - }; + this._bindIssueContainerHover(container, element); }) .openTag("div", { - class: `abledom-issue${ - rule.type === ValidationRuleType.Warning - ? " abledom-issue_warning" - : rule.type === ValidationRuleType.Info - ? " abledom-issue_info" - : "" - }`, + class: `abledom-issue${getIssueTypeClass(rule.type)}`, }) .openTag( "button", @@ -152,22 +206,7 @@ export class IssueUI { title: "Log to Console", }, (logButton) => { - logButton.onclick = () => { - const { id, message, element, rel, help, ...extra } = issue; - - this._core.log( - "AbleDOM: ", - "\nid:", - id, - "\nmessage:", - message, - "\nelement:", - element, - ...(rel ? ["\nrelative:", rel] : []), - ...(help ? ["\nhelp:", help] : []), - ...(Object.keys(extra).length > 0 ? ["\nextra:", extra] : []), - ); - }; + this._bindLogButton(logButton, issue); }, ) .element(svgLog) @@ -180,37 +219,10 @@ export class IssueUI { title: "Scroll element into view", }, (revealButton: HTMLElement) => { - const element = issue.element; - - if (element) { - revealButton.onclick = () => { - this._issuesUI?.highlight(element, true); - }; - } else { - revealButton.style.display = "none"; - } - // const body = win.document.body; - // const hasDevTools = - // !!(win as WindowWithAbleDOMDevtools).__ableDOMDevtools - // ?.revealElement && false; // Temtorarily disabling the devtools plugin integration. - - // if (hasDevTools && element && body.contains(element)) { - // revealButton.onclick = () => { - // const revealElement = (win as WindowWithAbleDOMDevtools) - // .__ableDOMDevtools?.revealElement; - - // if (revealElement && body.contains(element)) { - // revealElement(element).then((revealed: boolean) => { - // if (!revealed) { - // // TODO - // } - // }); - // } - // }; - // } else { - // revealButton.style.display = "none"; - // } + this._bindRevealButton(revealButton, issue); }, + undefined, + !issue.element, ) .element(svgReveal) .closeTag() @@ -221,21 +233,7 @@ export class IssueUI { title: "Report bug", }, (bugReportButton) => { - const bugReport = this._issuesUI?.bugReport; - - if (bugReport?.isVisible(issue)) { - const title = bugReport.getTitle?.(issue); - - if (title) { - bugReportButton.title = title; - } - - bugReportButton.onclick = () => { - bugReport.onClick(issue); - }; - } else { - bugReportButton.style.display = "none"; - } + this._bindBugReportButton(bugReportButton, issue); }, ) .element(svgBugReport) @@ -244,16 +242,16 @@ export class IssueUI { .openTag( "a", { - class: "button close", + class: "button help", href: issue.help || "/", title: "Open help", target: "_blank", }, (help) => { - if (!issue.help) { - help.style.display = "none"; - } + this._bindHelpLink(help, issue.help); }, + undefined, + !!rule.groupName, ) .element(svgHelp) .closeTag() @@ -264,10 +262,7 @@ export class IssueUI { title: "Hide", }, (closeButton) => { - closeButton.onclick = () => { - this.toggle(false); - this._issuesUI?.highlight(null); - }; + this._bindCloseButton(closeButton); }, ) .element(svgClose) @@ -276,22 +271,139 @@ export class IssueUI { .closeTag(); } - toggle(show: boolean, initial = false) { + private _bindIssueContainerHover( + container: HTMLElement, + element: HTMLElement | null | undefined, + ): void { + container.onmouseenter = () => { + element && this._issuesUI?.highlight(element); + }; + + container.onmouseleave = () => { + this._issuesUI?.highlight(null); + }; + } + + private _bindLogButton(button: HTMLElement, issue: ValidationIssue): void { + button.onclick = () => { + const { id, message, element, rel, help, ...extra } = issue; + + this._core.log( + "AbleDOM: ", + "\nid:", + id, + "\nmessage:", + message, + "\nelement:", + element, + ...(rel ? ["\nrelative:", rel] : []), + ...(help ? ["\nhelp:", help] : []), + ...(Object.keys(extra).length > 0 ? ["\nextra:", extra] : []), + ); + }; + } + + private _bindRevealButton(button: HTMLElement, issue: ValidationIssue): void { + const element = issue.element; + + if (element) { + button.onclick = () => { + this._issuesUI?.highlight(element, true); + }; + } + } + + private _bindBugReportButton( + button: HTMLElement, + issue: ValidationIssue, + ): void { + const bugReport = this._issuesUI?.bugReport; + + if (bugReport?.isVisible(issue)) { + const title = bugReport.getTitle?.(issue); + + if (title) { + button.title = title; + } + + button.onclick = () => { + bugReport.onClick(issue); + }; + } else { + button.style.display = "none"; + } + } + + private _bindHelpLink(help: HTMLElement, issueHelp?: string): void { + if (!issueHelp) { + help.style.display = "none"; + } + } + + private _bindCloseButton(button: HTMLElement): void { + button.onclick = () => { + this.toggle(false); + this._issuesUI?.highlight(null); + }; + } + + toggle(show: boolean | undefined, initial = false) { if (!this._wrapper) { return; } - this.isHidden = !show; + if (show === undefined) { + this.isHidden = !this.isHidden; + show = !this.isHidden; + } else { + this.isHidden = !show; + } if (!initial) { - this._onToggle?.(this, show); + this._onToggle?.(this, !this.isHidden); - if (!this._rule.anchored && !show) { + if (!this._rule.anchored && this.isHidden) { this.dispose(); } } this._wrapper.style.display = show ? "block" : "none"; + this._syncGroupVisibility(!!show); + } + + private _syncGroupVisibility(show: boolean): void { + const wrapper = this._wrapper; + + if (!wrapper) { + return; + } + + const potentiallyGroupIssuesElement = wrapper.parentElement; + const potentiallyGroupElement = + potentiallyGroupIssuesElement?.parentElement; + + if ( + !potentiallyGroupIssuesElement || + !potentiallyGroupElement?.classList.contains("abledom-issue-group") + ) { + return; + } + + if (show) { + potentiallyGroupElement.style.display = "block"; + return; + } + + if ( + potentiallyGroupElement.style.display !== "none" && + Array.prototype.every.call( + potentiallyGroupIssuesElement.children, + (el: HTMLElementWithIssueUI) => + el.__ableDOMIssueUI ? el.__ableDOMIssueUI?.isHidden : true, + ) + ) { + potentiallyGroupElement.style.display = "none"; + } } dispose() { @@ -316,6 +428,9 @@ export class IssuesUI { private _isMuted = false; private _issues: Set = new Set(); + private _issueGroupContainers: Record = + {}; + private _issueGroupCountElements: Record = {}; private _getHighlighter?: () => ElementHighlighter | undefined; @@ -337,9 +452,20 @@ export class IssuesUI { } const doc = win.document; + const container = (this._container = this._createRootContainer(doc)); + this._issuesContainer = this._createIssuesContainer(doc, container); + const menuElement = (this._menuElement = this._createMenuContainer( + doc, + container, + )); + + this._buildMenu(menuElement); + + doc.body.appendChild(container); + } - const container = (this._container = - doc.createElement("div")) as HTMLElementWithAbleDOMUIFlag; + private _createRootContainer(doc: Document): HTMLElementWithAbleDOMUIFlag { + const container = doc.createElement("div") as HTMLElementWithAbleDOMUIFlag; container.__abledomui = true; container.id = "abledom-report"; @@ -348,19 +474,38 @@ export class IssuesUI { style.appendChild(doc.createTextNode(uiCSS)); container.appendChild(style); - const issuesContainer = (this._issuesContainer = - doc.createElement("div")) as HTMLElementWithAbleDOMUIFlag; + return container; + } + + private _createIssuesContainer( + doc: Document, + container: HTMLElementWithAbleDOMUIFlag, + ): HTMLElementWithAbleDOMUIFlag { + const issuesContainer = doc.createElement( + "div", + ) as HTMLElementWithAbleDOMUIFlag; issuesContainer.__abledomui = true; issuesContainer.className = "abledom-issues-container"; container.appendChild(issuesContainer); + return issuesContainer; + } - const menuElement = (this._menuElement = - doc.createElement("div")) as HTMLElementWithAbleDOMUIFlag; + private _createMenuContainer( + doc: Document, + container: HTMLElementWithAbleDOMUIFlag, + ): HTMLElementWithAbleDOMUIFlag { + const menuElement = doc.createElement( + "div", + ) as HTMLElementWithAbleDOMUIFlag; menuElement.__abledomui = true; menuElement.className = "abledom-menu-container"; container.appendChild(menuElement); + return menuElement; + } + private _buildMenu(menuElement: HTMLElementWithAbleDOMUIFlag): void { new DOMBuilder(menuElement) + .openTag("div", { class: "abledom-menu-wrapper" }) .openTag("div", { class: "abledom-menu" }) .openTag( "span", @@ -369,10 +514,12 @@ export class IssuesUI { title: "Number of issues", }, (issueCountElement) => { - this._issueCountElement = issueCountElement; + this._bindIssueCountElement(issueCountElement); }, ) .closeTag() + .openTag("div", { class: "controls-wrapper" }) + .openTag("div", { class: "controls" }) .openTag( "button", { @@ -381,10 +528,7 @@ export class IssuesUI { }, (showAllButton) => { this._showAllButton = showAllButton; - - showAllButton.onclick = () => { - this.showAll(); - }; + this._bindToggleAllButton(showAllButton, true); }, ) .element(svgShowAll) @@ -397,10 +541,7 @@ export class IssuesUI { }, (hideAllButton) => { this._hideAllButton = hideAllButton; - - hideAllButton.onclick = () => { - this.hideAll(); - }; + this._bindToggleAllButton(hideAllButton, false); }, ) .element(svgHideAll) @@ -412,16 +553,7 @@ export class IssuesUI { title: "Mute newly appearing issues", }, (muteButton) => { - muteButton.onclick = () => { - const isMuted = (this._isMuted = - muteButton.classList.toggle(pressedClass)); - - if (isMuted) { - muteButton.setAttribute("title", "Unmute newly appearing issues"); - } else { - muteButton.setAttribute("title", "Mute newly appearing issues"); - } - }; + this._bindMuteButton(muteButton); }, ) .element(svgMuteAll) @@ -434,10 +566,10 @@ export class IssuesUI { }, (alignBottomLeftButton) => { this._alignBottomLeftButton = alignBottomLeftButton; - - alignBottomLeftButton.onclick = () => { - this.setUIAlignment(UIAlignments.BottomLeft); - }; + this._bindAlignmentButton( + alignBottomLeftButton, + UIAlignments.BottomLeft, + ); }, ) .element(svgAlignBottomLeft) @@ -450,10 +582,7 @@ export class IssuesUI { }, (alignTopLeftButton) => { this._alignTopLeftButton = alignTopLeftButton; - - alignTopLeftButton.onclick = () => { - this.setUIAlignment(UIAlignments.TopLeft); - }; + this._bindAlignmentButton(alignTopLeftButton, UIAlignments.TopLeft); }, ) .element(svgAlignTopLeft) @@ -466,10 +595,7 @@ export class IssuesUI { }, (alignTopRightButton) => { this._alignTopRightButton = alignTopRightButton; - - alignTopRightButton.onclick = () => { - this.setUIAlignment(UIAlignments.TopRight); - }; + this._bindAlignmentButton(alignTopRightButton, UIAlignments.TopRight); }, ) .element(svgAlignTopRight) @@ -482,17 +608,52 @@ export class IssuesUI { }, (alignBottomRightButton) => { this._alignBottomRightButton = alignBottomRightButton; - - alignBottomRightButton.onclick = () => { - this.setUIAlignment(UIAlignments.BottomRight); - }; + this._bindAlignmentButton( + alignBottomRightButton, + UIAlignments.BottomRight, + ); }, ) .element(svgAlignBottomRight) .closeTag() + .closeTag() + .closeTag() + .closeTag() .closeTag(); + } - doc.body.appendChild(container); + private _bindToggleAllButton(button: HTMLElement, show: boolean): void { + button.onclick = () => { + this.toggleAll(show); + }; + } + + private _bindIssueCountElement(element: HTMLElement): void { + this._issueCountElement = element as HTMLSpanElement; + element.onclick = () => { + this.toggleAll(); + }; + } + + private _bindMuteButton(button: HTMLElement): void { + button.onclick = () => { + const isMuted = (this._isMuted = button.classList.toggle(pressedClass)); + button.setAttribute( + "title", + isMuted + ? "Unmute newly appearing issues" + : "Mute newly appearing issues", + ); + }; + } + + private _bindAlignmentButton( + button: HTMLElement, + alignment: UIAlignments, + ): void { + button.onclick = () => { + this.setUIAlignment(alignment); + }; } private setUIAlignment(alignment: UIAlignments) { @@ -500,46 +661,63 @@ export class IssuesUI { return; } - this._alignBottomLeftButton?.classList.remove(pressedClass); - this._alignBottomRightButton?.classList.remove(pressedClass); - this._alignTopLeftButton?.classList.remove(pressedClass); - this._alignTopRightButton?.classList.remove(pressedClass); - + this._clearAlignmentButtons(); this._container.classList.remove( "abledom-align-left", "abledom-align-right", "abledom-align-top", "abledom-align-bottom", ); + + const { containerClasses, issuesFirst, activeButton } = + this._getAlignmentLayout(alignment); + activeButton?.classList.add(pressedClass); + + this._container.classList.add(...containerClasses); + this._container.insertBefore( + this._issuesContainer, + issuesFirst ? this._menuElement : null, + ); + } + + private _clearAlignmentButtons(): void { + this._alignBottomLeftButton?.classList.remove(pressedClass); + this._alignBottomRightButton?.classList.remove(pressedClass); + this._alignTopLeftButton?.classList.remove(pressedClass); + this._alignTopRightButton?.classList.remove(pressedClass); + } + + private _getAlignmentLayout(alignment: UIAlignments): { + containerClasses: string[]; + issuesFirst: boolean; + activeButton: HTMLElement | undefined; + } { let containerClasses: string[] = []; let issuesFirst = false; + let activeButton: HTMLElement | undefined; switch (alignment) { case UIAlignments.BottomLeft: containerClasses = ["abledom-align-left", "abledom-align-bottom"]; issuesFirst = true; - this._alignBottomLeftButton?.classList.add(pressedClass); + activeButton = this._alignBottomLeftButton; break; case UIAlignments.BottomRight: containerClasses = ["abledom-align-right", "abledom-align-bottom"]; issuesFirst = true; - this._alignBottomRightButton?.classList.add(pressedClass); + activeButton = this._alignBottomRightButton; break; case UIAlignments.TopLeft: containerClasses = ["abledom-align-left", "abledom-align-top"]; - this._alignTopLeftButton?.classList.add(pressedClass); + activeButton = this._alignTopLeftButton; break; case UIAlignments.TopRight: containerClasses = ["abledom-align-right", "abledom-align-top"]; - this._alignTopRightButton?.classList.add(pressedClass); + activeButton = this._alignTopRightButton; break; } - this._container.classList.add(...containerClasses); - this._container.insertBefore( - this._issuesContainer, - issuesFirst ? this._menuElement : null, - ); + return { containerClasses, issuesFirst, activeButton }; } private _setIssuesCount(count: number) { @@ -550,16 +728,28 @@ export class IssuesUI { const countElement = this._issueCountElement; if (countElement && count > 0) { - countElement.textContent = ""; - new DOMBuilder(countElement) - .openTag("strong") - .text(`${count}`) - .closeTag() - .text(` issue${count > 1 ? "s" : ""}`); - - this._menuElement.style.display = "block"; + this._renderIssueCountText(countElement, count); + this._setMenuVisibility(true); } else { - this._menuElement.style.display = "none"; + this._setMenuVisibility(false); + } + } + + private _renderIssueCountText( + countElement: HTMLSpanElement, + count: number, + ): void { + countElement.textContent = ""; + new DOMBuilder(countElement) + .openTag("strong") + .text(`${count}`) + .closeTag() + .text(` issue${count > 1 ? "s" : ""}`); + } + + private _setMenuVisibility(visible: boolean): void { + if (this._menuElement) { + this._menuElement.style.display = visible ? "block" : "none"; } } @@ -571,10 +761,20 @@ export class IssuesUI { return; } + const { allHidden, allVisible } = this._getIssueVisibilityState(); + + hideAllButton.style.display = allHidden ? "none" : ""; + showAllButton.style.display = allVisible ? "none" : ""; + } + + private _getIssueVisibilityState(): { + allHidden: boolean; + allVisible: boolean; + } { let allHidden = true; let allVisible = true; - for (let issue of this._issues) { + for (const issue of this._issues) { if (issue.isHidden) { allVisible = false; } else { @@ -586,8 +786,12 @@ export class IssuesUI { } } - hideAllButton.style.display = allHidden ? "none" : "block"; - showAllButton.style.display = allVisible ? "none" : "block"; + return { allHidden, allVisible }; + } + + private _syncMenuState(): void { + this._setIssuesCount(this._issues.size); + this._setShowHideButtonsVisibility(); } addIssue(issue: IssueUI) { @@ -609,15 +813,172 @@ export class IssuesUI { issue.toggle(false, true); } - const issueUIWraper = IssueUI.getElement(issue); - issueUIWraper && this._issuesContainer.appendChild(issueUIWraper); + IssueUI.setAppended(issue); IssueUI.setOnToggle(issue, () => { this._setShowHideButtonsVisibility(); }); - this._setIssuesCount(this._issues.size); - this._setShowHideButtonsVisibility(); + this._syncMenuState(); + } + + getIssueContainer( + type: ValidationRuleType, + groupName?: string, + helpLink?: string, + ): HTMLElement { + if (!this._issuesContainer) { + throw new Error("IssuesUI is not initialized"); + } + + if (!groupName) { + return this._issuesContainer; + } + + let groupContainer = this._issueGroupContainers[groupName]; + + if (!groupContainer) { + groupContainer = this._createIssueGroup(groupName, type, helpLink); + } + + return groupContainer; + } + + syncGroupIssueCounts(): void { + this._updateGroupIssueCounts(); + } + + private _createIssueGroup( + groupName: string, + type: ValidationRuleType, + helpLink?: string, + ): HTMLElementWithAbleDOMUIFlag { + if (!this._issuesContainer) { + throw new Error("IssuesUI is not initialized"); + } + + let groupContainer: HTMLElementWithAbleDOMUIFlag | undefined; + + new DOMBuilder(this._issuesContainer) + .openTag("div", { class: "abledom-issue-group" }) + .openTag("div", { + class: `abledom-issue-group-title abledom-issue${getIssueTypeClass(type)}`, + }) + .openTag( + "button", + { + class: "button toggle collapsed", + title: "Toggle group", + }, + (toggleButton) => { + this._bindGroupToggleButton(toggleButton, () => groupContainer); + }, + ) + .element(svgChevronRight) + .element(svgChevronDown) + .openTag("span", { class: "abledom-issue-group-count" }, (countEl) => { + this._issueGroupCountElements[groupName] = countEl; + }) + .closeTag() + .closeTag() + .text(groupName) + .openTag( + "a", + { + class: "button help", + href: helpLink || "/", + title: "Open help", + target: "_blank", + }, + (help) => { + this._hideHelpLinkIfMissing(help, helpLink); + }, + ) + .element(svgHelp) + .closeTag() + .openTag( + "button", + { + class: "button close", + title: "Hide", + }, + (closeButton) => { + this._bindGroupCloseButton(closeButton, () => groupContainer); + }, + ) + .element(svgClose) + .closeTag() + .closeTag() + .openTag("div", { class: "abledom-issue-group-issues" }, (element) => { + this._issueGroupContainers[groupName] = groupContainer = element; + groupContainer.style.display = "none"; + }) + .closeTag() + .closeTag(); + + if (!groupContainer) { + throw new Error("Issue group container was not created"); + } + + return groupContainer; + } + + private _bindGroupToggleButton( + button: HTMLElement, + getGroupContainer: () => HTMLElementWithAbleDOMUIFlag | undefined, + ): void { + let isCollapsed = true; + + button.onclick = () => { + isCollapsed = !isCollapsed; + + button.classList.toggle("collapsed", isCollapsed); + const groupContainer = getGroupContainer(); + + if (groupContainer) { + groupContainer.style.display = isCollapsed ? "none" : ""; + } + }; + } + + private _bindGroupCloseButton( + button: HTMLElement, + getGroupContainer: () => HTMLElementWithAbleDOMUIFlag | undefined, + ): void { + button.onclick = () => { + const groupContainer = getGroupContainer(); + + if (groupContainer) { + Array.prototype.forEach.call( + groupContainer.children, + (el: HTMLElementWithIssueUI) => { + el.__ableDOMIssueUI?.toggle(false); + }, + ); + this._setShowHideButtonsVisibility(); + } + + this.highlight(null); + }; + } + + private _hideHelpLinkIfMissing(help: HTMLElement, helpLink?: string): void { + if (!helpLink) { + help.style.display = "none"; + } + } + + private _updateGroupIssueCounts() { + for (const [groupName, container] of Object.entries( + this._issueGroupContainers, + )) { + const countElement = this._issueGroupCountElements[groupName]; + + if (container && countElement) { + const count = container.children.length; + countElement.textContent = `${count}`; + } + } } removeIssue(issue: IssueUI) { @@ -627,22 +988,21 @@ export class IssuesUI { this._issues.delete(issue); - this._setIssuesCount(this._issues.size); - this._setShowHideButtonsVisibility(); + this._syncMenuState(); + this._updateGroupIssueCounts(); this.highlight(null); } - hideAll() { - this._issues.forEach((issue) => { - issue.toggle(false); - }); - this._setShowHideButtonsVisibility(); - } + toggleAll(show?: boolean) { + if (show === undefined) { + const { allVisible } = this._getIssueVisibilityState(); + show = !allVisible; + } - showAll() { this._issues.forEach((issue) => { - issue.toggle(true); + issue.toggle(show); }); + this._setShowHideButtonsVisibility(); } @@ -747,24 +1107,13 @@ export class ElementHighlighter { }; } + this._unobserve(); this._intersectionObserver = new IntersectionObserver(([entry]) => { - if (entry) { - const rect = entry.boundingClientRect; - - const body = win.document.body; - const style = container.style; - - if (container.parentElement !== body) { - body.appendChild(container); - } - - style.width = `${rect.width}px`; - style.height = `${rect.height}px`; - style.top = `${rect.top}px`; - style.left = `${rect.left}px`; - - container.style.display = "block"; + if (!entry) { + return; } + + this._renderHighlightRect(entry.boundingClientRect, win.document.body); }); this._intersectionObserver.observe(element); @@ -781,6 +1130,25 @@ export class ElementHighlighter { delete this._window; } + private _renderHighlightRect(rect: DOMRectReadOnly, body: HTMLElement): void { + const container = this._container; + + if (!container) { + return; + } + + if (container.parentElement !== body) { + body.appendChild(container); + } + + const style = container.style; + style.width = `${rect.width}px`; + style.height = `${rect.height}px`; + style.top = `${rect.top}px`; + style.left = `${rect.left}px`; + style.display = "block"; + } + private _hide() { this._container && (this._container.style.display = "none"); } diff --git a/tests/notificationsUI/notificationsUI.html b/tests/notificationsUI/notificationsUI.html new file mode 100644 index 0000000..3629bf0 --- /dev/null +++ b/tests/notificationsUI/notificationsUI.html @@ -0,0 +1,30 @@ + + + + + + Notifications UI + + + +

Notifications UI Test

+ + + + + + + + + + + + + + diff --git a/tests/notificationsUI/notificationsUI.test.ts b/tests/notificationsUI/notificationsUI.test.ts new file mode 100644 index 0000000..8fce0f4 --- /dev/null +++ b/tests/notificationsUI/notificationsUI.test.ts @@ -0,0 +1,795 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ +import { test, expect, type Page, type Locator } from "@playwright/test"; +import { loadTestPage, awaitIdle } from "../utils"; + +const report = "#abledom-report"; +// Individual issues inside groups (excludes group titles which also have .abledom-issue). +const groupedIssueSelector = `${report} .abledom-issue-group-issues .abledom-issue`; +// Standalone issues not inside any group. +const standaloneIssueSelector = `${report} > .abledom-issues-container > div:not(.abledom-issue-group) .abledom-issue`; +// All actual issues (both standalone and grouped, excluding group titles). +const allIssueSelector = `${groupedIssueSelector}, ${standaloneIssueSelector}`; +const allIssueWrapperSelector = `${report} .abledom-issue-container-wrapper`; +const groupSelector = `${report} .abledom-issue-group`; +const groupTitleSelector = `${report} .abledom-issue-group-title`; +const menuContainerSelector = `${report} .abledom-menu-container`; +const menuSelector = `${report} .abledom-menu`; +const issueCountSelector = `${report} .issues-count`; + +async function hoverMenu(page: Page): Promise { + await page.locator(menuContainerSelector).hover(); +} + +function getShowAllButton(page: Page): Locator { + return page.locator(`${menuSelector} button[title="Show all issues"]`); +} + +function getHideAllButton(page: Page): Locator { + return page.locator(`${menuSelector} button[title="Hide all issues"]`); +} + +function getMuteButton(page: Page): Locator { + return page.locator( + `${menuSelector} button[title="Mute newly appearing issues"], ${menuSelector} button[title="Unmute newly appearing issues"]`, + ); +} + +function getIssueCountElement(page: Page): Locator { + return page.locator(issueCountSelector); +} + +async function sendNotification(page: Page, message: string): Promise { + await page.evaluate((msg) => { + ( + window as Window & { + __notifyRule?: { customNotify: (m: string) => void }; + } + ).__notifyRule?.customNotify(msg); + }, message); +} + +async function clearButtonLabels(page: Page): Promise { + await page.evaluate(() => { + for (const id of ["btn-1", "btn-2", "btn-3"]) { + const el = document.getElementById(id); + if (el) { + el.textContent = ""; + } + } + }); +} + +async function restoreButtonLabels(page: Page): Promise { + await page.evaluate(() => { + const labels: Record = { + "btn-1": "Button 1", + "btn-2": "Button 2", + "btn-3": "Button 3", + }; + for (const [id, text] of Object.entries(labels)) { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + } + } + }); +} + +async function getVisibleIssueCount(page: Page): Promise { + return page + .locator(allIssueWrapperSelector) + .evaluateAll( + (elements) => + elements.filter( + (element) => (element as HTMLElement).style.display !== "none", + ).length, + ); +} + +async function getTotalIssueCount(page: Page): Promise { + return page.locator(allIssueWrapperSelector).count(); +} + +async function getReportedIssueCount(page: Page): Promise { + const countText = (await getIssueCountElement(page).textContent()) || ""; + const match = countText.match(/\d+/); + return match ? Number(match[0]) : 0; +} + +async function getVisibleGroupCount(page: Page): Promise { + return page.locator(groupSelector).filter({ visible: true }).count(); +} + +function getGroupByName(page: Page, name: string): Locator { + return page.locator(groupSelector).filter({ hasText: name }).first(); +} + +async function ensureGroupExpanded(group: Locator): Promise { + const issuesContainer = group.locator(".abledom-issue-group-issues"); + + if (!(await issuesContainer.isVisible())) { + await group + .locator(`.abledom-issue-group-title button[title="Toggle group"]`) + .click(); + await expect(issuesContainer).toBeVisible(); + } +} + +async function setup(page: Page): Promise { + await loadTestPage(page, "tests/notificationsUI/notificationsUI.html"); + await awaitIdle(page); +} + +test.describe("Notifications UI", () => { + test.describe("Standalone issues (CustomNotifyRule)", () => { + test("standalone notification appears in the UI", async ({ page }) => { + await setup(page); + + const initialCount = await getVisibleIssueCount(page); + + await sendNotification(page, "Test notification 1"); + await awaitIdle(page); + + expect(await getVisibleIssueCount(page)).toBe(initialCount + 1); + }); + + test("multiple standalone notifications appear independently", async ({ + page, + }) => { + await setup(page); + const initialCount = await getVisibleIssueCount(page); + + await sendNotification(page, "Notification A"); + await awaitIdle(page); + await sendNotification(page, "Notification B"); + await awaitIdle(page); + + expect(await getVisibleIssueCount(page)).toBe(initialCount + 2); + }); + + test("closing a standalone notification removes it (non-anchored dispose)", async ({ + page, + }) => { + await setup(page); + const initialCount = await getVisibleIssueCount(page); + + await sendNotification(page, "Will be closed"); + await awaitIdle(page); + expect(await getVisibleIssueCount(page)).toBe(initialCount + 1); + + // Click the close button on the last standalone issue. + const standaloneIssues = page.locator(standaloneIssueSelector); + await standaloneIssues.last().locator('button[title="Hide"]').click(); + + // Non-anchored issues get disposed on hide, so count drops. + expect(await getVisibleIssueCount(page)).toBe(initialCount); + }); + + test("standalone notification does not appear in a group", async ({ + page, + }) => { + await setup(page); + const initialGroupCount = await getVisibleGroupCount(page); + + await sendNotification(page, "Standalone check"); + await awaitIdle(page); + + // Notification should not create a new group. + expect(await getVisibleGroupCount(page)).toBe(initialGroupCount); + }); + }); + + test.describe("Grouped issues (label rule)", () => { + test("label rule issues appear in a single group", async ({ page }) => { + await setup(page); + const initialGroupCount = await getVisibleGroupCount(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + // 3 label issues should form one group. + const labelGroups = page.locator( + `${groupTitleSelector}:has-text("Missing text label")`, + ); + await expect(labelGroups).toHaveCount(1); + expect(await getVisibleGroupCount(page)).toBe(initialGroupCount + 1); + }); + + test("fixing all label issues removes issues from the group", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const issuesBefore = await getVisibleIssueCount(page); + + await restoreButtonLabels(page); + await awaitIdle(page); + + // All 3 label issues should be gone. + expect(await getVisibleIssueCount(page)).toBe(issuesBefore - 3); + }); + + test("group close button hides all issues within the group", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + + await expect(labelGroup).toBeVisible(); + + // Click the close button on the group title. + await labelGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + + // The group should be hidden. + await expect(labelGroup).toBeHidden(); + }); + + test("individual issue close button hides only that issue", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + await ensureGroupExpanded(labelGroup); + + const issueWrappers = labelGroup.locator( + `.abledom-issue-group-issues > div`, + ); + const wrapperCountBefore = await issueWrappers.count(); + expect(wrapperCountBefore).toBe(3); + + // Close just the first issue wrapper. + await issueWrappers.first().locator('button[title="Hide"]').click(); + + // Group still visible (2 remaining). + await expect(labelGroup).toBeVisible(); + + const visibleWrappers = issueWrappers.filter({ visible: true }); + expect(await visibleWrappers.count()).toBe(wrapperCountBefore - 1); + }); + + test("closing all individual issues in a group hides the group", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + await ensureGroupExpanded(labelGroup); + + // Close each issue individually. + const wrappers = labelGroup.locator(`.abledom-issue-group-issues > div`); + const count = await wrappers.count(); + + for (let i = 0; i < count; i++) { + await wrappers.nth(i).locator('button[title="Hide"]').click(); + } + + // Group should now be hidden since all children are hidden. + await expect(labelGroup).toBeHidden(); + }); + }); + + test.describe("Grouped issues (contrast rule)", () => { + test("contrast issues appear in groups", async ({ page }) => { + await setup(page); + + // The two bad-contrast buttons produce issues. + expect(await getVisibleIssueCount(page)).toBeGreaterThanOrEqual(2); + expect(await getVisibleGroupCount(page)).toBeGreaterThanOrEqual(1); + }); + + test("adding a new contrast issue dynamically updates the UI", async ({ + page, + }) => { + await setup(page); + const initialIssues = await getVisibleIssueCount(page); + + // Make the good-contrast element bad. + await page.evaluate(() => { + const el = document.getElementById("contrast-good"); + if (el) { + el.style.color = "#fff"; + el.style.backgroundColor = "#fff"; + } + }); + await awaitIdle(page); + + expect(await getVisibleIssueCount(page)).toBeGreaterThan(initialIssues); + }); + }); + + test.describe("Group toggle button (chevron)", () => { + test("clicking toggle collapses the group issues", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + + const issuesContainer = labelGroup.locator(".abledom-issue-group-issues"); + await ensureGroupExpanded(labelGroup); + + // Click the toggle button (chevron). + await labelGroup + .locator(`.abledom-issue-group-title button[title="Toggle group"]`) + .click(); + + // Issues container should be hidden. + await expect(issuesContainer).toBeHidden(); + }); + + test("clicking toggle again expands the group issues", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + + const issuesContainer = labelGroup.locator(".abledom-issue-group-issues"); + const toggleBtn = labelGroup.locator( + `.abledom-issue-group-title button[title="Toggle group"]`, + ); + + // Collapse then expand. + await ensureGroupExpanded(labelGroup); + await toggleBtn.click(); + await expect(issuesContainer).toBeHidden(); + + await toggleBtn.click(); + await expect(issuesContainer).toBeVisible(); + }); + + test("group shows issue count", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + const labelGroup = getGroupByName(page, "Missing text label"); + + const countEl = labelGroup.locator(".abledom-issue-group-count"); + await expect(countEl).toHaveText("3"); + }); + + test("collapsing group does not affect show/hide all buttons (issues remain shown)", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + // All visible, so show-all should be hidden. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + + // Collapse the label group (toggle does not call issue.toggle). + const labelGroup = getGroupByName(page, "Missing text label"); + await ensureGroupExpanded(labelGroup); + await labelGroup + .locator(`.abledom-issue-group-title button[title="Toggle group"]`) + .click(); + + // Show-all should still be hidden since issues aren't actually hidden. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + }); + }); + + test.describe("Show all / Hide all buttons", () => { + test("hide all button hides every issue", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + expect(await getVisibleIssueCount(page)).toBeGreaterThan(0); + + await hoverMenu(page); + await getHideAllButton(page).click(); + + expect(await getVisibleIssueCount(page)).toBe(0); + }); + + test("show all button shows every issue after hide all", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + const totalIssues = await getVisibleIssueCount(page); + + await hoverMenu(page); + await getHideAllButton(page).click(); + expect(await getVisibleIssueCount(page)).toBe(0); + + await hoverMenu(page); + await getShowAllButton(page).click(); + + expect(await getVisibleIssueCount(page)).toBe(totalIssues); + }); + + test("hide all button is hidden when all issues are already hidden", async ({ + page, + }) => { + await setup(page); + + await hoverMenu(page); + await getHideAllButton(page).click(); + + await hoverMenu(page); + await expect(getHideAllButton(page)).toBeHidden(); + }); + + test("show all button is hidden when all issues are already visible", async ({ + page, + }) => { + await setup(page); + + // All issues visible initially. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + }); + + test("show all button appears after hiding one issue", async ({ page }) => { + await setup(page); + + // Initially show-all is hidden. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + + // Hide one issue via group close button. + const firstGroup = page.locator(groupSelector).first(); + await firstGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + + // Show all button should now appear. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeVisible(); + }); + + test("hide all button disappears after all issues are hidden individually", async ({ + page, + }) => { + await setup(page); + + // Hide all groups via their close buttons. + const groups = page.locator(groupSelector); + const groupCount = await groups.count(); + + for (let i = 0; i < groupCount; i++) { + const group = groups.nth(i); + if (await group.isVisible()) { + await group + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + } + } + + await hoverMenu(page); + await expect(getHideAllButton(page)).toBeHidden(); + }); + + test("show all restores group visibility after group close", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + // Close the label group. + const labelGroup = getGroupByName(page, "Missing text label"); + await labelGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + await expect(labelGroup).toBeHidden(); + + // Show all should restore everything. + await hoverMenu(page); + await getShowAllButton(page).click(); + await expect(labelGroup).toBeVisible(); + }); + }); + + test.describe("Issue count display", () => { + test("issue count shows correct number", async ({ page }) => { + await setup(page); + + const totalCount = await getTotalIssueCount(page); + const countText = await getIssueCountElement(page).textContent(); + + expect(await getReportedIssueCount(page)).toBe(totalCount); + expect(countText).toContain("issue"); + }); + + test("issue count updates when new issues appear", async ({ page }) => { + await setup(page); + const initialCount = await getTotalIssueCount(page); + + await clearButtonLabels(page); + await awaitIdle(page); + const newCount = await getTotalIssueCount(page); + + expect(newCount).toBe(initialCount + 3); + expect(await getReportedIssueCount(page)).toBe(newCount); + }); + + test("clicking issue count toggles all issues", async ({ page }) => { + await setup(page); + + const totalIssues = await getVisibleIssueCount(page); + expect(totalIssues).toBeGreaterThan(0); + + // All visible → toggleAll() hides all. + await getIssueCountElement(page).click(); + expect(await getVisibleIssueCount(page)).toBe(0); + + // All hidden → toggleAll() shows all. + await getIssueCountElement(page).click(); + expect(await getVisibleIssueCount(page)).toBe(totalIssues); + }); + }); + + test.describe("Mute button", () => { + test("muting prevents new issues from showing", async ({ page }) => { + await setup(page); + + const initialVisible = await getVisibleIssueCount(page); + const initialTotal = await getTotalIssueCount(page); + + // Click mute. + await hoverMenu(page); + await getMuteButton(page).click(); + + // Add new issues while muted. + await clearButtonLabels(page); + await awaitIdle(page); + + // Visible list should stay the same; new label issues are hidden. + expect(await getVisibleIssueCount(page)).toBe(initialVisible); + + // Total count should include muted issues as well. + const totalAfterMute = await getTotalIssueCount(page); + expect(totalAfterMute).toBe(initialTotal + 3); + expect(await getReportedIssueCount(page)).toBe(totalAfterMute); + + // The group title should not be visible either. + const labelGroup = getGroupByName(page, "Missing text label"); + await expect(labelGroup).toBeHidden(); + }); + + test("unmuting and showing all reveals muted issues", async ({ page }) => { + await setup(page); + + // Mute. + await hoverMenu(page); + await getMuteButton(page).click(); + + // Create new issues. + await clearButtonLabels(page); + await awaitIdle(page); + + // Unmute. + await hoverMenu(page); + await getMuteButton(page).click(); + + // Show all to reveal previously muted issues. + await hoverMenu(page); + await getShowAllButton(page).click(); + + // All issues should now be visible. + const total = await page.locator(allIssueSelector).count(); + expect(await getVisibleIssueCount(page)).toBe(total); + }); + + test("mute button toggles title text", async ({ page }) => { + await setup(page); + + await hoverMenu(page); + const muteBtn = getMuteButton(page); + + await expect(muteBtn).toHaveAttribute( + "title", + "Mute newly appearing issues", + ); + + await muteBtn.click(); + + await expect(muteBtn).toHaveAttribute( + "title", + "Unmute newly appearing issues", + ); + + await muteBtn.click(); + + await expect(muteBtn).toHaveAttribute( + "title", + "Mute newly appearing issues", + ); + }); + }); + + test.describe("Mixed standalone and grouped issues", () => { + test("hiding a group does not affect standalone issues", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await sendNotification(page, "Standalone mixed test"); + await awaitIdle(page); + + const standaloneIssues = page.locator(standaloneIssueSelector); + const standaloneCountBefore = await standaloneIssues.count(); + expect(standaloneCountBefore).toBeGreaterThan(0); + + // Close the label group. + const labelGroup = getGroupByName(page, "Missing text label"); + await labelGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + + // Standalone issues should still be visible. + const visibleStandalone = standaloneIssues.filter({ visible: true }); + expect(await visibleStandalone.count()).toBe(standaloneCountBefore); + }); + + test("hide all hides both standalone and grouped issues", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await sendNotification(page, "Standalone to be hidden"); + await awaitIdle(page); + + expect(await getVisibleIssueCount(page)).toBeGreaterThan(0); + + await hoverMenu(page); + await getHideAllButton(page).click(); + + expect(await getVisibleIssueCount(page)).toBe(0); + }); + + test("show all restores grouped issues but not disposed standalone issues", async ({ + page, + }) => { + await setup(page); + + await clearButtonLabels(page); + await sendNotification(page, "Standalone to restore"); + await awaitIdle(page); + + const standaloneBefore = await page + .locator(standaloneIssueSelector) + .filter({ visible: true }) + .count(); + const totalVisibleBefore = await getVisibleIssueCount(page); + expect(standaloneBefore).toBeGreaterThan(0); + + await hoverMenu(page); + await getHideAllButton(page).click(); + expect(await getVisibleIssueCount(page)).toBe(0); + + await hoverMenu(page); + await getShowAllButton(page).click(); + + // Grouped (anchored) issues are restored; standalone (non-anchored) + // issues were disposed when hidden. + expect(await getVisibleIssueCount(page)).toBe( + totalVisibleBefore - standaloneBefore, + ); + }); + }); + + test.describe("Menu visibility", () => { + test("menu is visible when there are issues", async ({ page }) => { + await setup(page); + + const menu = page.locator(menuContainerSelector); + await expect(menu).toBeVisible(); + }); + }); + + test.describe("Show/hide button state consistency", () => { + test("after hiding some issues: both buttons visible", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + // Hide just one group. + const firstGroup = page.locator(groupSelector).first(); + await firstGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + + // Both buttons should be visible (some hidden, some shown). + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeVisible(); + await expect(getHideAllButton(page)).toBeVisible(); + }); + + test("after show all: hide all visible, show all hidden", async ({ + page, + }) => { + await setup(page); + + // Hide all first. + await hoverMenu(page); + await getHideAllButton(page).click(); + + // Show all. + await hoverMenu(page); + await getShowAllButton(page).click(); + + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + await expect(getHideAllButton(page)).toBeVisible(); + }); + + test("after hide all: show all visible, hide all hidden", async ({ + page, + }) => { + await setup(page); + + await hoverMenu(page); + await getHideAllButton(page).click(); + + await hoverMenu(page); + await expect(getHideAllButton(page)).toBeHidden(); + await expect(getShowAllButton(page)).toBeVisible(); + }); + + test("group close updates show/hide button state", async ({ page }) => { + await setup(page); + + await clearButtonLabels(page); + await awaitIdle(page); + + // All visible initially. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeHidden(); + + // Close the label group. + const labelGroup = page + .locator(groupSelector) + .filter({ + has: page.locator(`:has-text("Missing text label")`), + }) + .first(); + await labelGroup + .locator(`.abledom-issue-group-title button[title="Hide"]`) + .click(); + + // Now some are hidden, so show all button should appear. + await hoverMenu(page); + await expect(getShowAllButton(page)).toBeVisible(); + // And hide all should still be visible (contrast issues still showing). + await expect(getHideAllButton(page)).toBeVisible(); + }); + }); +}); diff --git a/tests/notificationsUI/notificationsUI.ts b/tests/notificationsUI/notificationsUI.ts new file mode 100644 index 0000000..b0fe9c0 --- /dev/null +++ b/tests/notificationsUI/notificationsUI.ts @@ -0,0 +1,24 @@ +/*! + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. + */ + +import { + AbleDOM, + CustomNotifyRule, + FocusableElementLabelRule, + ContrastRule, +} from "abledom"; +import { initIdleProp } from "../utils"; + +const notifyRule = new CustomNotifyRule(); + +const ableDOM = new AbleDOM(window); +initIdleProp(ableDOM); +ableDOM.addRule(notifyRule); +ableDOM.addRule(new FocusableElementLabelRule()); +ableDOM.addRule(new ContrastRule()); +ableDOM.start(); + +(window as Window & { __notifyRule?: CustomNotifyRule }).__notifyRule = + notifyRule; diff --git a/tests/utils.ts b/tests/utils.ts index e2bbd66..7ab88e8 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -10,7 +10,8 @@ import type { } from "abledom"; import { Page } from "@playwright/test"; -export const issueSelector = "#abledom-report .abledom-issue"; +export const issueSelector = + "#abledom-report .abledom-issue:not(.abledom-issue-group-title)"; interface WindowWithAbleDOMData extends Window { __ableDOMIdle?: typeof AbleDOM.prototype.idle;