| author | |
| committer | |
| log | 393d595984f66df08504e1659997e12ad551bce9 |
| tree | 7d269df8196ecd7e9b6513585d316ad8b7efdea8 |
| parent | 75d63d64c554a425adc485f1205ef03ec50f8564 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: eshaan Sood <eshaansood@192.168.1.14>
Co-authored-by: Gabriel De Andrade <30420087+gabrielelpidio@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>9 files changed, 219 insertions(+), 24 deletions(-)
apps/mobile/src/features/connection/ConnectionFormField.test.tsx created+42| ... | @@ -0,0 +1,42 @@ | ||
| 1 | import type { ReactNode } from "react"; | ||
| 2 | import { renderToStaticMarkup } from "react-dom/server"; | ||
| 3 | import { describe, expect, it, vi } from "vite-plus/test"; | ||
| 4 | |||
| 5 | vi.mock("react-native", () => ({ | ||
| 6 | View: ({ children }: { children: ReactNode }) => <div>{children}</div>, | ||
| 7 | })); | ||
| 8 | vi.mock("../../components/AppText", () => ({ | ||
| 9 | AppText: ({ | ||
| 10 | accessibilityElementsHidden, | ||
| 11 | importantForAccessibility, | ||
| 12 | children, | ||
| 13 | }: { | ||
| 14 | accessibilityElementsHidden?: boolean; | ||
| 15 | importantForAccessibility?: string; | ||
| 16 | children: ReactNode; | ||
| 17 | }) => ( | ||
| 18 | <span | ||
| 19 | aria-hidden={ | ||
| 20 | accessibilityElementsHidden || importantForAccessibility === "no-hide-descendants" | ||
| 21 | } | ||
| 22 | > | ||
| 23 | {children} | ||
| 24 | </span> | ||
| 25 | ), | ||
| 26 | AppTextInput: ({ accessibilityLabel }: { accessibilityLabel?: string }) => ( | ||
| 27 | <input aria-label={accessibilityLabel} /> | ||
| 28 | ), | ||
| 29 | })); | ||
| 30 | |||
| 31 | import { ConnectionFormField } from "./ConnectionFormField"; | ||
| 32 | |||
| 33 | describe("ConnectionFormField accessibility", () => { | ||
| 34 | it.each(["Host", "Pairing code"])("exposes %s once as the input name", (label) => { | ||
| 35 | const markup = renderToStaticMarkup( | ||
| 36 | <ConnectionFormField label={label} placeholder="Not an accessible name" />, | ||
| 37 | ); | ||
| 38 | |||
| 39 | expect(markup).toContain(`<span aria-hidden="true">${label}</span>`); | ||
| 40 | expect(markup).toContain(`<input aria-label="${label}"/>`); | ||
| 41 | }); | ||
| 42 | }); | ||
apps/mobile/src/features/connection/ConnectionFormField.tsx+7-3| ... | @@ -3,7 +3,7 @@ import { View } from "react-native"; | ... | @@ -3,7 +3,7 @@ import { View } from "react-native"; |
| 3 | import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; | 3 | import { AppText, AppTextInput, type AppTextInputProps } from "../../components/AppText"; |
| 4 | import { cn } from "../../lib/cn"; | 4 | import { cn } from "../../lib/cn"; |
| 5 | 5 | ||
| 6 | type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & { | 6 | type ConnectionFormFieldProps = Omit<AppTextInputProps, "accessibilityLabel" | "className"> & { |
| 7 | readonly label: string; | 7 | readonly label: string; |
| 8 | readonly className?: string; | 8 | readonly className?: string; |
| 9 | }; | 9 | }; |
| ... | @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & { | ... | @@ -12,12 +12,16 @@ type ConnectionFormFieldProps = Omit<AppTextInputProps, "className"> & { |
| 12 | export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { | 12 | export function ConnectionFormField({ label, className, ...inputProps }: ConnectionFormFieldProps) { |
| 13 | return ( | 13 | return ( |
| 14 | <View collapsable={false} className={cn("gap-1.5", className)}> | 14 | <View collapsable={false} className={cn("gap-1.5", className)}> |
| 15 | <AppText className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted"> | 15 | <AppText |
| 16 | accessibilityElementsHidden | ||
| 17 | importantForAccessibility="no-hide-descendants" | ||
| 18 | className="text-2xs font-t3-bold tracking-[0.8px] uppercase text-foreground-muted" | ||
| 19 | > | ||
| 16 | {label} | 20 | {label} |
| 17 | </AppText> | 21 | </AppText> |
| 18 | <AppTextInput | 22 | <AppTextInput |
| 19 | accessibilityLabel={label} | ||
| 20 | {...inputProps} | 23 | {...inputProps} |
| 24 | accessibilityLabel={label} | ||
| 21 | className="rounded-[14px] px-4 py-3.5" | 25 | className="rounded-[14px] px-4 py-3.5" |
| 22 | /> | 26 | /> |
| 23 | </View> | 27 | </View> |
apps/web/src/components/AppSidebarLayout.tsx+2| ... | @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { | ... | @@ -304,6 +304,8 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { |
| 304 | side="left" | 304 | side="left" |
| 305 | collapsible="offcanvas" | 305 | collapsible="offcanvas" |
| 306 | data-app-sidebar="" | 306 | data-app-sidebar="" |
| 307 | role="navigation" | ||
| 308 | aria-label={isOnSettings ? "Settings" : "Threads"} | ||
| 307 | resizable={{ | 309 | resizable={{ |
| 308 | maxWidth: sidebarMaximumWidth, | 310 | maxWidth: sidebarMaximumWidth, |
| 309 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, | 311 | minWidth: THREAD_SIDEBAR_MIN_WIDTH, |
apps/web/src/components/Sidebar.logic.test.ts+30| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | isTrailingDoubleClick, | 23 | isTrailingDoubleClick, |
| 24 | orderItemsByPreferredIds, | 24 | orderItemsByPreferredIds, |
| 25 | resolveProjectStatusIndicator, | 25 | resolveProjectStatusIndicator, |
| 26 | resolveSidebarRowAccessibility, | ||
| 26 | resolveSidebarThreadStatus, | 27 | resolveSidebarThreadStatus, |
| 27 | resolveThreadStatusPill, | 28 | resolveThreadStatusPill, |
| 28 | resolveWorkingStartedAt, | 29 | resolveWorkingStartedAt, |
| ... | @@ -69,6 +70,35 @@ import { | ... | @@ -69,6 +70,35 @@ import { |
| 69 | 70 | ||
| 70 | const localEnvironmentId = EnvironmentId.make("environment-local"); | 71 | const localEnvironmentId = EnvironmentId.make("environment-local"); |
| 71 | 72 | ||
| 73 | describe("resolveSidebarRowAccessibility", () => { | ||
| 74 | it.each([ | ||
| 75 | { | ||
| 76 | title: "Can you audit the UI?", | ||
| 77 | statusLabel: "Working", | ||
| 78 | projectDisplayName: "T3 Code", | ||
| 79 | isActive: true, | ||
| 80 | expected: { label: "Can you audit the UI?, Working, T3 Code", current: "page" }, | ||
| 81 | }, | ||
| 82 | { | ||
| 83 | title: "The audit is done", | ||
| 84 | statusLabel: null, | ||
| 85 | projectDisplayName: "T3 Code", | ||
| 86 | isActive: false, | ||
| 87 | expected: { label: "The audit is done, T3 Code", current: undefined }, | ||
| 88 | }, | ||
| 89 | { | ||
| 90 | title: "Untitled task", | ||
| 91 | statusLabel: null, | ||
| 92 | projectDisplayName: null, | ||
| 93 | isActive: false, | ||
| 94 | expected: { label: "Untitled task", current: undefined }, | ||
| 95 | }, | ||
| 96 | ])("leads with the title without folding row actions into its name: %j", (input) => { | ||
| 97 | const { expected, ...state } = input; | ||
| 98 | expect(resolveSidebarRowAccessibility(state)).toEqual(expected); | ||
| 99 | }); | ||
| 100 | }); | ||
| 101 | |||
| 72 | describe("animateSidebarLayoutChanges", () => { | 102 | describe("animateSidebarLayoutChanges", () => { |
| 73 | const baseArgs: Parameters<AnimateLayoutChanges>[0] = { | 103 | const baseArgs: Parameters<AnimateLayoutChanges>[0] = { |
| 74 | active: null, | 104 | active: null, |
apps/web/src/components/Sidebar.logic.ts+15| ... | @@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: { | ... | @@ -42,6 +42,21 @@ export function shouldNavigateAfterThreadPark(input: { |
| 42 | 42 | ||
| 43 | const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; | 43 | const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; |
| 44 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; | 44 | export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; |
| 45 | |||
| 46 | export function resolveSidebarRowAccessibility(input: { | ||
| 47 | readonly title: string; | ||
| 48 | readonly statusLabel: string | null; | ||
| 49 | readonly projectDisplayName: string | null; | ||
| 50 | readonly isActive: boolean; | ||
| 51 | }): { readonly label: string; readonly current: "page" | undefined } { | ||
| 52 | return { | ||
| 53 | // The title is the row's identity and must lead when users scan tasks. | ||
| 54 | // Only static context belongs here; nested action labels remain separate controls. | ||
| 55 | label: [input.title, input.statusLabel, input.projectDisplayName].filter(Boolean).join(", "), | ||
| 56 | current: input.isActive ? "page" : undefined, | ||
| 57 | }; | ||
| 58 | } | ||
| 59 | |||
| 45 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a | 60 | // Visible sidebar rows are prewarmed into the thread-detail cache so opening a |
| 46 | // nearby thread usually reuses an already-hot subscription. Each prewarmed | 61 | // nearby thread usually reuses an already-hot subscription. Each prewarmed |
| 47 | // thread holds a live, fully hydrated detail subscription (all messages and | 62 | // thread holds a live, fully hydrated detail subscription (all messages and |
apps/web/src/components/Sidebar.tsx+42-9| ... | @@ -173,6 +173,7 @@ import { | ... | @@ -173,6 +173,7 @@ import { |
| 173 | resolveAdjacentThreadId, | 173 | resolveAdjacentThreadId, |
| 174 | resolveSidebarDropTarget, | 174 | resolveSidebarDropTarget, |
| 175 | resolveSidebarDropVerb, | 175 | resolveSidebarDropVerb, |
| 176 | resolveSidebarRowAccessibility, | ||
| 176 | type SidebarDropVerb, | 177 | type SidebarDropVerb, |
| 177 | resolveSidebarThreadStatus, | 178 | resolveSidebarThreadStatus, |
| 178 | searchSidebarThreads, | 179 | searchSidebarThreads, |
| ... | @@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { | ... | @@ -728,6 +729,12 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 728 | promptPreview.length > 0 | 729 | promptPreview.length > 0 |
| 729 | ? promptPreview | 730 | ? promptPreview |
| 730 | : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; | 731 | : `${attachmentCount} attachment${attachmentCount === 1 ? "" : "s"}`; |
| 732 | const accessibility = resolveSidebarRowAccessibility({ | ||
| 733 | title: preview, | ||
| 734 | statusLabel: "Unsent draft", | ||
| 735 | projectDisplayName: props.projectDisplayName, | ||
| 736 | isActive: props.isActive, | ||
| 737 | }); | ||
| 731 | const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); | 738 | const handleActivate = useCallback(() => onNavigate(draftId), [draftId, onNavigate]); |
| 732 | const handleKeyDown = useCallback( | 739 | const handleKeyDown = useCallback( |
| 733 | (event: ReactKeyboardEvent) => { | 740 | (event: ReactKeyboardEvent) => { |
| ... | @@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { | ... | @@ -755,6 +762,8 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 755 | <div | 762 | <div |
| 756 | role="button" | 763 | role="button" |
| 757 | tabIndex={0} | 764 | tabIndex={0} |
| 765 | aria-label={accessibility.label} | ||
| 766 | aria-current={accessibility.current} | ||
| 758 | data-testid="sidebar-draft-row" | 767 | data-testid="sidebar-draft-row" |
| 759 | className={cn( | 768 | className={cn( |
| 760 | "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", | 769 | "group/sidebar-row relative w-full cursor-pointer overflow-hidden rounded-md text-left text-sidebar-foreground outline-none select-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", |
| ... | @@ -763,6 +772,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { | ... | @@ -763,6 +772,7 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 763 | onClick={handleActivate} | 772 | onClick={handleActivate} |
| 764 | onKeyDown={handleKeyDown} | 773 | onKeyDown={handleKeyDown} |
| 765 | > | 774 | > |
| 775 | <span className="sr-only">{preview}</span> | ||
| 766 | <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)"> | 776 | <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)"> |
| 767 | <div className="flex h-5 min-w-0 items-center gap-1.5"> | 777 | <div className="flex h-5 min-w-0 items-center gap-1.5"> |
| 768 | <SquarePenIcon aria-hidden className={draftPenClassName} /> | 778 | <SquarePenIcon aria-hidden className={draftPenClassName} /> |
| ... | @@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { | ... | @@ -790,7 +800,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { |
| 790 | </Tooltip> | 800 | </Tooltip> |
| 791 | </span> | 801 | </span> |
| 792 | </div> | 802 | </div> |
| 793 | <div className="mt-0.5 truncate text-sm font-medium text-foreground/90">{preview}</div> | 803 | <div aria-hidden className="mt-0.5 truncate text-sm font-medium text-foreground/90"> |
| 804 | {preview} | ||
| 805 | </div> | ||
| 794 | </div> | 806 | </div> |
| 795 | </div> | 807 | </div> |
| 796 | </li> | 808 | </li> |
| ... | @@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1447,6 +1459,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1447 | </span> | 1459 | </span> |
| 1448 | ) : null; | 1460 | ) : null; |
| 1449 | 1461 | ||
| 1462 | const accessibility = resolveSidebarRowAccessibility({ | ||
| 1463 | title: thread.title, | ||
| 1464 | statusLabel: topStatus?.label ?? null, | ||
| 1465 | projectDisplayName: props.projectDisplayName, | ||
| 1466 | isActive: props.isActive, | ||
| 1467 | }); | ||
| 1468 | |||
| 1450 | const title = isRenaming ? ( | 1469 | const title = isRenaming ? ( |
| 1451 | <input | 1470 | <input |
| 1452 | autoFocus | 1471 | autoFocus |
| ... | @@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1462,6 +1481,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1462 | /> | 1481 | /> |
| 1463 | ) : ( | 1482 | ) : ( |
| 1464 | <span | 1483 | <span |
| 1484 | aria-hidden | ||
| 1465 | className={cn( | 1485 | className={cn( |
| 1466 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", | 1486 | "min-w-0 flex-1 text-sm transition-opacity motion-reduce:transition-none", |
| 1467 | shouldRecede ? "font-normal" : "font-medium", | 1487 | shouldRecede ? "font-normal" : "font-medium", |
| ... | @@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1492,6 +1512,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1492 | {thread.title} | 1512 | {thread.title} |
| 1493 | </span> | 1513 | </span> |
| 1494 | ); | 1514 | ); |
| 1515 | const accessibleTitle = isRenaming ? null : <span className="sr-only">{thread.title}</span>; | ||
| 1495 | 1516 | ||
| 1496 | // Stacks show their layer count; multiple unrelated links show their total count. | 1517 | // Stacks show their layer count; multiple unrelated links show their total count. |
| 1497 | // Plain clicks open T3; individual PR links also support opening the host in a new tab. | 1518 | // Plain clicks open T3; individual PR links also support opening the host in a new tab. |
| ... | @@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1593,6 +1614,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1593 | ref={rowRef} | 1614 | ref={rowRef} |
| 1594 | role="button" | 1615 | role="button" |
| 1595 | tabIndex={0} | 1616 | tabIndex={0} |
| 1617 | aria-label={accessibility.label} | ||
| 1618 | aria-current={accessibility.current} | ||
| 1596 | data-testid="sidebar-row-slim" | 1619 | data-testid="sidebar-row-slim" |
| 1597 | aria-busy={isRegeneratingTitle || undefined} | 1620 | aria-busy={isRegeneratingTitle || undefined} |
| 1598 | className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} | 1621 | className={cn(rowSurfaceClassName, "flex h-9 items-center gap-2.5 px-2.5")} |
| ... | @@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1603,6 +1626,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1603 | /> | 1626 | /> |
| 1604 | } | 1627 | } |
| 1605 | > | 1628 | > |
| 1629 | {accessibleTitle} | ||
| 1606 | {/* Settled history recedes: dimmed favicon at rest, restored on | 1630 | {/* Settled history recedes: dimmed favicon at rest, restored on |
| 1607 | hover so the tail stays scannable when you're hunting. */} | 1631 | hover so the tail stays scannable when you're hunting. */} |
| 1608 | <span | 1632 | <span |
| ... | @@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1746,6 +1770,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1746 | ref={rowRef} | 1770 | ref={rowRef} |
| 1747 | role="button" | 1771 | role="button" |
| 1748 | tabIndex={0} | 1772 | tabIndex={0} |
| 1773 | aria-label={accessibility.label} | ||
| 1774 | aria-current={accessibility.current} | ||
| 1749 | data-testid="sidebar-row-card" | 1775 | data-testid="sidebar-row-card" |
| 1750 | aria-busy={isRegeneratingTitle || undefined} | 1776 | aria-busy={isRegeneratingTitle || undefined} |
| 1751 | className={rowSurfaceClassName} | 1777 | className={rowSurfaceClassName} |
| ... | @@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { | ... | @@ -1756,6 +1782,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { |
| 1756 | /> | 1782 | /> |
| 1757 | } | 1783 | } |
| 1758 | > | 1784 | > |
| 1785 | {accessibleTitle} | ||
| 1759 | <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)"> | 1786 | <div className="relative z-10 h-[4.875rem] px-(--sidebar-row-content-inset) py-(--sidebar-content-inset)"> |
| 1760 | <div className="flex h-5 min-w-0 items-center gap-1.5"> | 1787 | <div className="flex h-5 min-w-0 items-center gap-1.5"> |
| 1761 | {draftIndicator} | 1788 | {draftIndicator} |
| ... | @@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -2008,6 +2035,12 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2008 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; | 2035 | onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; |
| 2009 | }) { | 2036 | }) { |
| 2010 | const { thread } = props; | 2037 | const { thread } = props; |
| 2038 | const accessibility = resolveSidebarRowAccessibility({ | ||
| 2039 | title: thread.title, | ||
| 2040 | statusLabel: null, | ||
| 2041 | projectDisplayName: props.projectDisplayName, | ||
| 2042 | isActive: props.isRouteActive, | ||
| 2043 | }); | ||
| 2011 | const threadRef = useMemo( | 2044 | const threadRef = useMemo( |
| 2012 | () => scopeThreadRef(thread.environmentId, thread.id), | 2045 | () => scopeThreadRef(thread.environmentId, thread.id), |
| 2013 | [thread.environmentId, thread.id], | 2046 | [thread.environmentId, thread.id], |
| ... | @@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { | ... | @@ -2084,12 +2117,8 @@ const SidebarSearchResultRow = memo(function SidebarSearchResultRow(props: { |
| 2084 | // which owns all keyboard interaction for the listbox. | 2117 | // which owns all keyboard interaction for the listbox. |
| 2085 | tabIndex={-1} | 2118 | tabIndex={-1} |
| 2086 | aria-selected={props.isHighlighted} | 2119 | aria-selected={props.isHighlighted} |
| 2087 | aria-current={props.isRouteActive ? "page" : undefined} | 2120 | aria-current={accessibility.current} |
| 2088 | aria-label={ | 2121 | aria-label={accessibility.label} |
| 2089 | props.projectDisplayName | ||
| 2090 | ? `${thread.title}, ${props.projectDisplayName}` | ||
| 2091 | : thread.title | ||
| 2092 | } | ||
| 2093 | onMouseMove={props.onHighlight} | 2122 | onMouseMove={props.onHighlight} |
| 2094 | onClick={props.onSelect} | 2123 | onClick={props.onSelect} |
| 2095 | className={cn( | 2124 | className={cn( |
| ... | @@ -4573,7 +4602,7 @@ export default function Sidebar() { | ... | @@ -4573,7 +4602,7 @@ export default function Sidebar() { |
| 4573 | </SidebarGroup> | 4602 | </SidebarGroup> |
| 4574 | } | 4603 | } |
| 4575 | > | 4604 | > |
| 4576 | <SidebarGroup className="flex-1"> | 4605 | <SidebarGroup className="flex-1" role="presentation"> |
| 4577 | {isSearchingThreads ? ( | 4606 | {isSearchingThreads ? ( |
| 4578 | threadSearchResults.length > 0 ? ( | 4607 | threadSearchResults.length > 0 ? ( |
| 4579 | <TooltipProvider | 4608 | <TooltipProvider |
| ... | @@ -4664,7 +4693,11 @@ export default function Sidebar() { | ... | @@ -4664,7 +4693,11 @@ export default function Sidebar() { |
| 4664 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> | 4693 | <SortableContext items={sortableIds} strategy={sidebarSortingStrategy}> |
| 4665 | <ul | 4694 | <ul |
| 4666 | ref={attachListMotionRef} | 4695 | ref={attachListMotionRef} |
| 4667 | role="list" | 4696 | // VoiceOver treats an exposed list as an interaction boundary, |
| 4697 | // which hides its rows from ordinary linear navigation. A | ||
| 4698 | // presentational list also makes its implicit listitems | ||
| 4699 | // presentational while preserving every descendant control. | ||
| 4700 | role="presentation" | ||
| 4668 | className={cn( | 4701 | className={cn( |
| 4669 | "relative flex flex-col gap-px", | 4702 | "relative flex flex-col gap-px", |
| 4670 | sidebarListItems.length > 0 && "flex-1", | 4703 | sidebarListItems.length > 0 && "flex-1", |
apps/web/src/components/ui/scroll-area.tsx+4| ... | @@ -30,6 +30,7 @@ function ScrollArea({ | ... | @@ -30,6 +30,7 @@ function ScrollArea({ |
| 30 | hideScrollbars = false, | 30 | hideScrollbars = false, |
| 31 | chainVerticalScroll = false, | 31 | chainVerticalScroll = false, |
| 32 | radius = "inherit", | 32 | radius = "inherit", |
| 33 | viewportTabIndex, | ||
| 33 | ...props | 34 | ...props |
| 34 | }: ScrollAreaPrimitive.Root.Props & { | 35 | }: ScrollAreaPrimitive.Root.Props & { |
| 35 | scrollFade?: boolean; | 36 | scrollFade?: boolean; |
| ... | @@ -41,6 +42,8 @@ function ScrollArea({ | ... | @@ -41,6 +42,8 @@ function ScrollArea({ |
| 41 | chainVerticalScroll?: boolean; | 42 | chainVerticalScroll?: boolean; |
| 42 | /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */ | 43 | /** The viewport clips to the parent's radius; "none" for a region flush to an edge. */ |
| 43 | radius?: "inherit" | "none"; | 44 | radius?: "inherit" | "none"; |
| 45 | /** Override Base UI's focusable viewport when focusable descendants provide scroll access. */ | ||
| 46 | viewportTabIndex?: number; | ||
| 44 | }) { | 47 | }) { |
| 45 | return ( | 48 | return ( |
| 46 | <ScrollAreaPrimitive.Root | 49 | <ScrollAreaPrimitive.Root |
| ... | @@ -52,6 +55,7 @@ function ScrollArea({ | ... | @@ -52,6 +55,7 @@ function ScrollArea({ |
| 52 | {...props} | 55 | {...props} |
| 53 | > | 56 | > |
| 54 | <ScrollAreaPrimitive.Viewport | 57 | <ScrollAreaPrimitive.Viewport |
| 58 | {...(viewportTabIndex === undefined ? {} : { tabIndex: viewportTabIndex })} | ||
| 55 | className={cn( | 59 | className={cn( |
| 56 | "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", | 60 | "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadow focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain", |
| 57 | chainVerticalScroll && "overscroll-y-auto", | 61 | chainVerticalScroll && "overscroll-y-auto", |
apps/web/src/components/ui/sidebar.tsx+6-1| ... | @@ -233,7 +233,7 @@ function Sidebar({ | ... | @@ -233,7 +233,7 @@ function Sidebar({ |
| 233 | if (isMobile) { | 233 | if (isMobile) { |
| 234 | return ( | 234 | return ( |
| 235 | <SidebarInstanceContext value={instanceContextValue}> | 235 | <SidebarInstanceContext value={instanceContextValue}> |
| 236 | <Sheet onOpenChange={setOpenMobile} open={openMobile} {...props}> | 236 | <Sheet onOpenChange={setOpenMobile} open={openMobile}> |
| 237 | <SheetPopup | 237 | <SheetPopup |
| 238 | className={cn( | 238 | className={cn( |
| 239 | "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground", | 239 | "w-(--sidebar-width) max-w-none bg-sidebar surface-grain p-0 text-sidebar-foreground", |
| ... | @@ -259,6 +259,7 @@ function Sidebar({ | ... | @@ -259,6 +259,7 @@ function Sidebar({ |
| 259 | "flex h-full w-full flex-col pb-safe pt-safe", | 259 | "flex h-full w-full flex-col pb-safe pt-safe", |
| 260 | side === "left" ? "pl-safe" : "pr-safe", | 260 | side === "left" ? "pl-safe" : "pr-safe", |
| 261 | )} | 261 | )} |
| 262 | {...props} | ||
| 262 | > | 263 | > |
| 263 | {children} | 264 | {children} |
| 264 | </div> | 265 | </div> |
| ... | @@ -597,6 +598,10 @@ function SidebarContent({ | ... | @@ -597,6 +598,10 @@ function SidebarContent({ |
| 597 | hideScrollbars | 598 | hideScrollbars |
| 598 | scrollFade | 599 | scrollFade |
| 599 | scrollFadePadding={false} | 600 | scrollFadePadding={false} |
| 601 | // Thread rows provide keyboard access to this scroll region. Keeping | ||
| 602 | // Base UI's viewport out of the tab order lets its presentational role | ||
| 603 | // flatten in WebKit instead of becoming a VoiceOver interaction group. | ||
| 604 | viewportTabIndex={-1} | ||
| 600 | className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]" | 605 | className="h-auto min-h-0 flex-1 [&>[data-slot=scroll-area-viewport]]:[--fade-size:0.75rem]" |
| 601 | > | 606 | > |
| 602 | <div | 607 | <div |
apps/web/src/components/ui/switch.test.tsx+71-11| ... | @@ -1,20 +1,80 @@ | ... | @@ -1,20 +1,80 @@ |
| 1 | import { renderToStaticMarkup } from "react-dom/server"; | 1 | // @vitest-environment jsdom |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | |
| 3 | import { act, useState } from "react"; | ||
| 4 | import { createRoot, type Root } from "react-dom/client"; | ||
| 5 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 3 | 6 | ||
| 4 | import { Switch } from "./switch"; | 7 | import { Switch } from "./switch"; |
| 5 | 8 | ||
| 6 | function renderSwitchRoot(props: Partial<Parameters<typeof Switch>[0]>) { | 9 | let root: Root; |
| 7 | const html = renderToStaticMarkup(<Switch aria-label="Enable Claude Code" {...props} />); | 10 | let container: HTMLDivElement; |
| 8 | return html.match(/<span[^>]*role="switch"[^>]*>/)?.[0] ?? ""; | 11 | |
| 12 | beforeEach(() => { | ||
| 13 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | ||
| 14 | container = document.createElement("div"); | ||
| 15 | document.body.append(container); | ||
| 16 | root = createRoot(container); | ||
| 17 | }); | ||
| 18 | |||
| 19 | afterEach(async () => { | ||
| 20 | await act(async () => root.unmount()); | ||
| 21 | container.remove(); | ||
| 22 | vi.unstubAllGlobals(); | ||
| 23 | }); | ||
| 24 | |||
| 25 | function ControlledSwitch({ | ||
| 26 | initialChecked, | ||
| 27 | initialMixed = false, | ||
| 28 | }: { | ||
| 29 | initialChecked: boolean; | ||
| 30 | initialMixed?: boolean; | ||
| 31 | }) { | ||
| 32 | const [checked, setChecked] = useState(initialChecked); | ||
| 33 | const [mixed, setMixed] = useState(initialMixed); | ||
| 34 | |||
| 35 | return ( | ||
| 36 | <Switch | ||
| 37 | aria-label="Enable Claude Code" | ||
| 38 | checked={checked} | ||
| 39 | mixed={mixed} | ||
| 40 | onCheckedChange={(nextChecked) => { | ||
| 41 | setMixed(false); | ||
| 42 | setChecked(nextChecked); | ||
| 43 | }} | ||
| 44 | /> | ||
| 45 | ); | ||
| 9 | } | 46 | } |
| 10 | 47 | ||
| 11 | describe("Switch accessibility", () => { | 48 | function switchRoot() { |
| 12 | it("exposes the checked state to assistive tech", () => { | 49 | const element = container.querySelector<HTMLElement>('[role="switch"]'); |
| 13 | expect(renderSwitchRoot({ checked: true })).toContain('aria-checked="true"'); | 50 | if (!element) throw new Error("Switch was not rendered"); |
| 14 | expect(renderSwitchRoot({ checked: false })).toContain('aria-checked="false"'); | 51 | return element; |
| 52 | } | ||
| 53 | |||
| 54 | async function clickSwitch() { | ||
| 55 | await act(async () => switchRoot().click()); | ||
| 56 | } | ||
| 57 | |||
| 58 | async function renderSwitch(initialChecked: boolean, initialMixed = false) { | ||
| 59 | await act(async () => { | ||
| 60 | root.render(<ControlledSwitch {...{ initialChecked, initialMixed }} />); | ||
| 15 | }); | 61 | }); |
| 62 | } | ||
| 63 | |||
| 64 | describe("Switch accessibility", () => { | ||
| 65 | it.each([ | ||
| 66 | { initialChecked: false, initialMixed: false, before: false, after: true }, | ||
| 67 | { initialChecked: true, initialMixed: false, before: true, after: false }, | ||
| 68 | { initialChecked: false, initialMixed: true, before: "mixed", after: true }, | ||
| 69 | ])("exposes $before before activation and $after after activation", async (state) => { | ||
| 70 | await renderSwitch(state.initialChecked, state.initialMixed); | ||
| 71 | expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.before)); | ||
| 72 | |||
| 73 | await clickSwitch(); | ||
| 16 | 74 | ||
| 17 | it("exposes the mixed state", () => { | 75 | expect(switchRoot().getAttribute("aria-checked")).toBe(String(state.after)); |
| 18 | expect(renderSwitchRoot({ checked: false, mixed: true })).toContain('aria-checked="mixed"'); | 76 | expect(container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe( |
| 77 | state.after, | ||
| 78 | ); | ||
| 19 | }); | 79 | }); |
| 20 | }); | 80 | }); |