diff --git a/CHANGELOG.md b/CHANGELOG.md index 494dc90..c0d5643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ minor releases may break. Releases are tagged `vX.Y.Z` and publish from CI. - The docs ship in the package under `docs/`, the pages docs.gopherium.org shows, so an app reads the ones for the version it installs. - Design System pages under `docs/design-system/` write down the color, layout, radius and elevation, typography, motion and accessibility rules of a godmin admin, checked against the code by tests. - `--godmin-navigation-slide-distance` and `--godmin-navigation-slide-duration` are public custom properties, so an app may change the sidebar slide. +- `useCloseWhileOpen` returns a dialog's close that acts only while that dialog is on screen, so a row dialog an app writes never shuts a dialog opened on the row since. +- `focusInDialog` reports whether a dialog or an alert dialog holds the focus, for a write an app finishes outside a list run. +- `useFocusBack` puts a lost focus back on the title, media, phone row or actions menu a list dialog opened from, else on the list, in every layout, found through a `data-item-id` mark on the title cell. `focusedTrigger` reads which one, or the bulk bar, opened the dialog. +- `openOnTap` is in the main entry too, so a list drawn without TanStack Router opens the record a phone tap lands on. The router entry still hands it out. +- The list view types, such as `ListView` and `ListDefaults`, are in the main entry too, so a package without TanStack Router can type the list an app hands it. + +### Fixed + +- `RunConfirm` closes only itself once its run ends, never a dialog opened on the row after the confirm was dismissed while busy. +- `useListRun` focuses the list after a run moved an item only while the focus sits on the page, in the list, in the dialog the run started in, or, once that dialog closed, still where the focus first went outside it while it was open, so a dialog opened during a run and a control the reader moved to after that dialog closed keep the focus. ## [0.26.0] - 2026-10-24 diff --git a/README.md b/README.md index 56efbde..2ddb103 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ those two lines do for you. | Entry point | Contents | | --- | --- | -| `@gopherium/godmin` | `AdminRoot`, `Frame`, `Page`, `PageTitle`, `PageTabs`, `PageTab`, `SectionTitle`, `InitialsAvatar`, `NavigationScreen`, `MenuCount`, `ErrorNotice`, `LoadMore`, `LogList`, `LogItem`, `LogTime`, `LoadingScreen`, `LoadingRows`, `Toaster`, `useToaster`, `RepeatRows`, `RowControls`, `useRowKeys`, `keyFromLabel`, `listChromeCatalogFor`, `LIST_CHROME_DOMAIN`, `ListEmpty`, `ConfirmBody`, `RenameBody`, `FormBody`, `FileButton`, `useServerPaging`, `pageWindow`, `paginationOf`, `runEach`, `bulkNotes`, `useListRun`, `useBulkRun`, `RunConfirm`, `useUnsavedText`, `markLatest`, `viewNarrows`, `offeredView`, `offeredSize`, `sizedView`, `useMediaQuery`, `useTokenDocument`, the breakpoints `HUGE_BREAKPOINT`, `SIDEBAR_BREAKPOINT`, `EDGE_BREAKPOINT`, `DENSE_BREAKPOINT`, `SHEET_BREAKPOINT`, `TITLE_BREAKPOINT` and `SMALL_VIEWPORT`, `SUPPORTED_WPDS` | +| `@gopherium/godmin` | `AdminRoot`, `Frame`, `Page`, `PageTitle`, `PageTabs`, `PageTab`, `SectionTitle`, `InitialsAvatar`, `NavigationScreen`, `MenuCount`, `ErrorNotice`, `LoadMore`, `LogList`, `LogItem`, `LogTime`, `LoadingScreen`, `LoadingRows`, `Toaster`, `useToaster`, `RepeatRows`, `RowControls`, `useRowKeys`, `keyFromLabel`, `listChromeCatalogFor`, `LIST_CHROME_DOMAIN`, `ListEmpty`, `ConfirmBody`, `RenameBody`, `FormBody`, `FileButton`, `useServerPaging`, `pageWindow`, `paginationOf`, `runEach`, `bulkNotes`, `useListRun`, `useBulkRun`, `RunConfirm`, `useCloseWhileOpen`, `focusInDialog`, `useFocusBack`, `focusedTrigger`, `openOnTap`, `useUnsavedText`, `markLatest`, `viewNarrows`, `offeredView`, `offeredSize`, `sizedView`, `useMediaQuery`, `useTokenDocument`, the breakpoints `HUGE_BREAKPOINT`, `SIDEBAR_BREAKPOINT`, `EDGE_BREAKPOINT`, `DENSE_BREAKPOINT`, `SHEET_BREAKPOINT`, `TITLE_BREAKPOINT` and `SMALL_VIEWPORT`, `SUPPORTED_WPDS` | | `@gopherium/godmin/base.css` | Cascade layer order, design tokens, host rules, frame and screen styles | | `@gopherium/godmin/dates` | `startListDates`, `listDate`, the only entry that loads `@wordpress/date` | | `@gopherium/godmin/dialogs` | `ConfirmDialog`, `FormDialog`, the only entry that loads `@wordpress/components`. Load `@wordpress/components/build-style/style.css` once in your app, as DataViews asks | diff --git a/docs/design-system/accessibility.md b/docs/design-system/accessibility.md index 5e5f709..108ab05 100644 --- a/docs/design-system/accessibility.md +++ b/docs/design-system/accessibility.md @@ -81,7 +81,12 @@ a part that stays, so the keyboard reader keeps their place: Dialogs move focus as they open and close, as [a confirm outside a list](../lists.md#a-confirm-outside-a-list) and -[a form dialog](../lists.md#a-form-dialog) explain. +[a form dialog](../lists.md#a-form-dialog) explain. When the list +drew the row a dialog opened from anew while the dialog was open, +`useFocusBack` puts the focus back on that row, or on the list once +the row left it, as +[putting the focus back on a row](../lists.md#putting-the-focus-back-on-a-row) +explains. ## Landmarks and names diff --git a/docs/lists.md b/docs/lists.md index 2eef010..6bdc567 100644 --- a/docs/lists.md +++ b/docs/lists.md @@ -1,6 +1,6 @@ --- title: Lists with DataViews -description: Hosting a WordPress DataViews list in a godmin page, its view in the address, paging it on the server, empty states, runs over the picked rows, confirmations, confirms outside a list, renames, forms in a dialog, bulk messages, uploads, its Spanish chrome, its dates, and the table look for lists that stay custom. +description: Hosting a WordPress DataViews list in a godmin page, its view in the address, paging it on the server, empty states, runs over the picked rows, confirmations, confirms outside a list, renames, forms in a dialog, the focus back on a row, bulk messages, uploads, its Spanish chrome, its dates, and the table look for lists that stay custom. --- DataViews is the WordPress list component. It draws the search box, @@ -132,7 +132,13 @@ the next, the list forgets the picked columns and the ticked rows. A tap on a phone list selects nothing. `openOnTap(list, open)` gives you a handler that opens the record instead. Pass it as `onChangeSelection`. `list.phone` tells you which side of 640px the -screen is on. +screen is on. `openOnTap` comes from `@gopherium/godmin/router`, like +`useListView`, and from the main entry too, so a package that draws a +list without TanStack Router can call it with the `list` an app hands +it. It reads only `phone` and `onChangeSelection`. The list view +types, such as `ListView` and `ListDefaults`, come from the main entry +too, so such a package can type that `list` and its defaults without +the router. This needs godmin 0.27.0 or later. Leave `perPage` out while you do not know it yet, for example while your settings load. The list then shows every row. @@ -448,8 +454,8 @@ example does, so the reader sees one toast. Every list action that changes rows does the same things around the calls: the list reloaded, one toast, and the focus moved back to the -list once a row moved. `useListRun` and `useBulkRun` hold those -things for you: +list once a row moved, while the reader is still with the run. +`useListRun` and `useBulkRun` hold those things for you: ```tsx import { Page, RunConfirm, useBulkRun, useListRun } from '@gopherium/godmin' @@ -498,6 +504,37 @@ left on a button that went away with its row. When two runs overlap, only the latest one moves the focus. The list holds no failure. A run shows how it went in its toast. +A run can end while the reader works somewhere else, such as in a +dialog they opened on another row. So the list takes the focus only +while the reader is still with the run: + +- the focus fell to the page, +- the focus sits inside the list, +- the focus sits in the dialog the run started in, +- or that dialog closed before the run ended, the focus still sits + where it first went outside that dialog while the dialog was open, + and no dialog holds the focus. A confirm that closes at once gives + the focus back to the button that opened it, and the list then + takes the focus from that button. + +A dialog opened since keeps the focus, so typing still lands in it +and Escape still closes it. A reader who moved on to another control +outside the list keeps their place: during a run started outside +every dialog, while the dialog the run started in is still open, and +after that dialog gave the focus back or closed with nowhere to give +it. One move is the exception. A dialog that lets the focus out, +unlike a WordPress modal, can be left for another control while it +is still open. That control then counts as where the dialog gave the +focus back, since the focus moves the same way in both cases. So when +the dialog closes and the run ends with the focus still there, the +list takes the focus from it. Every list run does this, with nothing +to add to your screen. This needs godmin 0.27.0 or later. Before it, +the list took the focus wherever the focus sat. + +`focusInDialog()` tells whether a dialog holds the focus, for a write +you finish yourself outside a run. It also needs godmin 0.27.0 or +later. + `useListRun` hands the same `run` for as long as the screen stays, whichever list it shows. So when the reader moves to another tab, the handler `useBulkRun` builds from it stays the same too, as long as @@ -525,6 +562,11 @@ when the reader confirms, shows the confirm button busy and keeps Cancel inert while the run goes, and closes once the run settles. A failure shows in the run's toast, never inside the modal. It also closes when the run rejects, and the error still reaches your app. +It closes only itself. DataViews keeps one dialog for a row's actions +menu and another for its primary action buttons. A reader can dismiss +a busy confirm by clicking the dimmed page and open another dialog in +its place, from the same menu or the same buttons. When the run then +ends, that other dialog stays open, with what the reader typed in it. `listLabel` and `listRef` make the list a region with a name, which a screen reader announces, and give the focus its target. The region @@ -536,6 +578,17 @@ the same run. Call `run.onStart()` as its write starts. It answers a function, typed `RunSettled`. Call that function once the write ends, with whether a row moved, and show how it went in a toast. +Such a run starts outside every dialog. So when it ends, the list +takes the focus only if the focus fell to the page or sits inside the +list. While the focus is still on your button, or on another control +the reader moved to, it stays there. When your button opens a +`ConfirmDialog` first, as Empty Trash does, call `run.onStart()` +inside `onConfirm`, as [a confirm outside a list](#a-confirm-outside-a-list) +explains. The list then takes the focus once the run moved a row, +even after the dialog closed, while the focus is still on the button +the dialog gave it back to. A reader who moved to another control by +then keeps their place. This needs godmin 0.27.0 or later. + ## Confirming an action An action that cannot be undone asks first. DataViews opens a modal @@ -575,17 +628,18 @@ For a run you write yourself, `ConfirmBody` is the same body without the run: ```tsx -import { ConfirmBody, runEach, useToaster } from '@gopherium/godmin' +import { ConfirmBody, runEach, useCloseWhileOpen, useToaster } from '@gopherium/godmin' function TrashModal({ items, closeModal }: RenderModalProps) { const toaster = useToaster() + const close = useCloseWhileOpen(closeModal) const [busy, setBusy] = useState(false) const confirm = async () => { setBusy(true) const outcome = await runEach(items, (post) => trashPost(post.id)) const moved = outcome.failures.length === 0 toaster.show(moved ? 'Post moved to the trash.' : 'The post could not be moved to the trash.') - closeModal?.() + close() } return ( ) { const toaster = useToaster() + const close = useCloseWhileOpen(closeModal) const [title, setTitle] = useState(`${post.title} (Copy)`) const [busy, setBusy] = useState(false) const duplicate = () => { @@ -810,7 +891,7 @@ function DuplicateModal({ items: [post], closeModal }: RenderModalProps) { duplicatePost(post.id, title) .then(() => toaster.show(`"${toaster.name(title)}" created.`)) .catch((failure) => toaster.show(failure.message)) - .finally(() => closeModal?.()) + .finally(close) } return ( () + +const close = (shown: { user: User, trigger: Trigger }) => { + setOpened(undefined) + focusBack(shown.user.id, shown.trigger) +} +``` + +Mark the title cell of each row with the id DataViews keys the row +by, as `data-item-id`. That mark is how the hook finds the row in any +layout: + +```tsx +render: ({ item }) => {item.name} +``` + +Then note the trigger each dialog opened from. `focusedTrigger()` +reads it from the focus the click, tap or key left: + +```tsx +onClickItem={(user) => setOpened({ user, trigger: focusedTrigger() })} +callback: ([user]) => setOpened({ user, trigger: focusedTrigger() }) +``` + +The hook knows four triggers on a row: the title and the media a +click opens from, the phone row a tap opens from and the actions +menu. It finds the grid card, the phone list row or the table row +that holds the item now and focuses the same trigger there. When that +layout has no such trigger, such as a title in the phone list, it +focuses the row's actions menu. When the item left the list, or its +row has no actions menu, the list region takes the focus. + +An action chosen in the bulk bar reads `bulk` from `focusedTrigger()`. +The bar goes away once the selection clears, so the list region +takes the focus. + +The hook only moves a focus the page lost. When WordPress already put +the focus back, or the reader moved it, it stays where it is. While +the first argument is `true`, for example while the list reads its +rows again, the hook waits, since a busy list is out of reach of the +focus. Pass the same `region` you hand `Page` as `listRef`. The hook +looks for the region when the focus is asked back. When the list is +busy then, it looks once the wait ends. A screen that draws no region +at that point gets no focus moved, even when it draws one later. + +A list can draw the row again just after the focus went back to it. +The grid does this as it measures how many cards fit side by side, +since a card that moves to another row of cards is drawn anew. The +focus then falls to the page. The hook watches the list for this and +puts the focus on the same trigger of that row again, each time the +list draws it anew. When the item leaves the list instead, the list +region takes the focus and the hook stops watching. It also stops +once the reader moves the focus or presses somewhere else, the focus +is asked back again or the screen goes away, so a control the reader +moved to keeps the focus. This needs godmin 0.27.0 or later. + ## A failure from a dialog A write that fails never shows inside a dialog. It shows in a toast, diff --git a/docs/overview.md b/docs/overview.md index f2e3ace..0c94d7c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -29,7 +29,7 @@ duplicates something the packages above already ship. | Entry point | Contents | | --- | --- | -| `@gopherium/godmin` | `AdminRoot`, `Frame`, `Page`, `PageTitle`, `PageTabs`, `PageTab`, `SectionTitle`, `InitialsAvatar`, `NavigationScreen`, `MenuCount`, `ErrorNotice`, `LoadMore`, `LogList`, `LogItem`, `LogTime`, `LoadingScreen`, `LoadingRows`, `Toaster`, `useToaster`, `RepeatRows`, `RowControls`, `useRowKeys`, `keyFromLabel`, `listChromeCatalogFor`, `LIST_CHROME_DOMAIN`, `ListEmpty`, `ConfirmBody`, `RenameBody`, `FormBody`, `FileButton`, `useServerPaging`, `pageWindow`, `paginationOf`, `runEach`, `bulkNotes`, `useListRun`, `useBulkRun`, `RunConfirm`, `useUnsavedText`, `markLatest`, `viewNarrows`, `offeredView`, `offeredSize`, `sizedView`, `useMediaQuery`, `useTokenDocument`, the breakpoints `HUGE_BREAKPOINT`, `SIDEBAR_BREAKPOINT`, `EDGE_BREAKPOINT`, `DENSE_BREAKPOINT`, `SHEET_BREAKPOINT`, `TITLE_BREAKPOINT` and `SMALL_VIEWPORT`, `SUPPORTED_WPDS` | +| `@gopherium/godmin` | `AdminRoot`, `Frame`, `Page`, `PageTitle`, `PageTabs`, `PageTab`, `SectionTitle`, `InitialsAvatar`, `NavigationScreen`, `MenuCount`, `ErrorNotice`, `LoadMore`, `LogList`, `LogItem`, `LogTime`, `LoadingScreen`, `LoadingRows`, `Toaster`, `useToaster`, `RepeatRows`, `RowControls`, `useRowKeys`, `keyFromLabel`, `listChromeCatalogFor`, `LIST_CHROME_DOMAIN`, `ListEmpty`, `ConfirmBody`, `RenameBody`, `FormBody`, `FileButton`, `useServerPaging`, `pageWindow`, `paginationOf`, `runEach`, `bulkNotes`, `useListRun`, `useBulkRun`, `RunConfirm`, `useCloseWhileOpen`, `focusInDialog`, `useFocusBack`, `focusedTrigger`, `openOnTap`, `useUnsavedText`, `markLatest`, `viewNarrows`, `offeredView`, `offeredSize`, `sizedView`, `useMediaQuery`, `useTokenDocument`, the breakpoints `HUGE_BREAKPOINT`, `SIDEBAR_BREAKPOINT`, `EDGE_BREAKPOINT`, `DENSE_BREAKPOINT`, `SHEET_BREAKPOINT`, `TITLE_BREAKPOINT` and `SMALL_VIEWPORT`, `SUPPORTED_WPDS` | | `@gopherium/godmin/base.css` | Cascade layer order, design tokens, host rules, frame and screen styles | | `@gopherium/godmin/dates` | `startListDates`, `listDate`, the only entry that loads `@wordpress/date` | | `@gopherium/godmin/dialogs` | `ConfirmDialog`, `FormDialog`, the only entry that loads `@wordpress/components`. Load `@wordpress/components/build-style/style.css` once in your app, as DataViews asks | diff --git a/docs/screens.md b/docs/screens.md index 2d851dd..4329755 100644 --- a/docs/screens.md +++ b/docs/screens.md @@ -71,7 +71,11 @@ column. [Lists with DataViews](./lists.md) shows it in full. With either one, the list can take the focus but stays out of the Tab order. [Running an action over the picked rows](./lists.md#running-an-action-over-the-picked-rows) -uses both to move the focus back to the list. +uses both to move the focus back to the list once a row moved, +while the reader is still with the run. +[Putting the focus back on a row](./lists.md#putting-the-focus-back-on-a-row) +hands the same ref to `useFocusBack`, which puts the focus back on +the row a dialog opened from. A screen that fills the canvas edge to edge builds its own layout and uses `PageTitle` directly, so the page still gets its heading: diff --git a/src/close-while-open.ts b/src/close-while-open.ts new file mode 100644 index 0000000..81a4e35 --- /dev/null +++ b/src/close-while-open.ts @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useEffect, useRef } from 'react' + +/** + * Returns the close of a dialog body that acts only while that body is on screen, never on a dialog opened since. + * @param close - Closes the dialog, none for a body drawn outside a dialog. + * @returns The close, which does nothing once the body left the screen. + */ +export function useCloseWhileOpen(close: (() => void) | undefined): () => void { + const open = useRef(true) + useEffect(() => { + open.current = true + return () => { + open.current = false + } + }, []) + return () => { + if (open.current) { + close?.() + } + } +} diff --git a/src/focus-back.ts b/src/focus-back.ts new file mode 100644 index 0000000..8df2e9b --- /dev/null +++ b/src/focus-back.ts @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { RefObject } from 'react' + +/** Finds each cell an app marks with the id DataViews keys its item by, the title cell at least. */ +const MARKED = '[data-item-id]' + +/** Finds the card of a grid, the row of a phone list or the row of a table holding an item. */ +const HOLDERS = '.dataviews-view-grid__card, .dataviews-view-list__item-wrapper, .dataviews-view-table__row' + +/** Finds each trigger a dialog on an item opens from, inside the card or the row holding the item. */ +const TRIGGERS = { + title: '.dataviews-title-field[role="button"]', + media: '.dataviews-view-grid__media[role="button"], .dataviews-column-primary__media[role="button"]', + row: '.dataviews-view-list__item', + actions: '[aria-haspopup="menu"]', +} + +/** Finds the bar DataViews draws the actions on the ticked items in. */ +const BULK_BAR = '.dataviews-bulk-actions-footer__container' + +/** A trigger on a card or a row a dialog on its item opens from. */ +type ItemTrigger = keyof typeof TRIGGERS + +/** The trigger a list dialog opened from: an item's title, media, phone row or actions menu, or the bulk bar. */ +export type Trigger = ItemTrigger | 'bulk' + +/** One request to put the focus back on the trigger a dialog opened from, a new one each time it is asked. */ +interface FocusBack { + /** The item the dialog acted on. */ + id: string + /** The trigger the dialog opened from. */ + trigger: Trigger +} + +/** + * Returns the trigger a click, a tap or a key opened a dialog from, read from the focus it left there. + * @returns The bulk bar, the media, the title or the phone row holding the focus, the actions menu otherwise. + */ +export function focusedTrigger(): Trigger { + const focused = document.activeElement as Element + if (focused.closest(BULK_BAR) !== null) { + return 'bulk' + } + const triggers: ItemTrigger[] = ['media', 'title', 'row'] + return triggers.find((trigger) => focused.closest(TRIGGERS[trigger]) !== null) ?? 'actions' +} + +/** + * Returns the card or the row holding an item inside the list, found through the mark on its cells. + * @param list - The list region. + * @param id - The id DataViews keys the item by. + * @returns The card or the row, none once the item left the list or its mark sits outside every card and row. + */ +function holderOf(list: HTMLElement, id: string): Element | null { + const marked = [...list.querySelectorAll(MARKED)].find((cell) => cell.dataset.itemId === id) + return marked?.closest(HOLDERS) ?? null +} + +/** + * Returns the trigger a dialog on an item opened from, found wherever its card or row now sits. + * @param list - The list region. + * @param asked - The item and the trigger. + * @returns The trigger, else the item's actions menu, else the list, which a bulk action always gets. + */ +function triggerOf(list: HTMLElement, { id, trigger }: FocusBack): HTMLElement { + const holder = holderOf(list, id) + if (trigger === 'bulk' || holder === null) { + return list + } + const found = holder.querySelector(TRIGGERS[trigger]) + return found ?? holder.querySelector(TRIGGERS.actions) ?? list +} + +/** Stops nothing, for a focus put on the list region, which no layout of the list takes off the page. */ +function stopNothing(): void {} + +/** + * Reports whether a batch of changes to the list took an element off the page, to draw it anew or to move it. + * @param records - The changes to the list. + * @param element - The element. + * @returns Whether a change removed the element or a node holding it. + */ +function tookOff(records: MutationRecord[], element: Element): boolean { + return records.some((record) => [...record.removedNodes].some((node) => node.contains(element))) +} + +/** + * Puts the focus on the trigger a dialog on an item opened from, again on each redraw, on the list once the item left. + * @param list - The list region. + * @param asked - The item and the trigger. + * @returns What stops it, which also stops once the reader moves the focus or presses elsewhere, or the list takes it. + */ +function keepFocusOn(list: HTMLElement, asked: FocusBack): () => void { + let held = triggerOf(list, asked) + held.focus() + if (held === list) { + return stopNothing + } + const redrawn = new MutationObserver((records) => { + if (!tookOff(records, held)) { + return + } + held = triggerOf(list, asked) + held.focus() + if (held === list) { + stop() + } + }) + const moved = (event: Event) => { + if (!held.contains(event.target as Node)) { + stop() + } + } + const stop = () => { + redrawn.disconnect() + document.removeEventListener('focusin', moved, true) + document.removeEventListener('pointerdown', moved, true) + } + redrawn.observe(list, { childList: true, subtree: true }) + document.addEventListener('focusin', moved, true) + document.addEventListener('pointerdown', moved, true) + return stop +} + +/** + * Returns the handler putting a focus the page lost back on the trigger a list dialog opened from, once drawn again. + * @param busy - Whether the list reads its items again, its cards and rows then out of reach of the focus. + * @param region - The list region, whose cells carry the `data-item-id` mark, none while the screen draws no list. + * @returns The handler, given the id of the item and the trigger the dialog opened from. + */ +export function useFocusBack( + busy: boolean, + region: RefObject, +): (id: string, trigger: Trigger) => void { + const [asked, setAsked] = useState() + const handled = useRef(undefined) + const keeping = useRef(stopNothing) + useEffect(() => () => keeping.current(), []) + useEffect(() => { + if (busy || asked === handled.current) { + return + } + handled.current = asked + const list = region.current + if (list !== null && document.activeElement === document.body) { + keeping.current = keepFocusOn(list, asked as FocusBack) + } + }, [asked, busy, region]) + return useCallback((id: string, trigger: Trigger) => { + keeping.current() + setAsked({ id, trigger }) + }, []) +} diff --git a/src/index.ts b/src/index.ts index 1122123..c3bd80d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,10 +17,13 @@ export { useBulkRun } from './bulk-run.js' export type { BulkRun } from './bulk-run.js' export { bulkNotes, runEach } from './bulk.js' export type { BulkFailure, BulkNotes, BulkOutcome, BulkWords } from './bulk.js' +export { useCloseWhileOpen } from './close-while-open.js' export { ConfirmBody } from './confirm.js' export type { ConfirmBodyProps } from './confirm.js' export { FileButton } from './file-button.js' export type { FileButtonProps } from './file-button.js' +export { focusedTrigger, useFocusBack } from './focus-back.js' +export type { Trigger } from './focus-back.js' export { FormBody } from './form.js' export type { FormBodyProps, FormKind } from './form.js' export { Frame } from './frame.js' @@ -36,8 +39,17 @@ export { LIST_CHROME_DOMAIN, listChromeCatalogFor } from './list-chrome.js' export type { ListChromeCatalog } from './list-chrome.js' export { ListEmpty } from './list-empty.js' export type { ListEmptyProps } from './list-empty.js' -export { useListRun } from './list-run.js' +export { focusInDialog, useListRun } from './list-run.js' export type { ListRun, ListRunState, RunSettled } from './list-run.js' +export type { + LayoutDefaults, + ListDefaults, + ListFilter, + ListSearch, + ListSortDirection, + ListView, + ListViewShape, +} from './list-view.js' export { LoadingRows, LoadingScreen } from './loading.js' export { LogItem, LogList, LogTime } from './log-list.js' export type { LogItemProps, LogListProps, LogTimeProps } from './log-list.js' @@ -45,6 +57,8 @@ export { MenuCount } from './menu-count.js' export type { MenuCountProps } from './menu-count.js' export { NavigationScreen } from './navigation-screen.js' export type { NavigationScreenProps } from './navigation-screen.js' +export { openOnTap } from './open-on-tap.js' +export type { TapList } from './open-on-tap.js' export { ErrorNotice, LoadMore, Page, PageTitle, SectionTitle } from './page.js' export type { LoadMoreQuery, PageProps } from './page.js' export { PageTab, PageTabs } from './page-tabs.js' diff --git a/src/list-run.ts b/src/list-run.ts index 95658c4..91b93c4 100644 --- a/src/list-run.ts +++ b/src/list-run.ts @@ -3,7 +3,10 @@ import { useLayoutEffect, useRef, useState } from 'react' import type { RefObject } from 'react' -/** Settles a run on its list, focusing the list once an item moved. */ +/** Finds the dialog or the alert dialog holding the focus. */ +const DIALOG_IN_FOCUS = '[role="dialog"]:focus-within, [role="alertdialog"]:focus-within' + +/** Settles a run on its list, focusing the list once an item moved while the reader is still on it. */ export type RunSettled = (moved: boolean) => void /** What a list does around a run over its items. */ @@ -20,8 +23,71 @@ export interface ListRunState { region: RefObject } +/** Where the focus first landed outside the dialog a run started in, and what stops watching for it. */ +interface Landing { + /** The element the focus first landed on outside that dialog while it was on the page, none until it does. */ + element: EventTarget | null + /** Stops watching the focus, and does nothing once stopped. */ + stop: () => void +} + +/** Stops nothing, for a run that watches no focus. */ +function stopNothing(): void {} + +/** + * Reports whether a dialog holds the focus, which a write finished in the background leaves where it is. + * @returns Whether the focus sits inside a dialog or an alert dialog. + */ +export function focusInDialog(): boolean { + return document.querySelector(DIALOG_IN_FOCUS) !== null +} + +/** + * Watches where the focus first lands outside the dialog a run started in while that dialog is still on the page. + * @param startedIn - The dialog that held the focus as the run started, none for a run started outside every dialog. + * @returns The landing, filled in once the focus lands, which watches nothing for a run started outside every dialog. + */ +function watchLanding(startedIn: Element | null): Landing { + const landing: Landing = { element: null, stop: stopNothing } + if (startedIn === null) { + return landing + } + const note = (event: FocusEvent) => { + if (startedIn.isConnected && !startedIn.contains(event.target as Node)) { + landing.element = event.target + landing.stop() + } + } + document.addEventListener('focusin', note, true) + landing.stop = () => document.removeEventListener('focusin', note, true) + return landing +} + +/** + * Reports whether the dialog a run started in has closed while the focus stays where it first landed, in no dialog. + * @param startedIn - The dialog that held the focus as the run started, none for a run started outside every dialog. + * @param landed - The element the focus first landed on outside that dialog, none while it never did. + * @returns Whether that dialog has gone and the focus stays on the element it handed the focus back to. + */ +function stillWhereDialogLeftIt(startedIn: Element | null, landed: EventTarget | null): boolean { + return startedIn?.isConnected === false && document.activeElement === landed && !focusInDialog() +} + /** - * Holds the region a finished run focuses, and drops older runs and the ticked rows when the list changes. + * Reports whether the focus sits on the page, in the list, in the dialog that ran, or where that dialog left it. + * @param list - The list region, none while the screen draws no list. + * @param startedIn - The dialog that held the focus as the run started, none for a run started outside every dialog. + * @param landed - The element the focus first landed on outside that dialog, none while it never did. + * @returns Whether a finished run may move the focus to the list. + */ +function readerOnList(list: HTMLElement | null, startedIn: Element | null, landed: EventTarget | null): boolean { + const active = document.activeElement + const holds = [list, startedIn].some((holder) => holder !== null && holder.contains(active)) + return active === document.body || holds || stillWhereDialogLeftIt(startedIn, landed) +} + +/** + * Holds the region a run focuses while the reader stays on it, and drops older runs and ticked rows on a list change. * @param scope - Names the list on show. * @param onChangeSelection - Replaces the ticked rows, emptied when the list changes. * @returns The run and the region. @@ -29,9 +95,11 @@ export interface ListRunState { export function useListRun(scope: string, onChangeSelection: (selection: string[]) => void): ListRunState { const region = useRef(null) const latest = useRef(0) + const watching = useRef(stopNothing) const committed = useRef(scope) useLayoutEffect(() => { latest.current += 1 + return () => watching.current() }, [scope]) useLayoutEffect(() => { if (committed.current !== scope) { @@ -41,10 +109,18 @@ export function useListRun(scope: string, onChangeSelection: (selection: string[ }, [scope, onChangeSelection]) const [run] = useState(() => ({ onStart: () => { + watching.current() latest.current += 1 const id = latest.current + const startedIn = document.querySelector(DIALOG_IN_FOCUS) + const landing = watchLanding(startedIn) + watching.current = landing.stop return (moved) => { - if (latest.current === id && moved) { + landing.stop() + if (latest.current !== id || !moved) { + return + } + if (readerOnList(region.current, startedIn, landing.element)) { region.current?.focus() } } diff --git a/src/list-view.ts b/src/list-view.ts index 3ec460a..f112b7d 100644 --- a/src/list-view.ts +++ b/src/list-view.ts @@ -7,6 +7,8 @@ import type { Dispatch, SetStateAction } from 'react' import { DENSE_BREAKPOINT } from './breakpoints.js' import { useMediaQuery } from './use-media-query.js' +export { openOnTap } from './open-on-tap.js' + /** The direction the rows of a list are sorted in. */ export type ListSortDirection = 'asc' | 'desc' @@ -363,23 +365,3 @@ export function useListView(defaults: ListDefaults): Li ...selection, } } - -/** - * Returns the selection handler a list hands DataViews, opening the record a tap lands on in the phone layout. - * @param list - The list view the screen holds. - * @param open - Opens the record with the given id. - * @returns The handler. - */ -export function openOnTap( - list: ListView, - open: (id: string) => void, -): (selection: string[]) => void { - if (!list.phone) { - return list.onChangeSelection - } - return ([id]) => { - if (id !== undefined) { - open(id) - } - } -} diff --git a/src/open-on-tap.ts b/src/open-on-tap.ts new file mode 100644 index 0000000..4113090 --- /dev/null +++ b/src/open-on-tap.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 + +/** The part of a list view a tap reads, which `useListView` returns among the rest. */ +export interface TapList { + /** Whether the viewport takes the phone layout. */ + phone: boolean + /** Holds the rows ticked in the wide layout. */ + onChangeSelection: (selection: string[]) => void +} + +/** + * Returns the selection handler a list hands DataViews, opening the record a tap lands on in the phone layout. + * @param list - The list view the screen holds. + * @param open - Opens the record with the given id. + * @returns The handler. + */ +export function openOnTap(list: TapList, open: (id: string) => void): (selection: string[]) => void { + if (!list.phone) { + return list.onChangeSelection + } + return ([id]) => { + if (id !== undefined) { + open(id) + } + } +} diff --git a/src/run-confirm.tsx b/src/run-confirm.tsx index cb3a9e0..1178843 100644 --- a/src/run-confirm.tsx +++ b/src/run-confirm.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import type { ReactNode } from 'react' +import { useCloseWhileOpen } from './close-while-open.js' import { ConfirmBody } from './confirm.js' /** The props of a run confirm, the first two as a DataViews modal hands them. */ @@ -22,7 +23,7 @@ export interface RunConfirmProps { } /** - * Renders a confirmation that runs the action, keeps Cancel inert while it runs and closes once it settles. + * Renders a confirmation that runs the action, keeps Cancel inert while it runs and closes only itself once it settles. * @param props - The items, the close handler, the question, the labels and the run. * @returns The confirmation body. */ @@ -35,6 +36,7 @@ export function RunConfirm({ run, }: RunConfirmProps) { const [busy, setBusy] = useState(false) + const close = useCloseWhileOpen(closeModal) return ( ({ .then(() => run(items)) .finally(() => { setBusy(false) - closeModal?.() + close() }) }} > diff --git a/test/bulk-run.test.tsx b/test/bulk-run.test.tsx index 6491472..4730dbd 100644 --- a/test/bulk-run.test.tsx +++ b/test/bulk-run.test.tsx @@ -44,7 +44,7 @@ interface Bench { } /** - * Renders the rows of a list with a Trash button that runs one call per item. + * Renders a list with a Trash button inside it, where DataViews draws its bulk bar, that runs one call per item. * @param props - The bench the test set up. * @returns The page with its list. */ @@ -52,18 +52,11 @@ function TrashScreen({ bench }: { bench: Bench }) { const { run, region } = useListRun('post publish', () => {}) const bulk = useBulkRun(run, bench.refresh) return ( - bench.runs.push(bulk(bench.items, bench.call, bench.words))}> - Trash - - } - > +

