-
Notifications
You must be signed in to change notification settings - Fork 1
fix(a11y): announce empty mail and search states without wrapping actions #1576
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
seonghobae
wants to merge
22
commits into
develop
Choose a base branch
from
palette/dynamic-empty-state-a11y-5903985985804935687
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 10 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
3cb31d6
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae 7e221bb
test(search): pin live-region interaction boundary
seonghobae 84a7659
fix(search): isolate live status from interactive controls
seonghobae 69c92ec
chore(a11y): restore canonical Palette guidance
seonghobae a0c544b
chore(a11y): exactly adopt protected Palette guidance
seonghobae 597790b
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae bb8209b
fix(a11y): restore reviewed live-region contract
seonghobae 13e3f18
test(a11y): cover empty email live region
seonghobae 3f41ff5
test(a11y): cover empty search live region
seonghobae dfa3f03
fix(a11y): narrow empty-state announcement scope
seonghobae 6bd0ec5
test(a11y): match inbox empty-state copy
seonghobae 84cf3c9
test(a11y): distinguish inbox and search empty states
seonghobae 65c370d
fix(a11y): distinguish inbox and search empty copy
seonghobae b243f7c
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae 4d2c0cb
fix(a11y): preserve reviewed empty-state contracts
seonghobae 98f7c66
test(mail): preserve latest result set across search races
seonghobae 05423b5
fix(mail): ignore stale search responses after scope changes
seonghobae 134813f
test(mail): make stale-response ordering contract deterministic
seonghobae a2810ed
test(a11y): exercise mail and search live regions in browser
seonghobae 270acbc
test(a11y): make search-detail browser probe deterministic
seonghobae 33f1e14
SearchLayout, EmailList, TasksLayout, DocumentRepositoryTab 등의 동적 UI에…
seonghobae 58df9fb
fix(a11y): preserve reviewed mail search contracts after concurrent c…
seonghobae File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
175 changes: 175 additions & 0 deletions
175
frontend/src/components/SearchLayout.live-region.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| /* @vitest-environment jsdom */ | ||
| import React, { act } from "react"; | ||
| import { createRoot, type Root } from "react-dom/client"; | ||
| import { afterEach, describe, expect, it, vi } from "vitest"; | ||
|
|
||
| vi.mock("next/dynamic", () => ({ | ||
| default: () => function MockDynamic() { | ||
| return <div>mock graph</div>; | ||
| }, | ||
| })); | ||
|
|
||
| vi.mock("next/link", () => ({ | ||
| default: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement> & { href: string }) => ( | ||
| <a href={href} {...props}>{children}</a> | ||
| ), | ||
| })); | ||
|
|
||
| vi.mock("lucide-react", () => ({ | ||
| AlertCircle: () => <svg aria-hidden="true" />, | ||
| CalendarDays: () => <svg aria-hidden="true" />, | ||
| CheckCircle2: () => <svg aria-hidden="true" />, | ||
| Clock: () => <svg aria-hidden="true" />, | ||
| CornerDownRight: () => <svg aria-hidden="true" />, | ||
| FileText: () => <svg aria-hidden="true" />, | ||
| Loader2: () => <svg aria-hidden="true" />, | ||
| Mail: () => <svg aria-hidden="true" />, | ||
| Network: () => <svg aria-hidden="true" />, | ||
| Search: () => <svg aria-hidden="true" />, | ||
| Sparkles: () => <svg aria-hidden="true" />, | ||
| X: () => <svg aria-hidden="true" />, | ||
| })); | ||
|
|
||
| import { SearchLayout } from "./SearchLayout"; | ||
|
|
||
| function jsonResponse(body: unknown) { | ||
| return new Response(JSON.stringify(body), { | ||
| status: 200, | ||
| headers: { "Content-Type": "application/json" }, | ||
| }); | ||
| } | ||
|
|
||
| async function flushAsyncWork() { | ||
| for (let index = 0; index < 5; index += 1) { | ||
| await act(async () => { | ||
| await Promise.resolve(); | ||
| await new Promise((resolve) => setTimeout(resolve, 0)); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| async function waitForCondition(condition: () => boolean) { | ||
| for (let index = 0; index < 20; index += 1) { | ||
| if (condition()) return; | ||
| await flushAsyncWork(); | ||
| } | ||
| throw new Error("waitForCondition timed out after 20 attempts"); | ||
| } | ||
|
|
||
| function searchResult() { | ||
| return { | ||
| id: 101, | ||
| source_message_id: "<launch-source@example.com>", | ||
| subject: "런칭 캠페인 결과", | ||
| sender: "pm@example.com", | ||
| date: "2026-05-20T09:00:00Z", | ||
| snippet: "검색 결과에서 관계 캡처 액션을 실행할 수 있습니다.", | ||
| thread_id: "thread-launch", | ||
| reply_count: 2, | ||
| score: 0.93, | ||
| }; | ||
| } | ||
|
|
||
| describe("SearchLayout live-region semantics", () => { | ||
| let root: Root | null = null; | ||
| let container: HTMLDivElement | null = null; | ||
|
|
||
| afterEach(() => { | ||
| if (root) { | ||
| act(() => root?.unmount()); | ||
| } | ||
| root = null; | ||
| container?.remove(); | ||
| container = null; | ||
| vi.unstubAllGlobals(); | ||
| }); | ||
|
|
||
| it("announces an empty search result set as a polite status", async () => { | ||
| vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { | ||
| const url = String(input); | ||
| if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [] })); | ||
| if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); | ||
| throw new Error(`Unexpected fetch: ${url}`); | ||
| })); | ||
|
|
||
| container = document.createElement("div"); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
|
|
||
| await act(async () => { | ||
| root?.render(<SearchLayout />); | ||
| }); | ||
| await waitForCondition(() => container?.textContent?.includes("맥락 검색 결과가 없습니다.") ?? false); | ||
|
|
||
| const emptyResultStatus = Array.from(container.querySelectorAll<HTMLElement>("[role='status']")).find( | ||
| (node) => node.textContent?.includes("맥락 검색 결과가 없습니다."), | ||
| ); | ||
| expect(emptyResultStatus).not.toBeUndefined(); | ||
| expect(emptyResultStatus?.getAttribute("aria-live")).toBe("polite"); | ||
| }); | ||
|
|
||
| it("announces the empty sender relationship message without wrapping its action button in a status region", async () => { | ||
| vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { | ||
| const url = String(input); | ||
| if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] })); | ||
| if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([])); | ||
| if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); | ||
| throw new Error(`Unexpected fetch: ${url}`); | ||
| })); | ||
|
|
||
| container = document.createElement("div"); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
|
|
||
| await act(async () => { | ||
| root?.render(<SearchLayout />); | ||
| }); | ||
| await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false); | ||
|
|
||
| const emptyRelationshipStatus = Array.from(container.querySelectorAll<HTMLElement>("[role='status']")).find( | ||
| (node) => node.textContent?.includes("이 맥락 검색 결과에 연결된 발신자 관계가 아직 없습니다."), | ||
| ); | ||
| expect(emptyRelationshipStatus).not.toBeUndefined(); | ||
| expect(emptyRelationshipStatus?.querySelector("button, a, input, select, textarea")).toBeNull(); | ||
| }); | ||
|
|
||
| it("announces relationship capture failure as an alert", async () => { | ||
| vi.stubGlobal("fetch", vi.fn((input: RequestInfo | URL) => { | ||
| const url = String(input); | ||
| if (url.endsWith("/api/search")) return Promise.resolve(jsonResponse({ results: [searchResult()] })); | ||
| if (url.includes("/api/ontology/relationships?")) return Promise.resolve(jsonResponse([])); | ||
| if (url.endsWith("/api/search/answer")) return Promise.resolve(jsonResponse({ answer: null })); | ||
| if (url.endsWith("/api/ontology/relationships/capture-source")) { | ||
| return Promise.resolve(new Response(JSON.stringify({ error_code: "capture_failed" }), { | ||
| status: 500, | ||
| headers: { "Content-Type": "application/json" }, | ||
| })); | ||
| } | ||
| throw new Error(`Unexpected fetch: ${url}`); | ||
| })); | ||
|
|
||
| container = document.createElement("div"); | ||
| document.body.appendChild(container); | ||
| root = createRoot(container); | ||
|
|
||
| await act(async () => { | ||
| root?.render(<SearchLayout />); | ||
| }); | ||
| await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처") ?? false); | ||
|
|
||
| const captureButton = Array.from(container.querySelectorAll<HTMLButtonElement>("button")).find( | ||
| (button) => button.textContent?.includes("발신자 관계 캡처"), | ||
| ); | ||
| expect(captureButton).not.toBeUndefined(); | ||
|
|
||
| await act(async () => { | ||
| captureButton?.click(); | ||
| }); | ||
| await waitForCondition(() => container?.textContent?.includes("발신자 관계 캡처에 실패했습니다.") ?? false); | ||
|
|
||
| const captureAlert = Array.from(container.querySelectorAll<HTMLElement>("[role='alert']")).find( | ||
| (node) => node.textContent?.includes("발신자 관계 캡처에 실패했습니다."), | ||
| ); | ||
| expect(captureAlert).not.toBeUndefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.