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:
@@ -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;
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
@@ -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 />)
|
||||||
|
|||||||
Reference in New Issue
Block a user