Hello

+
) } diff --git a/test/close-while-open.test.tsx b/test/close-while-open.test.tsx new file mode 100644 index 0000000..f7d7fc3 --- /dev/null +++ b/test/close-while-open.test.tsx @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { renderHook } from '@testing-library/react' +import { StrictMode } from 'react' +import { expect, test, vi } from 'vitest' + +import { useCloseWhileOpen } from '../src/index' +import { installTestEnvironment } from '../src/testing.js' + +installTestEnvironment() + +test('closes the dialog while its body is on screen', () => { + const close = vi.fn() + const { result } = renderHook(() => useCloseWhileOpen(close)) + + result.current() + + expect(close).toHaveBeenCalledOnce() +}) + +test('closes nothing once its body left the screen, so a dialog opened since stays open', () => { + const close = vi.fn() + const { result, unmount } = renderHook(() => useCloseWhileOpen(close)) + const held = result.current + unmount() + + held() + + expect(close).not.toHaveBeenCalled() +}) + +test('closes the dialog while its body is on screen under strict mode, which mounts it twice', () => { + const close = vi.fn() + const { result } = renderHook(() => useCloseWhileOpen(close), { wrapper: StrictMode }) + + result.current() + + expect(close).toHaveBeenCalledOnce() +}) + +test('does nothing for a body drawn outside a dialog, handed no close', () => { + const { result } = renderHook(() => useCloseWhileOpen(undefined)) + + expect(() => result.current()).not.toThrow() +}) diff --git a/test/entries.test.ts b/test/entries.test.ts index 4bafca4..9110fea 100644 --- a/test/entries.test.ts +++ b/test/entries.test.ts @@ -5,10 +5,12 @@ import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' -import { expect, test } from 'vitest' +import { expect, expectTypeOf, test } from 'vitest' import manifest from '../package.json' import * as dialogs from '../src/dialogs.js' +import type * as MainEntry from '../src/index' +import type * as RouterEntry from '../src/router' /** The source folder the entries are built from. */ const source = resolve(import.meta.dirname, '../src') @@ -194,6 +196,34 @@ test('the main entry hands out the store of unsaved text, which reaches React al expect([...packagesReachedFrom(join(source, 'unsaved-text.ts'))]).toEqual(['react']) }) +test('the main entry hands out the tap handler of a phone list, which reaches no package, so no router', async () => { + const main = (await import(join(source, 'index.ts'))) as Record + + expect(typeof main.openOnTap).toBe('function') + expect([...packagesReachedFrom(join(source, 'open-on-tap.ts'))]).toEqual([]) +}) + +test.each([ + { file: 'close-while-open.ts', hooks: ['useCloseWhileOpen'] }, + { file: 'list-run.ts', hooks: ['useListRun', 'focusInDialog'] }, + { file: 'focus-back.ts', hooks: ['useFocusBack', 'focusedTrigger'] }, +])('the main entry hands out the dialog and focus hooks of $file, which reach React alone', async ({ file, hooks }) => { + const main = (await import(join(source, 'index.ts'))) as Record + const own = (await import(join(source, file))) as Record + + expect(hooks.map((name) => typeof main[name])).toEqual(hooks.map(() => 'function')) + expect(hooks.filter((name) => main[name] !== own[name])).toEqual([]) + expect([...packagesReachedFrom(join(source, file))]).toEqual(['react']) +}) + +test('the main entry hands out the list view types of the router entry, to type a list without the router', () => { + type Shape = RouterEntry.ListViewShape + + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf>() + expectTypeOf().toEqualTypeOf() +}) + test('the router peer starts at a release whose route matching runs no route context', () => { expect(manifest.peerDependencies['@tanstack/react-router']).toBe('^1.170.44') }) diff --git a/test/focus-back.test.tsx b/test/focus-back.test.tsx new file mode 100644 index 0000000..e89775d --- /dev/null +++ b/test/focus-back.test.tsx @@ -0,0 +1,686 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { act, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { DataViews } from '@wordpress/dataviews' +import type { Action, Field, View } from '@wordpress/dataviews' +import { useRef, useState } from 'react' +import { expect, onTestFinished, test, vi } from 'vitest' + +import { focusedTrigger, LoadingRows, useFocusBack } from '../src/index' +import type { Trigger } from '../src/index' +import { installTestEnvironment, renderAdmin } from '../src/testing.js' + +installTestEnvironment() + +/** An item a list shows. */ +interface Post { + /** The id DataViews keys the item by. */ + id: string + /** The title of the item. */ + title: string +} + +/** A layout DataViews draws a list in. */ +type Layout = 'grid' | 'list' | 'table' + +/** The items a list shows, in the order it opens on. */ +const POSTS: Post[] = [ + { id: '1', title: 'Hello' }, + { id: '2', title: 'Harbor at dawn' }, +] + +/** The fields of the list: the title marked with its item, and the media. */ +const FIELDS: Field[] = [ + { + id: 'title', + label: 'Title', + enableHiding: false, + getValue: ({ item }) => item.title, + render: ({ item }) => {item.title}, + }, + { id: 'media', label: 'Media', render: () => media }, +] + +/** + * Returns the one row action, offered from each row's actions menu and from the bulk bar for ticked rows. + * @param chosen - Told where the focus sat once the action was chosen. + * @returns The action. + */ +function rename(chosen: (trigger: Trigger) => void): Action { + return { id: 'rename', label: 'Rename', supportsBulk: true, callback: () => chosen(focusedTrigger()) } +} + +/** What a test reads back from the list and how it changes it. */ +interface Handle { + /** Asks for the focus back on the trigger a dialog on an item opened from. */ + focusBack: (id: string, trigger: Trigger) => void + /** The trigger each click on an item read from the focus. */ + clicked: Trigger[] +} + +/** What a list probe draws. */ +interface ListProps { + /** The layout DataViews draws. */ + layout: Layout + /** The items in the order the list draws them. */ + posts: Post[] + /** Whether the list reads its items again. */ + busy: boolean + /** Whether the list draws loading rows in place of DataViews while busy, so DataViews mounts again after. */ + loadingRows: boolean + /** Whether the list draws its region. */ + region: boolean + /** Whether the rows offer the row action. */ + withActions: boolean + /** What the test reads back. */ + handle: Handle +} + +/** + * Renders a list region around DataViews in one layout, its titles and media opening a dialog on a click. + * @param props - The layout, the items, whether the list is busy, how it draws then, its region and what to record. + * @returns The list. + */ +function List({ layout, posts, busy, loadingRows, region, withActions, handle }: ListProps) { + const list = useRef(null) + const [selection, setSelection] = useState([]) + handle.focusBack = useFocusBack(busy, list) + const view: View = { type: layout, fields: [], titleField: 'title', mediaField: 'media', page: 1, perPage: 10 } + const views = busy && loadingRows ? ( + + ) : ( + {}} + defaultLayouts={{ [layout]: {} }} + actions={withActions ? [rename((trigger) => handle.clicked.push(trigger))] : []} + paginationInfo={{ totalItems: posts.length, totalPages: 1 }} + getItemId={(item: Post) => item.id} + selection={selection} + onChangeSelection={setSelection} + isItemClickable={() => true} + onClickItem={() => handle.clicked.push(focusedTrigger())} + /> + ) + if (!region) { + return views + } + return ( +
+ {views} +
+ ) +} + +/** + * Renders the list in one layout. + * @param layout - The layout DataViews draws. + * @param props - The props beside the layout, the items in their opening order when absent. + * @returns What the list records, a rerender with other props and what takes the list off the page. + */ +function renderList(layout: Layout, props: Partial> = {}) { + const handle: Handle = { focusBack: () => {}, clicked: [] } + const drawn = { posts: POSTS, busy: false, loadingRows: false, region: true, withActions: true, ...props } + const { rerender, unmount } = renderAdmin() + return { + handle, + rerender: (next: Partial>) => + rerender(), + unmount, + } +} + +/** + * Returns the card or row each layout draws an item in. + * @param title - The title the item shows. + * @returns The card or row. + */ +function holderOf(title: string): HTMLElement { + const holders = '.dataviews-view-grid__card, .dataviews-view-list__item-wrapper, .dataviews-view-table__row' + return screen.getByText(title, { selector: '[data-item-id]' }).closest(holders) as HTMLElement +} + +/** + * Returns an element of the card or row holding an item. + * @param title - The title the item shows. + * @param selector - Finds the element inside the card or row. + * @returns The element. + */ +function partOf(title: string, selector: string): HTMLElement { + return holderOf(title).querySelector(selector) as HTMLElement +} + +/** Finds the actions menu of a card or row. */ +const MENU = '[aria-haspopup="menu"]' + +/** Finds the title a click opens a dialog from. */ +const TITLE = '.dataviews-title-field[role="button"]' + +/** Finds the media of a grid card a click opens a dialog from. */ +const GRID_MEDIA = '.dataviews-view-grid__media[role="button"]' + +/** Finds the media of a table row a click opens a dialog from. */ +const TABLE_MEDIA = '.dataviews-column-primary__media[role="button"]' + +/** Finds the row of a phone list a tap opens a dialog from. */ +const PHONE_ROW = '.dataviews-view-list__item' + +/** + * Drops the focus to the page body, as a browser does once a closed dialog's trigger left the page. + */ +function dropFocus(): void { + act(() => (document.activeElement as HTMLElement).blur()) +} + +/** + * Asks for the focus back with the focus on the page body, the items drawn in the reverse order, as a write moved them. + * @param layout - The layout DataViews draws. + * @param trigger - The trigger the dialog opened from. + */ +function askAfterMove(layout: Layout, trigger: Trigger): void { + const { handle, rerender } = renderList(layout) + rerender({ posts: [...POSTS].reverse() }) + dropFocus() + act(() => handle.focusBack('1', trigger)) +} + +/** + * Returns the region the list is drawn in. + * @returns The region. + */ +function region(): HTMLElement { + return screen.getByRole('region', { name: 'Posts' }) +} + +/** A grid width DataViews lays three cards across at its default preview size. */ +const ROW_OF_THREE = 800 + +/** A grid width DataViews lays one card across at its default preview size. */ +const ROW_OF_ONE = 400 + +/** + * Makes each grid DataViews mounts measure a width, and lets the test report another one, as a browser does. + * @param width - The width each grid measures as it mounts. + * @returns What reports another width to every grid on the page, as a resize after a layout does. + */ +function measureGrids(width: number): (next: number) => void { + let measured = width + const offset = vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockImplementation(() => measured) + const watched = new Map() + vi.stubGlobal( + 'ResizeObserver', + class { + /** What the page runs on a resize. */ + readonly callback: ResizeObserverCallback + + /** + * Holds what the page runs on a resize. + * @param callback - What the page runs on a resize. + */ + constructor(callback: ResizeObserverCallback) { + this.callback = callback + } + + /** + * Watches an element. + * @param element - The element. + */ + observe(element: Element) { + watched.set(element, this.callback) + } + + /** + * Stops watching an element. + * @param element - The element. + */ + unobserve(element: Element) { + watched.delete(element) + } + + /** Stops watching every element. */ + disconnect() {} + }, + ) + onTestFinished(() => { + offset.mockRestore() + vi.unstubAllGlobals() + }) + return (next) => { + measured = next + const grids = [...watched].filter(([element]) => element.isConnected) + const entry = (element: Element) => ({ target: element, borderBoxSize: [{ inlineSize: next, blockSize: 0 }] }) + act(() => grids.forEach(([element, resized]) => resized([entry(element)] as never, {} as ResizeObserver))) + } +} + +/** + * Lets the page answer the changes the last step drew, as a browser does before the reader acts again. + */ +async function settle(): Promise { + await act(async () => {}) +} + +/** + * Puts the focus back on the trigger of the item at the second place of a grid three cards wide. + * @param trigger - The trigger the dialog opened from. + * @returns What the list records, a rerender, what takes it off the page, lays it out again and counts its watches. + */ +async function focusBackInGrid(trigger: Trigger) { + const layOut = measureGrids(ROW_OF_THREE) + const list = renderList('grid', { posts: [...POSTS].reverse() }) + const live = liveWatches(region()) + act(() => list.handle.focusBack('1', trigger)) + await settle() + return { ...list, layOut, live } +} + +/** + * Counts what the page keeps watching from now on: observers of the list and focus and press listeners on the page. + * @param list - The list region. + * @returns What counts the observers of the list and the listeners still live, each in that order. + */ +function liveWatches(list: HTMLElement): () => [number, number] { + const observed = vi.spyOn(MutationObserver.prototype, 'observe') + const disconnected = vi.spyOn(MutationObserver.prototype, 'disconnect') + const added = vi.spyOn(document, 'addEventListener') + const removed = vi.spyOn(document, 'removeEventListener') + onTestFinished(() => [observed, disconnected, added, removed].forEach((spy) => spy.mockRestore())) + const watching = (type: unknown) => type === 'focusin' || type === 'pointerdown' + const gone = (type: unknown, listener: unknown, options: unknown) => + removed.mock.calls.some((call) => call[0] === type && call[1] === listener && call[2] === options) + return () => [ + observed.mock.contexts.filter( + (observer, call) => observed.mock.calls[call][0] === list && !disconnected.mock.contexts.includes(observer), + ).length, + added.mock.calls.filter(([type, listener, options]) => watching(type) && !gone(type, listener, options)).length, + ] +} + +test.each([ + { trigger: 'title' as const, selector: TITLE }, + { trigger: 'media' as const, selector: GRID_MEDIA }, + { trigger: 'actions' as const, selector: MENU }, +])('puts the focus back on the $trigger of a grid card where the card moved', ({ trigger, selector }) => { + askAfterMove('grid', trigger) + + expect(document.activeElement).toBe(partOf('Hello', selector)) + expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(holderOf('Hello')) +}) + +test.each([ + { trigger: 'title' as const, selector: TITLE }, + { trigger: 'media' as const, selector: TABLE_MEDIA }, + { trigger: 'actions' as const, selector: MENU }, +])('puts the focus back on the $trigger of a table row where the row moved', ({ trigger, selector }) => { + askAfterMove('table', trigger) + + expect(document.activeElement).toBe(partOf('Hello', selector)) + expect(document.querySelectorAll('tbody tr.dataviews-view-table__row')[1]).toBe(holderOf('Hello')) +}) + +test.each([ + { trigger: 'row' as const, selector: PHONE_ROW }, + { trigger: 'actions' as const, selector: MENU }, +])('puts the focus back on the $trigger of a phone list row where the row moved', ({ trigger, selector }) => { + askAfterMove('list', trigger) + + expect(document.activeElement).toBe(partOf('Hello', selector)) + expect(document.querySelectorAll('.dataviews-view-list__item-wrapper')[1]).toBe(holderOf('Hello')) +}) + +test.each([ + { layout: 'grid' as const, trigger: 'row' as const }, + { layout: 'table' as const, trigger: 'row' as const }, + { layout: 'list' as const, trigger: 'title' as const }, + { layout: 'list' as const, trigger: 'media' as const }, +])('falls back to the actions menu once the $layout draws no $trigger', ({ layout, trigger }) => { + askAfterMove(layout, trigger) + + expect(document.activeElement).toBe(partOf('Hello', MENU)) +}) + +test.each(['grid', 'list', 'table'] as const)( + 'puts the focus on the %s list once the item left it', + (layout) => { + const { handle, rerender } = renderList(layout) + rerender({ posts: POSTS.slice(1) }) + dropFocus() + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(region()) + }, +) + +test.each(['grid', 'list', 'table'] as const)( + 'puts the focus on the %s list once the row draws neither the trigger nor an actions menu to fall back to', + (layout) => { + const { handle } = renderList(layout, { withActions: false }) + dropFocus() + + act(() => handle.focusBack('1', 'actions')) + + expect(document.activeElement).toBe(region()) + }, +) + +test.each(['grid', 'list', 'table'] as const)( + 'puts the focus on the %s list after a bulk action, the bar gone with the selection', + (layout) => { + const { handle } = renderList(layout) + dropFocus() + + act(() => handle.focusBack('1', 'bulk')) + + expect(document.activeElement).toBe(region()) + }, +) + +test('puts the focus on the list when the mark sits outside every card and row', () => { + const handle: Handle = { focusBack: () => {}, clicked: [] } + /** + * Renders a list region holding a marked item outside DataViews. + * @returns The region. + */ + function Loose() { + const list = useRef(null) + handle.focusBack = useFocusBack(false, list) + return ( +
+ +
+ ) + } + renderAdmin() + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(region()) +}) + +test('finds the item inside its own list, never a cell marked with its id elsewhere on the page', () => { + const handle: Handle = { focusBack: () => {}, clicked: [] } + renderAdmin( + <> + Details + + , + ) + dropFocus() + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('waits for the list to read its items again before it puts the focus back', () => { + const { handle, rerender } = renderList('table', { busy: true }) + dropFocus() + act(() => handle.focusBack('1', 'title')) + expect(document.activeElement).toBe(document.body) + + rerender({ busy: false }) + + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('leaves a focus the page kept where it is', () => { + const { handle } = renderList('table') + const other = partOf('Harbor at dawn', TITLE) + act(() => other.focus()) + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(other) +}) + +test('puts the focus back each time it is asked, even for the same trigger', () => { + const { handle } = renderList('table') + dropFocus() + act(() => handle.focusBack('1', 'title')) + dropFocus() + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('puts the focus back once only, a later render of the list leaving it alone', () => { + const { handle, rerender } = renderList('table') + dropFocus() + act(() => handle.focusBack('1', 'title')) + dropFocus() + + rerender({ busy: true }) + rerender({ busy: false }) + + expect(document.activeElement).toBe(document.body) +}) + +test('puts no focus anywhere while the list draws no region, and throws nothing', () => { + const { handle } = renderList('table', { region: false }) + dropFocus() + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(document.body) +}) + +test('drops an ask made while the list drew no region, so a region drawn later takes no focus', () => { + const { handle, rerender } = renderList('table', { region: false }) + dropFocus() + act(() => handle.focusBack('1', 'title')) + + rerender({ region: true, busy: true }) + rerender({ region: true, busy: false }) + + expect(document.activeElement).toBe(document.body) +}) + +test.each([ + { trigger: 'title' as const, selector: TITLE }, + { trigger: 'media' as const, selector: GRID_MEDIA }, + { trigger: 'actions' as const, selector: MENU }, +])('keeps the focus on the $trigger of a grid card the grid drew anew as it measured its columns', async (each) => { + measureGrids(ROW_OF_THREE) + const { handle, rerender } = renderList('grid', { posts: [...POSTS].reverse(), busy: true, loadingRows: true }) + act(() => handle.focusBack('1', each.trigger)) + + rerender({ busy: false }) + await settle() + + expect(document.activeElement).toBe(partOf('Hello', each.selector)) +}) + +test('keeps the focus on the title of a grid card the grid draws anew in another row at another width', async () => { + const { layOut } = await focusBackInGrid('title') + + layOut(ROW_OF_ONE) + await settle() + + expect(holderOf('Hello').closest('[role="row"]')?.getAttribute('aria-rowindex')).toBe('2') + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('keeps the focus on the title of a grid card through each layout that draws the card anew', async () => { + const { layOut } = await focusBackInGrid('title') + layOut(ROW_OF_ONE) + await settle() + + layOut(ROW_OF_THREE) + await settle() + + expect(holderOf('Hello').closest('[role="row"]')?.getAttribute('aria-rowindex')).toBe('1') + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('watches nothing once the focus went to the list, which no layout of the list redraws', async () => { + measureGrids(ROW_OF_THREE) + const { handle, rerender } = renderList('grid', { posts: [...POSTS].reverse() }) + const live = liveWatches(region()) + rerender({ posts: POSTS.slice(1) }) + + act(() => handle.focusBack('1', 'title')) + + expect(document.activeElement).toBe(region()) + expect(live()).toEqual([0, 0]) +}) + +test('keeps the focus on the title after the reader pressed on that title', async () => { + const { layOut } = await focusBackInGrid('title') + await userEvent.click(screen.getByText('Hello', { selector: '[data-item-id]' })) + + layOut(ROW_OF_ONE) + await settle() + + expect(document.activeElement).toBe(partOf('Hello', TITLE)) +}) + +test('stops putting the focus back once the reader moved it, even to the same card', async () => { + const { layOut, live } = await focusBackInGrid('title') + act(() => partOf('Hello', MENU).focus()) + + layOut(ROW_OF_ONE) + await settle() + + expect(document.activeElement).toBe(document.body) + expect(live()).toEqual([0, 0]) +}) + +test('stops putting the focus back once the reader pressed elsewhere on the page', async () => { + const { layOut, live } = await focusBackInGrid('title') + await userEvent.click(document.body) + + layOut(ROW_OF_ONE) + await settle() + + expect(document.activeElement).toBe(document.body) + expect(live()).toEqual([0, 0]) +}) + +test('stops putting the focus back on an item once the focus is asked back for another', async () => { + const { handle, rerender, layOut, live } = await focusBackInGrid('title') + rerender({ busy: true }) + act(() => handle.focusBack('2', 'title')) + + layOut(ROW_OF_ONE) + await settle() + + expect(document.activeElement).toBe(document.body) + expect(live()).toEqual([0, 0]) +}) + +test('stops putting the focus back once the item left the list, even when it comes back', async () => { + const { rerender, live } = await focusBackInGrid('title') + rerender({ posts: POSTS.slice(1) }) + await settle() + const left = live() + + rerender({ posts: [...POSTS].reverse() }) + await settle() + + expect(document.activeElement).toBe(region()) + expect(left).toEqual([0, 0]) +}) + +test.each([ + { layout: 'grid' as const, trigger: 'title' as const }, + { layout: 'list' as const, trigger: 'row' as const }, + { layout: 'table' as const, trigger: 'title' as const }, +])('puts the focus on the list once the item left the $layout while its $trigger kept the focus', async (each) => { + const { handle, rerender } = renderList(each.layout) + const live = liveWatches(region()) + dropFocus() + act(() => handle.focusBack('1', each.trigger)) + const kept = live() + + rerender({ posts: POSTS.slice(1) }) + await settle() + + expect(document.activeElement).toBe(region()) + expect([kept, live()]).toEqual([ + [1, 2], + [0, 0], + ]) +}) + +test('leaves the focus on a control the reader moved to when the item then left the list', async () => { + const { rerender } = await focusBackInGrid('title') + const other = partOf('Harbor at dawn', TITLE) + act(() => other.focus()) + + rerender({ posts: POSTS.slice(1) }) + await settle() + + expect(document.activeElement).toBe(other) +}) + +test('stops watching the list and the focus once the screen leaves', async () => { + const { unmount, live } = await focusBackInGrid('title') + const kept = live() + + unmount() + + expect([kept, live()]).toEqual([ + [1, 2], + [0, 0], + ]) +}) + +test('puts the focus back again only once its trigger leaves the page, not on other changes to the list', async () => { + const { rerender } = await focusBackInGrid('title') + dropFocus() + + rerender({ posts: [...POSTS, { id: '3', title: 'Lighthouse' }].reverse() }) + await settle() + + expect(document.activeElement).toBe(document.body) +}) + +test.each([ + { layout: 'grid' as const, selector: TITLE, trigger: 'title' }, + { layout: 'grid' as const, selector: GRID_MEDIA, trigger: 'media' }, + { layout: 'table' as const, selector: TITLE, trigger: 'title' }, + { layout: 'table' as const, selector: TABLE_MEDIA, trigger: 'media' }, +])('reads a click on the $trigger of a $layout item as that trigger', async ({ layout, selector, trigger }) => { + const { handle } = renderList(layout) + + await userEvent.click(partOf('Hello', selector)) + + expect(handle.clicked).toEqual([trigger]) +}) + +test('reads a focus on a phone list row as the row', () => { + renderList('list') + + act(() => partOf('Hello', PHONE_ROW).focus()) + + expect(focusedTrigger()).toBe('row') +}) + +test.each(['grid', 'table'] as const)('reads an action chosen in the bulk bar of a %s as the bar', async (layout) => { + const { handle } = renderList(layout) + await userEvent.click(within(holderOf('Hello')).getByRole('checkbox')) + const bar = document.querySelector('.dataviews-bulk-actions-footer__container') as HTMLElement + + await userEvent.click(within(bar).getByRole('button', { name: 'Rename' })) + + expect(handle.clicked).toEqual(['bulk']) +}) + +test.each(['grid', 'list', 'table'] as const)( + 'reads an action chosen in the actions menu of a %s item as the actions menu', + async (layout) => { + const { handle } = renderList(layout) + await userEvent.click(within(holderOf('Hello')).getByRole('button', { name: 'Actions' })) + + await userEvent.click(await screen.findByRole('menuitem', { name: 'Rename' })) + + expect(handle.clicked).toEqual(['actions']) + }, +) diff --git a/test/list-run.test.tsx b/test/list-run.test.tsx index 42e810d..781c4db 100644 --- a/test/list-run.test.tsx +++ b/test/list-run.test.tsx @@ -1,10 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 -import { act, fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen, waitFor } from '@testing-library/react' import { useState } from 'react' import { expect, onTestFinished, test, vi } from 'vitest' -import { Page, useListRun } from '../src/index' +import { ConfirmDialog } from '../src/dialogs' +import { focusInDialog, Page, useListRun } from '../src/index' import type { ListRun, ListRunState, RunSettled } from '../src/index' import { installTestEnvironment, renderAdmin } from '../src/testing.js' @@ -23,18 +24,20 @@ interface Seen { } /** - * Renders a list screen around one list run, with a button that starts a run. - * @param props - The list on show, whether it draws its region, the spy on the selection setter and what to record. - * @returns The page with its list region. + * Renders a list screen whose runs start in the list, a confirm or an alert dialog, beside Add Post and an Edit form. + * @param props - The list on show, whether it draws its region and the confirm, the selection spy and what to record. + * @returns The page with its list region and the dialogs. */ function ListScreen({ scope, region = true, + asking = true, onTicks, seen, }: { scope: string region?: boolean + asking?: boolean onTicks: (selection: string[]) => void seen: Seen }) { @@ -47,20 +50,37 @@ function ListScreen({ seen.states.push(state) seen.runs.push(run) seen.ticked.push(ticked) + const startRun = () => seen.settles.push(run.onStart()) return ( - seen.settles.push(run.onStart())}> + <> + Add Post} + > + - } - > -

Hello

-
+
+ {asking ? ( +
+ + +
+ ) : null} +
+ +
+
+ +
+ ) } @@ -68,7 +88,7 @@ function ListScreen({ * Renders the list screen on the given list. * @param scope - The list on show. * @param region - Whether the screen draws its list region. - * @returns What the screen recorded, the selection spy and a rerender onto another list. + * @returns What the screen recorded, the selection spy, a rerender onto another list and one that closes the confirm. */ function renderList(scope: string, region = true) { const seen: Seen = { settles: [], runs: [], states: [], ticked: [] } @@ -79,16 +99,59 @@ function renderList(scope: string, region = true) { onTicks, unmount, show: (next: string) => rerender(), + closeConfirm: () => + rerender(), } } /** - * Starts a run from the Trash button, focused first as a pointer press leaves it. + * Spies on the focusin listeners the document gains from here on. + * @returns What counts those listeners the document still holds. + */ +function focusinListeners(): () => number { + const added = vi.spyOn(document, 'addEventListener') + const removed = vi.spyOn(document, 'removeEventListener') + onTestFinished(() => { + added.mockRestore() + removed.mockRestore() + }) + const focusin = (calls: unknown[][]) => calls.filter(([type]) => type === 'focusin') + const gone = (listener: unknown, options: unknown) => + focusin(removed.mock.calls).some(([, each, eachOptions]) => each === listener && eachOptions === options) + return () => focusin(added.mock.calls).filter(([, listener, options]) => !gone(listener, options)).length +} + +/** + * Returns the named control, the Title field or a button. + * @param name - The accessible name of the control. + * @returns The control. + */ +function control(name: string): HTMLElement { + return screen.getByRole(name === 'Title' ? 'textbox' : 'button', { name }) +} + +/** + * Focuses the named control, as a reader moving to it does. + * @param name - The accessible name of the control. + */ +function focusOn(name: string): void { + act(() => control(name).focus()) +} + +/** + * Drops the focus to the page body, as a browser does once the focused row left the list. + */ +function dropFocus(): void { + act(() => (document.activeElement as HTMLElement).blur()) +} + +/** + * Starts a run from the named button, focused first as a pointer press leaves it. + * @param name - The button, the Trash button inside the list when none is named. */ -function start(): void { - const trash = screen.getByRole('button', { name: 'Trash' }) - trash.focus() - fireEvent.click(trash) +function start(name = 'Trash'): void { + focusOn(name) + fireEvent.click(control(name)) } /** @@ -100,6 +163,14 @@ function settle(settle: RunSettled, moved: boolean): void { act(() => settle(moved)) } +/** + * Returns the list region a finished run focuses. + * @returns The region named Posts. + */ +function listRegion(): HTMLElement { + return screen.getByRole('region', { name: 'Posts' }) +} + test('holds no failure above the list, since a run shows its failure in a toast', () => { const { seen } = renderList('post publish') start() @@ -110,13 +181,272 @@ test('holds no failure above the list, since a run shows its failure in a toast' expect(screen.queryByRole('alert')).toBeNull() }) -test('focuses the list region once a run moved an item', () => { +test('focuses the list region once a run moved an item while the focus sat inside the list', () => { + const { seen } = renderList('post publish') + start() + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(listRegion()) +}) + +test('focuses the list region once a run moved an item and the focus fell to the page', () => { + const { seen } = renderList('post publish') + start() + dropFocus() + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(listRegion()) +}) + +test('focuses the list region once a run moved an item while the confirm it started in still holds the focus', () => { + const { seen } = renderList('post publish') + start('Move to trash') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(listRegion()) +}) + +test('focuses the list region once a run started in an alert dialog that still holds the focus moved an item', () => { + const { seen } = renderList('post publish') + start('Delete permanently') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(listRegion()) +}) + +test('leaves the focus in a dialog opened during a run that started outside every dialog', () => { const { seen } = renderList('post publish') start() + focusOn('Title') settle(seen.settles[0], true) - expect(document.activeElement).toBe(screen.getByRole('region', { name: 'Posts' })) + expect(document.activeElement).toBe(control('Title')) +}) + +test('leaves the focus in a dialog opened during a run that started in another dialog', () => { + const { seen } = renderList('post publish') + start('Move to trash') + focusOn('Title') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Title')) +}) + +test('leaves the focus in an alert dialog opened during a run', () => { + const { seen } = renderList('post publish') + start() + focusOn('Delete permanently') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Delete permanently')) +}) + +test('leaves the focus on a control outside the list the reader moved to during a run', () => { + const { seen } = renderList('post publish') + start() + focusOn('Add Post') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Add Post')) +}) + +test('leaves the focus on a control outside the list the reader moved to while the run dialog stays open', () => { + const { seen } = renderList('post publish') + start('Move to trash') + focusOn('Add Post') + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Add Post')) +}) + +/** Keeps the ticked rows as they are, since the trash screen ticks none. */ +const keepTicks = () => {} + +/** + * Renders a trash list whose Empty Trash opens a confirm that starts a run and closes at once, by Add Post and Edit. + * @param props - Whether Empty Trash is on the page and where to record the settle of each run started. + * @returns The page with its list region, the confirm while asked and the Edit form. + */ +function TrashScreen({ opener, settles }: { opener: boolean; settles: RunSettled[] }) { + const [asking, setAsking] = useState(false) + const { run, region } = useListRun('post trash', keepTicks) + const emptyTrash = () => { + settles.push(run.onStart()) + } + return ( + <> + + {opener ? ( + + ) : null} + + + } + > + + + {asking ? ( + setAsking(false)} + > + Empty the trash? + + ) : null} +
+ +
+ + ) +} + +/** + * Empties the trash through a confirm that closes at once and gives the focus back to Empty Trash while it stays. + * @param openerLeaves - Whether Empty Trash leaves the page while the confirm is open, so the focus falls to the body. + * @returns The settle of each run started and a rerender that takes Empty Trash off the page. + */ +async function emptyTrash(openerLeaves = false) { + const settles: RunSettled[] = [] + const { rerender } = renderAdmin() + const dropOpener = () => rerender() + start('Empty Trash') + await screen.findByRole('button', { name: 'Empty trash' }) + if (openerLeaves) { + dropOpener() + } + start('Empty trash') + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Empty the trash?' })).toBeNull()) + return { settles, dropOpener } +} + +test('focuses the list region once a run moved an item after its confirm closed at once', async () => { + const { settles, dropOpener } = await emptyTrash() + expect(document.activeElement).toBe(control('Empty Trash')) + + settle(settles[0], true) + + expect(document.activeElement).toBe(listRegion()) + dropOpener() + expect(document.activeElement).toBe(listRegion()) +}) + +test('leaves the focus in a dialog opened after the confirm the run started in closed', async () => { + const { settles } = await emptyTrash() + focusOn('Title') + + settle(settles[0], true) + + expect(document.activeElement).toBe(control('Title')) +}) + +test('leaves the focus on a control outside the list the reader moved to after a confirm closed at once', async () => { + const { settles } = await emptyTrash() + expect(document.activeElement).toBe(control('Empty Trash')) + focusOn('Add Post') + + settle(settles[0], true) + + expect(document.activeElement).toBe(control('Add Post')) +}) + +test('leaves the focus on a control the reader moved to after a confirm closed with its opener gone', async () => { + const { settles } = await emptyTrash(true) + expect(document.activeElement).toBe(document.body) + focusOn('Add Post') + + settle(settles[0], true) + + expect(document.activeElement).toBe(control('Add Post')) +}) + +test('focuses the list region once the dialog a run started in gave the focus back, after a move inside it', () => { + const { seen, closeConfirm } = renderList('post publish') + start('Move to trash') + focusOn('Cancel') + focusOn('Add Post') + closeConfirm() + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(listRegion()) +}) + +test('leaves the focus in a dialog the reader moved to before the dialog the run started in closed', () => { + const { seen, closeConfirm } = renderList('post publish') + start('Move to trash') + focusOn('Title') + closeConfirm() + + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Title')) +}) + +test('watches the focus only for a run started in a dialog, and only until that run settles', () => { + const { seen } = renderList('post publish') + const live = focusinListeners() + start() + const outside = live() + settle(seen.settles[0], true) + start('Move to trash') + const inDialog = live() + + settle(seen.settles[1], true) + + expect([outside, inDialog, live()]).toEqual([0, 1, 0]) +}) + +test('stops watching the focus for a run once a newer run starts', () => { + const { seen } = renderList('post publish') + const live = focusinListeners() + start('Move to trash') + start('Move to trash') + const both = live() + + settle(seen.settles[1], false) + + expect([both, live()]).toEqual([1, 0]) +}) + +test('stops watching the focus for a run once another list is shown', () => { + const { show } = renderList('post publish') + const live = focusinListeners() + start('Move to trash') + const during = live() + + show('post trash') + + expect([during, live()]).toEqual([1, 0]) +}) + +test('stops watching the focus for a run once the screen goes', () => { + const { unmount } = renderList('post publish') + const live = focusinListeners() + start('Move to trash') + const during = live() + + unmount() + + expect([during, live()]).toEqual([1, 0]) }) test('leaves the focus alone when a run moved nothing', () => { @@ -125,18 +455,30 @@ test('leaves the focus alone when a run moved nothing', () => { settle(seen.settles[0], false) - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Trash' })) + expect(document.activeElement).toBe(control('Trash')) +}) + +test('ignores a run that is no longer the latest, though the focus sits inside the list', () => { + const { seen } = renderList('post publish') + start() + start() + + settle(seen.settles[1], false) + settle(seen.settles[0], true) + + expect(document.activeElement).toBe(control('Trash')) }) -test('ignores a run that is no longer the latest', () => { +test('ignores a run that is no longer the latest, though the focus fell to the page', () => { const { seen } = renderList('post publish') start() start() + dropFocus() settle(seen.settles[1], false) settle(seen.settles[0], true) - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Trash' })) + expect(document.activeElement).toBe(document.body) }) test('ignores a run started before the list changed, and leaves the focus alone', () => { @@ -146,7 +488,7 @@ test('ignores a run started before the list changed, and leaves the focus alone' settle(seen.settles[0], true) - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Trash' })) + expect(document.activeElement).toBe(control('Trash')) }) test('clears the ticked rows once another list is shown, and only then', () => { @@ -213,14 +555,28 @@ test('ignores a run started before the list changed, even once the first list is settle(seen.settles[0], true) - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Trash' })) + expect(document.activeElement).toBe(control('Trash')) }) test('settles a run on a screen that draws no list region, with nothing to focus', () => { const { seen } = renderList('post publish', false) start() + dropFocus() settle(seen.settles[0], true) - expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Trash' })) + expect(document.activeElement).toBe(document.body) +}) + +test('reports a dialog holding the focus, and no dialog while the focus sits on the page or in the list', () => { + renderList('post publish') + const held: boolean[] = [focusInDialog()] + focusOn('Trash') + held.push(focusInDialog()) + focusOn('Title') + held.push(focusInDialog()) + focusOn('Delete permanently') + held.push(focusInDialog()) + + expect(held).toEqual([false, false, true, true]) }) diff --git a/test/open-on-tap.test.ts b/test/open-on-tap.test.ts new file mode 100644 index 0000000..63f7f96 --- /dev/null +++ b/test/open-on-tap.test.ts @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { expect, test } from 'vitest' + +import { openOnTap } from '../src/index' +import type { TapList } from '../src/index' +import { openOnTap as routerOpenOnTap } from '../src/router.js' + +/** + * Builds the part of a list a tap reads, on a phone or a wide viewport, its ticks held by the given handler. + * @param phone - Whether the viewport takes the phone layout. + * @param onChangeSelection - The handler holding the ticks. + * @returns The list. + */ +function listOn(phone: boolean, onChangeSelection: (selection: string[]) => void): TapList { + return { phone, onChangeSelection } +} + +test('a tap in the list a phone shows opens the record it lands on, with no router in reach', () => { + const ticked: string[][] = [] + const opened: string[] = [] + + openOnTap(listOn(true, (selection) => ticked.push(selection)), (id) => opened.push(id))(['7']) + + expect({ ticked, opened }).toEqual({ ticked: [], opened: ['7'] }) +}) + +test('a tap on a phone that lands on no row opens nothing', () => { + const opened: string[] = [] + + openOnTap(listOn(true, () => {}), (id) => opened.push(id))([]) + + expect(opened).toEqual([]) +}) + +test('a tick in a wide layout stays a selection and opens nothing', () => { + const ticked: string[][] = [] + const opened: string[] = [] + + openOnTap(listOn(false, (selection) => ticked.push(selection)), (id) => opened.push(id))(['7']) + + expect({ ticked, opened }).toEqual({ ticked: [['7']], opened: [] }) +}) + +test('the router entry hands out the same tap handler as the main entry', () => { + expect(routerOpenOnTap).toBe(openOnTap) +}) diff --git a/test/run-confirm.test.tsx b/test/run-confirm.test.tsx index 1c7acb8..bb481a5 100644 --- a/test/run-confirm.test.tsx +++ b/test/run-confirm.test.tsx @@ -2,6 +2,7 @@ import { act, fireEvent, screen, waitFor } from '@testing-library/react' import type { ActionModal } from '@wordpress/dataviews' +import { useState } from 'react' import { expect, onTestFinished, test, vi } from 'vitest' import { RunConfirm } from '../src/index' @@ -201,6 +202,41 @@ test('runs the action when it was handed no way to close, as a body drawn outsid expect(run).toHaveBeenCalledExactlyOnceWith([HELLO]) }) +/** The dialog a row holds open, as DataViews keeps one dialog per row. */ +type RowDialog = 'confirm' | 'edit' | undefined + +/** + * Renders a row that holds one dialog at a time and closes it with the one handler DataViews hands every dialog. + * @param props - The run the confirm starts and the handle the test opens a dialog through. + * @returns The dialog the row holds. + */ +function Row({ run, opener }: { run: () => Promise, opener: { open: (dialog: RowDialog) => void } }) { + const [open, setOpen] = useState('confirm') + opener.open = setOpen + const closeModal = () => setOpen(undefined) + if (open === 'confirm') { + return ( + + Move "Hello" to the trash? + + ) + } + return open === 'edit' ?
: null +} + +test('closes only itself once its run ends, leaving open a dialog opened on the row after its dismissal', async () => { + const held = heldRun() + const opener = { open: (_dialog: RowDialog) => {} } + renderAdmin() + await press('Trash') + + act(() => opener.open(undefined)) + act(() => opener.open('edit')) + await act(async () => held.settle()) + + expect(screen.getByRole('dialog', { name: 'Edit' })).not.toBeNull() +}) + test('takes the props a DataViews action modal hands over', async () => { const run = vi.fn(async () => undefined) const closeModal = vi.fn()