fix(host): native button semantics for rows; path editor inert under the create dialog

Rows keep their NATIVE button role inside a listitem seat (role overrides
exposed selectable folders as passive list items to assistive technology),
the crumb trail becomes a navigation landmark, and the path-edit input joins
the parentInert derivation so the nested create dialog cannot be undercut
through it.
This commit is contained in:
creatixchu
2026-07-28 23:50:05 +08:00
parent 6add9a8eef
commit af049cceaf
3 changed files with 53 additions and 40 deletions

View File

@@ -140,7 +140,13 @@
background: var(--dsw-alias-border-l3); background: var(--dsw-alias-border-l3);
} }
.rowSeat {
display: flex;
flex: none;
}
.row { .row {
width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;

View File

@@ -71,21 +71,23 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide }: {
{entries.filter(entry => !entry.hidden).map((entry) => { {entries.filter(entry => !entry.hidden).map((entry) => {
const selected = entry.path === selectedPath const selected = entry.path === selectedPath
return ( return (
<button // The wrapper carries the list semantics; the row keeps its NATIVE
key={entry.path} // button role so assistive technology exposes an actionable control.
type="button" <span key={entry.path} role="listitem" className={css.rowSeat}>
role="listitem" <button
aria-current={selected || undefined} type="button"
className={clsx(css.row, selected && css.rowSelected)} aria-current={selected || undefined}
disabled={busy} className={clsx(css.row, selected && css.rowSelected)}
onClick={() => { onPick(entry) }} disabled={busy}
> onClick={() => { onPick(entry) }}
{selected >
? <IconFolderOpen16 size={16} className={css.rowIconSelected} /> {selected
: <IconFolderClose16 size={16} className={css.rowIcon} />} ? <IconFolderOpen16 size={16} className={css.rowIconSelected} />
<span className={css.rowName}>{entry.name}</span> : <IconFolderClose16 size={16} className={css.rowIcon} />}
<IconChevronRightOutline14 size={12} className={css.rowChevron} /> <span className={css.rowName}>{entry.name}</span>
</button> <IconChevronRightOutline14 size={12} className={css.rowChevron} />
</button>
</span>
) )
})} })}
</div> </div>
@@ -260,7 +262,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
{pathDraft === null {pathDraft === null
? ( ? (
<> <>
<span className={css.crumbTrail} ref={crumbTrailRef}> <span className={css.crumbTrail} role="navigation" ref={crumbTrailRef}>
{crumbs.map((crumb, index) => ( {crumbs.map((crumb, index) => (
<span key={crumb.path} className={css.crumbSeat}> <span key={crumb.path} className={css.crumbSeat}>
{index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />} {index > 0 && <IconChevronRightOutline14 size={12} className={css.crumbChevron} />}
@@ -292,7 +294,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen,
value={pathDraft} value={pathDraft}
aria-label={t('browser.editPath')} aria-label={t('browser.editPath')}
autoFocus autoFocus
disabled={busy} disabled={parentInert}
onChange={(event) => { setPathDraft(event.target.value) }} onChange={(event) => { setPathDraft(event.target.value) }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter') { if (event.key === 'Enter') {

View File

@@ -83,6 +83,11 @@ function columns(): HTMLElement[] {
return screen.getAllByRole('list') return screen.getAllByRole('list')
} }
/** The actionable button inside a listitem seat (rows keep native button semantics). */
function rowButton(item: HTMLElement): HTMLButtonElement {
return within(item).getByRole<HTMLButtonElement>('button')
}
describe('DirectoryBrowser', () => { describe('DirectoryBrowser', () => {
it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => { it('opens at the Host home as one wide column, hides hidden entries, and roots the crumbs at Home', async () => {
const b = mount() const b = mount()
@@ -98,39 +103,39 @@ describe('DirectoryBrowser', () => {
it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => { it('selects a row into the two-pane view: children preview right, crumbs follow the selection', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
const [level, preview] = columns() const [level, preview] = columns()
const selectedRow = within(level!).getByRole('listitem') const selectedRow = within(level!).getByRole('listitem')
expect(selectedRow.textContent).toBe('Documents') expect(selectedRow.textContent).toBe('Documents')
expect(selectedRow.getAttribute('aria-current')).toBe('true') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
expect(within(preview!).getByRole('listitem').textContent).toBe('harness') expect(within(preview!).getByRole('listitem').textContent).toBe('harness')
expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS) expect(b.listDirectory).toHaveBeenLastCalledWith(DOCS)
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy() expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy()
}) })
it('advances one level when a right-column row is picked', async () => { it('advances one level when a right-column row is picked', async () => {
mount() mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(within(columns()[1]!).getByRole('listitem')) fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem')))
await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() })
const [level] = columns() const [level] = columns()
const selectedRow = within(level!).getByRole('listitem') const selectedRow = within(level!).getByRole('listitem')
expect(selectedRow.textContent).toBe('harness') expect(selectedRow.textContent).toBe('harness')
expect(selectedRow.getAttribute('aria-current')).toBe('true') expect(rowButton(selectedRow).getAttribute('aria-current')).toBe('true')
}) })
it('jumps back through a crumb into a fresh single-column level', async () => { it('jumps back through a crumb into a fresh single-column level', async () => {
mount() mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
await waitFor(() => { expect(columns()).toHaveLength(1) }) await waitFor(() => { expect(columns()).toHaveLength(1) })
expect(screen.getByRole('listitem').textContent).toBe('Documents') expect(screen.getByRole('listitem').textContent).toBe('Documents')
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBeNull() expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull()
}) })
it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => {
@@ -138,7 +143,7 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
expect(b.onOpen).toHaveBeenCalledWith(HOME) expect(b.onOpen).toHaveBeenCalledWith(HOME)
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.open' })) fireEvent.click(screen.getByRole('button', { name: 'browser.open' }))
expect(b.onOpen).toHaveBeenLastCalledWith(DOCS) expect(b.onOpen).toHaveBeenLastCalledWith(DOCS)
@@ -282,8 +287,8 @@ describe('DirectoryBrowser', () => {
expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) expect(cancels.map(button => button.disabled).sort()).toEqual([false, true])
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.open' }).disabled).toBe(true)
expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(true) expect(screen.getByRole<HTMLButtonElement>('button', { name: 'browser.editPath' }).disabled).toBe(true)
for (const row of screen.getAllByRole<HTMLButtonElement>('listitem')) { for (const row of screen.getAllByRole('listitem')) {
expect(row.disabled).toBe(true) expect(rowButton(row).disabled).toBe(true)
} }
}) })
@@ -326,7 +331,7 @@ describe('DirectoryBrowser', () => {
it('creates a folder through the nested dialog and lands with it selected', async () => { it('creates a folder through the nested dialog and lands with it selected', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' }))
// The nested dialog names the create target (the selected folder). // The nested dialog names the create target (the selected folder).
@@ -352,10 +357,10 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') }) await waitFor(() => { expect(b.createDirectory).toHaveBeenCalledWith(DOCS, 'fresh') })
// The create target became the level and the new folder its selection. // The create target became the level and the new folder its selection.
await waitFor(() => { await waitFor(() => {
expect(screen.getByRole('button', { name: 'Documents' })).toBeTruthy() expect(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })).toBeTruthy()
const level = columns()[0]! const level = columns()[0]!
const rows = within(level).getAllByRole('listitem') const rows = within(level).getAllByRole('listitem')
expect(rows.some(row => row.textContent === 'fresh' && row.getAttribute('aria-current') === 'true')).toBe(true) expect(rows.some(row => row.textContent === 'fresh' && rowButton(row).getAttribute('aria-current') === 'true')).toBe(true)
}) })
}) })
@@ -394,9 +399,9 @@ describe('DirectoryBrowser', () => {
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
b.listDirectory.mockRejectedValueOnce( b.listDirectory.mockRejectedValueOnce(
new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } })) new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path: DOCS } }))
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') }) await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
expect(screen.getByRole('listitem').getAttribute('aria-current')).toBe('true') expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBe('true')
// No preview column arrived for the failed selection. // No preview column arrived for the failed selection.
expect(columns()).toHaveLength(1) expect(columns()).toHaveLength(1)
}) })
@@ -419,7 +424,7 @@ describe('DirectoryBrowser', () => {
let resolveSlow!: (value: DirectoryListing) => void let resolveSlow!: (value: DirectoryListing) => void
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle }) const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
b.listDirectory.mockReturnValueOnce(slow) b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
await waitFor(() => { expect(columns()).toHaveLength(1) }) await waitFor(() => { expect(columns()).toHaveLength(1) })
@@ -435,7 +440,7 @@ describe('DirectoryBrowser', () => {
let rejectSlow!: (reason: unknown) => void let rejectSlow!: (reason: unknown) => void
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail }) const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
b.listDirectory.mockReturnValueOnce(slow) b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) }) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(3) })
rejectSlow(new Error('too late to matter')) rejectSlow(new Error('too late to matter'))
@@ -447,14 +452,14 @@ describe('DirectoryBrowser', () => {
it('drops a stale navigation failure that rejects after a newer jump', async () => { it('drops a stale navigation failure that rejects after a newer jump', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
let rejectSlow!: (reason: unknown) => void let rejectSlow!: (reason: unknown) => void
const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail }) const slow = new Promise<DirectoryListing>((_settle, fail) => { rejectSlow = fail })
b.listDirectory.mockReturnValueOnce(slow) b.listDirectory.mockReturnValueOnce(slow)
// A slow crumb jump superseded by a second jump. // A slow crumb jump superseded by a second jump.
fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
fireEvent.click(screen.getByRole('button', { name: 'Documents' })) fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) }) await waitFor(() => { expect(b.listDirectory).toHaveBeenCalledTimes(4) })
rejectSlow(new Error('late nav failure')) rejectSlow(new Error('late nav failure'))
await new Promise(settle => setTimeout(settle, 0)) await new Promise(settle => setTimeout(settle, 0))
@@ -464,13 +469,13 @@ describe('DirectoryBrowser', () => {
it('drops a stale navigation listing that resolves after a newer jump', async () => { it('drops a stale navigation listing that resolves after a newer jump', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
let resolveSlow!: (value: DirectoryListing) => void let resolveSlow!: (value: DirectoryListing) => void
const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle }) const slow = new Promise<DirectoryListing>((settle) => { resolveSlow = settle })
b.listDirectory.mockReturnValueOnce(slow) b.listDirectory.mockReturnValueOnce(slow)
fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(screen.getByRole('button', { name: 'browser.home' }))
fireEvent.click(screen.getByRole('button', { name: 'Documents' })) fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' }))
await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') })
resolveSlow(listingFor(undefined)) resolveSlow(listingFor(undefined))
await new Promise(settle => setTimeout(settle, 0)) await new Promise(settle => setTimeout(settle, 0))
@@ -510,7 +515,7 @@ describe('DirectoryBrowser', () => {
it('starts back at home on reopen', async () => { it('starts back at home on reopen', async () => {
const b = mount() const b = mount()
await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() })
fireEvent.click(screen.getByRole('listitem')) fireEvent.click(rowButton(screen.getByRole('listitem')))
await waitFor(() => { expect(columns()).toHaveLength(2) }) await waitFor(() => { expect(columns()).toHaveLength(2) })
b.view.rerender(<DirectoryBrowser {...b.props} open={false} />) b.view.rerender(<DirectoryBrowser {...b.props} open={false} />)
b.view.rerender(<DirectoryBrowser {...b.props} open />) b.view.rerender(<DirectoryBrowser {...b.props} open />)