diff --git a/docs/src/content/docs/extending/the-plugin-sdk.md b/docs/src/content/docs/extending/the-plugin-sdk.md index 85634f81..c9403cb4 100644 --- a/docs/src/content/docs/extending/the-plugin-sdk.md +++ b/docs/src/content/docs/extending/the-plugin-sdk.md @@ -674,6 +674,93 @@ does. A copy, or a change to several rows, keeps the modal open with `busy` until the write ends. Taking all of these from the SDK keeps your plugin on the admin's own copy. +A modal that stays open during its write, such as a `ConfirmBody` +you run yourself or a busy `FormBody`, closes through +`useCloseWhileOpen(closeModal)`. Every dialog on a row gets the same +`closeModal`, so a write that ends after the reader dismissed your +modal and opened another one would shut the new one. The close +`useCloseWhileOpen` returns does nothing once your modal left the +screen. A dialog your screen holds open on one row loses the button +that opened it when the list draws that row again, and the focus +falls to the page. `useFocusBack` finds the row again and puts the +focus back on the `Trigger` that `focusedTrigger()` noted when the +dialog opened. Mark the title cell of each row with the id DataViews +keys the row by, as `data-item-id`. `focusInDialog()` tells whether a +dialog holds the focus, for a write you finish yourself outside a run. + +A rename, or a form that closes before its write, is gone by the time +a write fails. Keep what the reader typed, so the next open of the +modal starts from it and the reader can try once more. +`useUnsavedText` from the SDK holds that text, by item id, for as long +as the screen stays. Keep the store in the hook that builds your +actions, never in the modal, which goes the moment the reader saves. +Pass the item's kept text as the first value of the field. Clear the +item's text before each write, so a save that works leaves nothing +behind. `markLatest` marks the write as the latest one sent to the +item and returns a check. Keep the failed text only while that check +holds, so an earlier save that fails late never overwrites what a +later save sent: + +```tsx +import { RenameBody, markLatest, useToaster, useUnsavedText } from '@gophenberg/frontend-sdk' +import type { Action } from '@gophenberg/frontend-sdk/dataviews' +import { useMemo } from 'react' + +function useRenameAction(): Action { + const toaster = useToaster() + const renames = useUnsavedText() + return useMemo>( + () => ({ + id: 'rename', + label: 'Rename…', + modalHeader: 'Rename', + modalFocusOnMount: 'firstContentElement', + RenderModal: ({ items: [note], closeModal }) => ( + { + closeModal?.() + renames.delete(note.id) + const stillLatest = markLatest(renames, note.id) + renameNote(note.id, title) + .then(() => toaster.show('Name updated.')) + .catch((failure) => { + if (stillLatest()) { + renames.set(note.id, title) + } + toaster.show(failure.message) + }) + }} + onCancel={closeModal} + /> + ), + }), + [toaster, renames], + ) +} +``` + +The store holds any value, so a form with several fields keeps them +all, as `useUnsavedText()`. Type a store you pass on with +`UnsavedText`, from the SDK too. + +`ListEmpty` is what your list shows when it has no rows. It titles +the empty list one level under your page's title, as the admin's own +lists do. Pass it to DataViews as `empty`, with an icon, a title and +an optional hint: + +```tsx +import { ListEmpty, postIcon } from '@gophenberg/frontend-sdk' +import { DataViews } from '@gophenberg/frontend-sdk/dataviews' + +} /> +``` + +Leave the hint out when a search or a filter emptied the list. + ## Versions Each version below says what it adds and what it changes. When a diff --git a/frontend/package.json b/frontend/package.json index 22d92ad6..21465a53 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ }, "dependencies": { "@gophenberg/frontend-sdk": "workspace:*", - "@gopherium/godmin": "0.26.0", + "@gopherium/godmin": "0.27.0", "@gopherium/gottext": "0.7.0", "@gopherium/react-auth": "0.15.0", "@tanstack/react-query": "^5.102.8", diff --git a/frontend/src/content/NameModals.tsx b/frontend/src/content/NameModals.tsx index c1c4541b..31c5f6d3 100644 --- a/frontend/src/content/NameModals.tsx +++ b/frontend/src/content/NameModals.tsx @@ -2,16 +2,14 @@ import { InputControl } from '@gophenberg/frontend-sdk' import type { RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { FormBody, useToaster } from '@gopherium/godmin' +import { FormBody, markLatest, useCloseWhileOpen, useToaster } from '@gopherium/godmin' +import type { UnsavedText } from '@gopherium/godmin' import { useQueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query' import { __, _x, sprintf } from '@wordpress/i18n' import { useState } from 'react' import { RenameModal } from '../lists/RenameModal' -import { useCloseWhileOpen } from '../lists/closeWhileOpen' -import { markLatest } from '../lists/unsavedText' -import type { UnsavedText } from '../lists/unsavedText' import { useRefresh } from './actions' import { createPost, fetchPost, renamePost } from './api' import type { Post } from './api' diff --git a/frontend/src/content/TypeModals.tsx b/frontend/src/content/TypeModals.tsx index 693781bb..7e34ffcd 100644 --- a/frontend/src/content/TypeModals.tsx +++ b/frontend/src/content/TypeModals.tsx @@ -2,15 +2,13 @@ import { InputControl, Text } from '@gophenberg/frontend-sdk' import type { RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { FormBody, RunConfirm, useToaster } from '@gopherium/godmin' +import { FormBody, RunConfirm, markLatest, useCloseWhileOpen, useToaster } from '@gopherium/godmin' +import type { UnsavedText } from '@gopherium/godmin' import { FormDialog } from '@gopherium/godmin/dialogs' import { useQueryClient } from '@tanstack/react-query' import { __, _x, sprintf } from '@wordpress/i18n' import { useState } from 'react' -import { useCloseWhileOpen } from '../lists/closeWhileOpen' -import { markLatest } from '../lists/unsavedText' -import type { UnsavedText } from '../lists/unsavedText' import { typesQueryKey } from './nav' import { pluralOf } from './typeText' import { createType, slugifyKey } from './types' @@ -156,7 +154,6 @@ export function MakeDefaultModal({ }: RenderModalProps & { runOnList: RunOnList }) { const client = useQueryClient() const toaster = useToaster() - const close = useCloseWhileOpen(closeModal) const [holder] = useState(() => client.getQueryData(typesQueryKey)?.find((held) => held.isDefault)) const [type] = items const handOver = () => @@ -170,7 +167,7 @@ export function MakeDefaultModal({ return ( runOnList(items, handOver)} diff --git a/frontend/src/content/TypesScreen.tsx b/frontend/src/content/TypesScreen.tsx index 01d5be7a..25286a71 100644 --- a/frontend/src/content/TypesScreen.tsx +++ b/frontend/src/content/TypesScreen.tsx @@ -12,7 +12,6 @@ import type { UseQueryResult } from '@tanstack/react-query' import { __, sprintf } from '@wordpress/i18n' import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { useFocusOnList } from '../lists/focusOnList' import type { ListSettings } from '../settings/api' import { useListSettings } from '../settings/useListSettings' import { typesMenuItem, typesQueryKey } from './nav' @@ -142,8 +141,7 @@ export function TypesScreen() { const fields = useTypeFields(types.data) const { list, view, settings } = useTypesView(fields) const { run, region } = useListRun('content-types', list.onChangeSelection) - const onList = useFocusOnList(run, region) - const actions = useTypeActions(onList, usePageKeys(view, fields)) + const actions = useTypeActions(run, usePageKeys(view, fields)) return ( { * @returns The confirm. */ function TrashConfirm({ items, closeModal, run, name }: PostConfirmProps) { - const close = useCloseWhileOpen(closeModal) return ( run(picked, (post) => trashPost(post.id), trashWords(name))} @@ -81,11 +77,10 @@ function TrashConfirm({ items, closeModal, run, name }: PostConfirmProps) { * @returns The confirm. */ function DeleteConfirm({ items, closeModal, run, name }: PostConfirmProps) { - const close = useCloseWhileOpen(closeModal) return ( run(picked, (post) => deletePost(post.id), deleteWords(name, postName))} diff --git a/frontend/src/content/typeActions.tsx b/frontend/src/content/typeActions.tsx index 8fda59c9..3ac2fb14 100644 --- a/frontend/src/content/typeActions.tsx +++ b/frontend/src/content/typeActions.tsx @@ -1,17 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 import type { Action, RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { RunConfirm, useBulkRun, useToaster } from '@gopherium/godmin' -import type { BulkRun, ListRun } from '@gopherium/godmin' +import { RunConfirm, useBulkRun, useToaster, useUnsavedText } from '@gopherium/godmin' +import type { BulkRun, ListRun, UnsavedText } from '@gopherium/godmin' import { useQueryClient } from '@tanstack/react-query' import type { QueryClient } from '@tanstack/react-query' import { __ } from '@wordpress/i18n' import { useCallback, useMemo } from 'react' -import { useCloseWhileOpen } from '../lists/closeWhileOpen' import type { NameCut } from '../lists/itemWords' -import { useUnsavedText } from '../lists/unsavedText' -import type { UnsavedText } from '../lists/unsavedText' import { originOf } from './typeFields' import { AddressModal, DescribeModal, MakeDefaultModal } from './TypeModals' import type { RunOnList } from './TypeModals' @@ -146,11 +143,10 @@ function switchActions(tools: ActionTools): Action[] { * @returns The confirm. */ function DeleteTypesModal({ items, closeModal, tools }: RenderModalProps & { tools: ActionTools }) { - const close = useCloseWhileOpen(closeModal) return ( tools.removals(types, (type) => deleteInTurn(tools.client, type.key), removalWords(tools.name))} diff --git a/frontend/src/lists/RenameModal.tsx b/frontend/src/lists/RenameModal.tsx index 8eb99982..70c33335 100644 --- a/frontend/src/lists/RenameModal.tsx +++ b/frontend/src/lists/RenameModal.tsx @@ -1,12 +1,10 @@ // SPDX-License-Identifier: Apache-2.0 import type { RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { RenameBody, useToaster } from '@gopherium/godmin' +import { RenameBody, markLatest, useToaster } from '@gopherium/godmin' +import type { UnsavedText } from '@gopherium/godmin' import { __ } from '@wordpress/i18n' -import { markLatest } from './unsavedText' -import type { UnsavedText } from './unsavedText' - /** What the send of a new name for one item needs. */ export interface RenameSend { /** Returns the id an item is known by. */ diff --git a/frontend/src/lists/closeWhileOpen.ts b/frontend/src/lists/closeWhileOpen.ts deleted file mode 100644 index d083f077..00000000 --- a/frontend/src/lists/closeWhileOpen.ts +++ /dev/null @@ -1,23 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { useEffect, useRef } from 'react' - -/** - * Returns the close of a modal body that acts only while that body is on screen, never on a modal opened since. - * @param close - Closes the modal. - * @returns The close, a no-op 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/frontend/src/lists/focusOnList.ts b/frontend/src/lists/focusOnList.ts deleted file mode 100644 index a702f2c9..00000000 --- a/frontend/src/lists/focusOnList.ts +++ /dev/null @@ -1,47 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import type { ListRun } from '@gopherium/godmin' -import { useMemo } from 'react' -import type { RefObject } from 'react' - -/** Finds the dialog holding the focus. */ -const DIALOG_IN_FOCUS = '[role="dialog"]:focus-within' - -/** - * 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. - */ -export function focusInDialog(): boolean { - return document.querySelector(DIALOG_IN_FOCUS) !== null -} - -/** - * Reports whether the reader is still on the list, the focus on the page, inside the list or in the dialog that ran. - * @param list - The list region. - * @param startedIn - The dialog that held the focus as the run started, none for a run started outside every dialog. - * @returns Whether a finished run may move the focus to the list. - */ -function readerOnList(list: HTMLElement | null, startedIn: Element | null): boolean { - const active = document.activeElement - return active === document.body || [list, startedIn].some((holder) => holder !== null && holder.contains(active)) -} - -/** - * Returns the list run that moves the focus to the list only while the reader is still on it, never out of a dialog - * opened since. - * @param run - What the list does around a run. - * @param region - The list region a finished run focuses. - * @returns The run, the same for as long as the screen stays. - */ -export function useFocusOnList(run: ListRun, region: RefObject): ListRun { - return useMemo( - () => ({ - onStart: () => { - const settle = run.onStart() - const startedIn = document.querySelector(DIALOG_IN_FOCUS) - return (moved) => settle(moved && readerOnList(region.current, startedIn)) - }, - }), - [run, region], - ) -} diff --git a/frontend/src/lists/unsavedText.ts b/frontend/src/lists/unsavedText.ts deleted file mode 100644 index f96944cb..00000000 --- a/frontend/src/lists/unsavedText.ts +++ /dev/null @@ -1,32 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { useState } from 'react' - -/** What a failed write left unsaved, by the id of the item it was typed for, the text of one field unless named. */ -export type UnsavedText = Map - -/** - * Returns what failed writes left unsaved, the same store for as long as the screen stays. - * @returns The store a row form takes its first value from when it opens again. - */ -export function useUnsavedText(): UnsavedText { - const [held] = useState>(() => new Map()) - return held -} - -/** The latest write each store sent, by the id of the item it went to. */ -const latestWrites = new WeakMap, Map>() - -/** - * Marks a write as the latest one sent for an item, the only one that may keep its text. - * @param unsaved - The store the latest writes of the item are marked on. - * @param id - The item the write goes to. - * @returns The check telling whether no later write went to the item since. - */ -export function markLatest(unsaved: UnsavedText, id: string): () => boolean { - const writes = latestWrites.get(unsaved) ?? new Map() - latestWrites.set(unsaved, writes) - const write = Symbol(id) - writes.set(id, write) - return () => writes.get(id) === write -} diff --git a/frontend/src/media/MediaModals.tsx b/frontend/src/media/MediaModals.tsx index a7f7f067..37a04dde 100644 --- a/frontend/src/media/MediaModals.tsx +++ b/frontend/src/media/MediaModals.tsx @@ -3,8 +3,8 @@ import { InputControl } from '@gophenberg/frontend-sdk' import { DataForm } from '@gophenberg/frontend-sdk/dataviews' import type { Form, RenderModalProps } from '@gophenberg/frontend-sdk/dataviews' -import { RunConfirm, useToaster } from '@gopherium/godmin' -import type { BulkRun } from '@gopherium/godmin' +import { RunConfirm, markLatest, useToaster } from '@gopherium/godmin' +import type { BulkRun, Trigger, UnsavedText } from '@gopherium/godmin' import { FormDialog } from '@gopherium/godmin/dialogs' import type { QueryClient } from '@tanstack/react-query' import { __ } from '@wordpress/i18n' @@ -12,16 +12,12 @@ import { useMemo, useState } from 'react' import { holdWrite, writesSettled } from '../content/postWrites' import { useRenameSend } from '../lists/RenameModal' -import { useCloseWhileOpen } from '../lists/closeWhileOpen' import { deleteQuestion, deleteWords } from '../lists/itemWords' import type { NameCut } from '../lists/itemWords' -import { markLatest } from '../lists/unsavedText' -import type { UnsavedText } from '../lists/unsavedText' import { deleteMedia, editMedia } from './api' import type { MediaDescriptions, MediaItem } from './api' import { mediaFields } from './fields' import { FieldsPane } from './FieldsPane' -import type { Door } from './focusBack' import { saveFailure, titleOf } from './format' /** What the actions of the media library need from the screen around them. */ @@ -49,8 +45,8 @@ export interface OpenedDialog { action: 'edit' | 'rename' /** The file the dialog is open on. */ item: MediaItem - /** The element the dialog opened from, which takes the focus back once the dialog is done. */ - door: Door + /** The trigger the dialog opened from, which takes the focus back once the dialog is done. */ + trigger: Trigger } /** What a dialog the screen holds open on one file takes. */ @@ -61,7 +57,7 @@ interface MediaDialogProps { tools: MediaTools /** Takes the dialog away. */ onClose: () => void - /** Puts a focus the list lost while it read the library again back on the element the dialog opened from. */ + /** Puts a focus the list lost while it read the library again back on the trigger the dialog opened from. */ onSettled: () => void } @@ -128,7 +124,7 @@ function dropKeptTitle(edits: UnsavedText, id: string) { * @param item - The file. * @param tools - What the dialog needs from the screen. * @param close - Takes the dialog away. - * @param settled - Puts a focus the list lost while it read the library again back on the element Edit opened from. + * @param settled - Puts a focus the list lost while it read the library again back on the trigger Edit opened from. * @returns The props of the form and the send. */ function useEditMedia(item: MediaItem, tools: MediaTools, close: () => void, settled: () => void) { @@ -229,9 +225,9 @@ const OPENED_DIALOGS = { edit: EditMediaDialog, rename: RenameMediaDialog } /** * Renders the dialog the screen holds open on one file, none while no dialog is open. - * @param props - The dialog, its file and the element it opened from, what it needs from the screen, what takes it away - * and what puts a lost focus back on the element a dialog on a file opened from. - * @returns The dialog, giving the focus back to the element it opened from once it closed and once its write read the + * @param props - The dialog, its file and the trigger it opened from, what it needs from the screen, what takes it away + * and what puts a lost focus back on the trigger a dialog on a file opened from. + * @returns The dialog, giving the focus back to the trigger it opened from once it closed and once its write read the * library again. */ export function OpenMediaDialog({ @@ -239,12 +235,15 @@ export function OpenMediaDialog({ tools, onClose, onBack, -}: { opened?: OpenedDialog, onBack: (id: number, door: Door) => void } & Omit) { +}: { + opened?: OpenedDialog + onBack: (id: string, trigger: Trigger) => void +} & Omit) { if (opened === undefined) { return null } const Dialog = OPENED_DIALOGS[opened.action] - const back = () => onBack(opened.item.id, opened.door) + const back = () => onBack(String(opened.item.id), opened.trigger) const close = () => { onClose() back() @@ -266,11 +265,10 @@ export function OpenMediaDialog({ * @returns The confirm. */ export function DeleteMediaModal({ items, closeModal, tools }: MediaModalProps) { - const close = useCloseWhileOpen(closeModal) return ( diff --git a/frontend/src/media/MediaScreen.tsx b/frontend/src/media/MediaScreen.tsx index c21bce5b..61da86dd 100644 --- a/frontend/src/media/MediaScreen.tsx +++ b/frontend/src/media/MediaScreen.tsx @@ -9,16 +9,19 @@ import { ListEmpty, Page, bulkNotes, + focusInDialog, + focusedTrigger, offeredSize, offeredView, paginationOf, runEach, sizedView, + useFocusBack, useListRun, useToaster, viewNarrows, } from '@gopherium/godmin' -import type { BulkFailure, BulkWords, OfferedView } from '@gopherium/godmin' +import type { BulkFailure, BulkWords, OfferedView, Trigger } from '@gopherium/godmin' import { openOnTap, useListView } from '@gopherium/godmin/router' import type { ListView } from '@gopherium/godmin/router' import { formatNumber } from '@gopherium/gottext' @@ -28,7 +31,6 @@ import { __, _n, _x, sprintf } from '@wordpress/i18n' import { useMemo, useRef, useState } from 'react' import type { RefObject } from 'react' -import { focusInDialog, useFocusOnList } from '../lists/focusOnList' import type { ListSettings } from '../settings/api' import { useListSettings } from '../settings/useListSettings' import './media.css' @@ -36,8 +38,6 @@ import { useMayChange, useMediaActions, useMediaTools, useRefreshMedia, viewFile import { listMedia, mediaQueryKey, uploadMedia } from './api' import type { MediaItem, MediaPage, MediaQuery } from './api' import { MEDIA_SORT, MEDIA_VIEW_GUARD, libraryFields, mediaListing } from './fields' -import { clickedDoor, useFocusBack } from './focusBack' -import type { Door } from './focusBack' import { OpenMediaDialog } from './MediaModals' import type { MediaTools, OpenedDialog } from './MediaModals' @@ -279,7 +279,7 @@ function MediaList(props: { }) { const actions = useMediaActions(props.tools, props.mayChange, props.open) const { media, list, view } = props - const edit = (item: MediaItem, door: Door) => props.open({ action: 'edit', item, door }) + const edit = (item: MediaItem, trigger: Trigger) => props.open({ action: 'edit', item, trigger }) if (media.isError) { return {__('The media library could not be loaded.', 'gophenberg')} } @@ -295,7 +295,7 @@ function MediaList(props: { selection={list.selection} onChangeSelection={openOnTap(list, openTapped(media.data, props.mayChange, (item) => edit(item, 'row')))} isItemClickable={props.mayChange} - onClickItem={(item: MediaItem) => edit(item, clickedDoor())} + onClickItem={(item: MediaItem) => edit(item, focusedTrigger())} isLoading={media.isFetching} getItemId={(item: MediaItem) => String(item.id)} searchLabel={sprintf(__('Search %(type)s…', 'gophenberg'), { @@ -312,7 +312,7 @@ function MediaList(props: { export function MediaScreen() { const { list, fields, view, settings, media } = useMediaList() const { run, region } = useListRun('media', list.onChangeSelection) - const tools = useMediaTools(useFocusOnList(run, region)) + const tools = useMediaTools(run) const mayChange = useMayChange() const [opened, setOpened] = useState() const focusBack = useFocusBack(media.isFetching, region) diff --git a/frontend/src/media/actions.tsx b/frontend/src/media/actions.tsx index 6070cb76..a2508e0b 100644 --- a/frontend/src/media/actions.tsx +++ b/frontend/src/media/actions.tsx @@ -2,14 +2,13 @@ import { sessionMayChange, trashIcon } from '@gophenberg/frontend-sdk' import type { Action } from '@gophenberg/frontend-sdk/dataviews' -import { useBulkRun, useToaster } from '@gopherium/godmin' +import { useBulkRun, useToaster, useUnsavedText } from '@gopherium/godmin' import type { ListRun } from '@gopherium/godmin' import { useSession } from '@gopherium/react-auth' import { useQueryClient } from '@tanstack/react-query' import { __, _x } from '@wordpress/i18n' import { useCallback, useMemo } from 'react' -import { useUnsavedText } from '../lists/unsavedText' import { mediaQueryKey, mediaSrc } from './api' import type { MediaDescriptions, MediaItem } from './api' import { displayFile } from './format' @@ -96,13 +95,13 @@ export function useMediaActions( id: 'edit', label: __('Edit…', 'gophenberg'), isEligible: mayChange, - callback: ([item]) => open({ action: 'edit', item, door: 'actions' }), + callback: ([item]) => open({ action: 'edit', item, trigger: 'actions' }), }, { id: 'rename', label: __('Rename…', 'gophenberg'), isEligible: mayChange, - callback: ([item]) => open({ action: 'rename', item, door: 'actions' }), + callback: ([item]) => open({ action: 'rename', item, trigger: 'actions' }), }, { id: 'delete', diff --git a/frontend/src/media/fields.tsx b/frontend/src/media/fields.tsx index aae545e9..56d92c2e 100644 --- a/frontend/src/media/fields.tsx +++ b/frontend/src/media/fields.tsx @@ -94,7 +94,7 @@ function PictureCell({ item, sizes, onError }: { item: MediaItem, sizes: string } }, []) return ( -
+
+
{titleOf(item)} + return {titleOf(item)} } /** diff --git a/frontend/src/media/focusBack.ts b/frontend/src/media/focusBack.ts deleted file mode 100644 index 1e972665..00000000 --- a/frontend/src/media/focusBack.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -import { useCallback, useEffect, useRef, useState } from 'react' -import type { RefObject } from 'react' - -/** Finds the card of a grid or the row of a list that holds a file. */ -const HOLDERS = '.dataviews-view-grid__card, .dataviews-view-list__item-wrapper' - -/** Finds each element a dialog on a file opens from, inside the card or the row holding the file. */ -const DOORS = { - actions: '[aria-haspopup="menu"]', - title: '.dataviews-title-field[role="button"]', - picture: '.dataviews-view-grid__media[role="button"]', - row: '.dataviews-view-list__item', -} - -/** The element a dialog on a file opened from: its actions menu, the title or the picture of its card, or its row. */ -export type Door = keyof typeof DOORS - -/** One request to put the focus back on the element a dialog on a file opened from, a new one each time it is asked. */ -interface FocusBack { - /** The file. */ - id: number - /** The element the dialog opened from. */ - door: Door -} - -/** - * Returns the part of a card a click opened a dialog from, read from the focus the click put on it. - * @returns The picture while the focus sits on it, the title otherwise. - */ -export function clickedDoor(): Door { - return (document.activeElement as Element).matches(DOORS.picture) ? 'picture' : 'title' -} - -/** - * Returns the element a dialog on a file opened from, found through the file wherever its card or row now sits. - * @param asked - The file and the door. - * @returns The door, the actions menu of the file once the layout draws no such door, none while the list draws no card - * or row of the file. - */ -function doorOf({ id, door }: FocusBack): HTMLElement | null { - const marked = document.querySelector(`[data-media-item="${id}"]`) - if (marked === null) { - return null - } - const holder = marked.closest(HOLDERS) as Element - return holder.querySelector(DOORS[door]) ?? (holder.querySelector(DOORS.actions) as HTMLElement) -} - -/** - * Returns the handler putting a focus the page lost back on the element a dialog on a file opened from, once the list - * is drawn again, as WordPress's modal gives the focus back to what opened it, and on the list once the file left it. - * @param busy - Whether the list reads the library, its cards then out of reach of the focus. - * @param region - The list region. - * @returns The handler, given the file and the element the dialog opened from. - */ -export function useFocusBack(busy: boolean, region: RefObject): (id: number, door: Door) => void { - const [asked, setAsked] = useState() - const handled = useRef(undefined) - useEffect(() => { - if (busy || asked === handled.current) { - return - } - handled.current = asked - if (document.activeElement === document.body) { - const target = doorOf(asked as FocusBack) ?? (region.current as HTMLElement) - target.focus() - } - }, [asked, busy, region]) - return useCallback((id: number, door: Door) => setAsked({ id, door }), []) -} diff --git a/frontend/src/test/media-actions.test.tsx b/frontend/src/test/media-actions.test.tsx index 8976edf4..93a250fa 100644 --- a/frontend/src/test/media-actions.test.tsx +++ b/frontend/src/test/media-actions.test.tsx @@ -180,16 +180,16 @@ function aMoment() { } /** Finds the element of a card a dialog on its file opens from. */ -type CardDoor = (card: Element) => HTMLElement | null +type CardTrigger = (card: Element) => HTMLElement | null /** Finds the button of a card opening its actions menu. */ -const menuDoor: CardDoor = (card) => card.querySelector('[aria-haspopup="menu"]') +const menuTrigger: CardTrigger = (card) => card.querySelector('[aria-haspopup="menu"]') /** Finds the title of a card a click opens Edit from. */ -const titleDoor: CardDoor = (card) => card.querySelector('.dataviews-title-field[role="button"]') +const titleTrigger: CardTrigger = (card) => card.querySelector('.dataviews-title-field[role="button"]') /** Finds the picture of a card a click opens Edit from. */ -const pictureDoor: CardDoor = (card) => card.querySelector('.dataviews-view-grid__media[role="button"]') +const pictureTrigger: CardTrigger = (card) => card.querySelector('.dataviews-view-grid__media[role="button"]') /** * Returns the card of the grid holding the given title. @@ -1134,38 +1134,38 @@ const ANCHOR = { ...MANUAL, id: 9, file: '2026/08/anchor-chain.pdf', title: 'Anc /** * Opens Edit from a part of the card of Harbor at dawn, as a click on it does. - * @param door - Finds the part clicked inside the card. + * @param trigger - Finds the part clicked inside the card. * @returns The dialog. */ -async function openFromHarbor(door: CardDoor): Promise { - await userEvent.click(door(await cardHolding('Harbor at dawn')) as HTMLElement) +async function openFromHarbor(trigger: CardTrigger): Promise { + await userEvent.click(trigger(await cardHolding('Harbor at dawn')) as HTMLElement) return screen.findByRole('dialog', { name: 'Edit media' }) } test.each([ - { part: 'title', door: titleDoor }, - { part: 'picture', door: pictureDoor }, + { part: 'title', trigger: titleTrigger }, + { part: 'picture', trigger: pictureTrigger }, ])('gives the focus back to the $part Edit opened from, on its card where it moved, once Save stored it', async ({ - door, + trigger, }) => { listByTitle() renderAt('/media') - const dialog = await openFromHarbor(door) + const dialog = await openFromHarbor(trigger) await retype(dialog, 'Title', 'Zebra crossing') await save(dialog) await findToast('Media file updated.') const moved = await cardHolding('Zebra crossing') - await waitFor(() => expect(door(moved)).toHaveFocus()) + await waitFor(() => expect(trigger(moved)).toHaveFocus()) expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(moved) }) test.each([ - { part: 'actions menu', door: menuDoor, open: () => openCardDialog('Harbor at dawn', 'Edit…') }, - { part: 'title', door: titleDoor, open: () => openFromHarbor(titleDoor) }, + { part: 'actions menu', trigger: menuTrigger, open: () => openCardDialog('Harbor at dawn', 'Edit…') }, + { part: 'title', trigger: titleTrigger, open: () => openFromHarbor(titleTrigger) }, ])('gives the focus back to the $part of a file whose edit failed, on its card where it moved', async ({ - door, + trigger, open, }) => { vi.spyOn(console, 'error').mockImplementation(() => {}) @@ -1182,7 +1182,7 @@ test.each([ await findToast('Could not save image. The server could not be reached, so nothing was saved.') const moved = await cardHolding('Harbor at dawn') - await waitFor(() => expect(door(moved)).toHaveFocus()) + await waitFor(() => expect(trigger(moved)).toHaveFocus()) expect(document.querySelectorAll('.dataviews-view-grid__card')[1]).toBe(moved) }) @@ -1231,7 +1231,7 @@ test('gives the focus back to the phone row Edit opened from once the save faile expect(listings).toBeGreaterThan(1) }) -test('falls back to the actions menu once the door Edit opened from is gone, as a phone row in a grid', async () => { +test('falls back to the actions menu once the trigger Edit opened from is gone, as a phone row in a grid', async () => { setViewport({ width: 639 }) renderAt('/media') await userEvent.click(await screen.findByRole('button', { name: /^Harbor at dawn/ })) diff --git a/frontend/src/test/media-fields.test.tsx b/frontend/src/test/media-fields.test.tsx index ac5ad6ca..140c0bce 100644 --- a/frontend/src/test/media-fields.test.tsx +++ b/frontend/src/test/media-fields.test.tsx @@ -161,8 +161,8 @@ test('reads an untitled item as an empty title under the No title placeholder, s }) test('draws a title as text marked with the file, (no title) when untitled, so the bold title rule applies', () => { - expect(renderCell('title', item()).innerHTML).toBe('Harbor at dawn') - expect(renderCell('title', item({ title: '' })).innerHTML).toBe('(no title)') + expect(renderCell('title', item()).innerHTML).toBe('Harbor at dawn') + expect(renderCell('title', item({ title: '' })).innerHTML).toBe('(no title)') }) test.each([ @@ -171,7 +171,7 @@ test.each([ ])('marks the thumbnail of $shape with the file, so the focus finds its card again', ({ held }) => { const cell = renderCell('thumbnail', held, '900px') - expect(cell.querySelectorAll('[data-media-item="7"]')).toHaveLength(1) + expect(cell.querySelectorAll('[data-item-id="7"]')).toHaveLength(1) }) test('offers alt text on a picture alone', () => { diff --git a/frontend/src/test/posts-actions.test.tsx b/frontend/src/test/posts-actions.test.tsx index a0c7d8ab..58bf74c1 100644 --- a/frontend/src/test/posts-actions.test.tsx +++ b/frontend/src/test/posts-actions.test.tsx @@ -2,7 +2,7 @@ import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing' import { LIST_CHROME_DOMAIN, listChromeCatalogFor } from '@gopherium/godmin' -import { buttonClasses } from '@gopherium/godmin/testing' +import { buttonClasses, stubLayout } from '@gopherium/godmin/testing' import { act, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { resetLocaleData, setLocaleData } from '@wordpress/i18n' @@ -413,6 +413,33 @@ test('leaves a dialog opened on the row after a trash dismissed while busy open expect(screen.getByRole('dialog', { name: 'Rename' })).toBe(rename) }) +test('keeps the focus in a Rename opened after a dismissed trash once that trash lands', async () => { + stubLayout() + const answer = gate() + server.use( + http.delete('/api/content/:id', async () => { + await answer.held + return HttpResponse.json({ ...PUBLISHED, status: 'trash' }) + }), + ) + renderAt('/content/post') + await trashTheListedPost() + await waitBusy(screen.getByRole('dialog'), 'Move to trash') + await clickDimmedPage() + await openRowActions() + await userEvent.click(await screen.findByRole('menuitem', { name: 'Rename…' })) + const rename = await screen.findByRole('dialog', { name: 'Rename' }) + const name = within(rename).getByRole('textbox', { name: 'Name' }) + await waitFor(() => expect(name).toHaveFocus()) + + answer.release() + await screen.findByText('"Welcome to Gophenberg" moved to the trash.', { selector: '.godmin-toast' }) + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + + expect(screen.getByRole('dialog', { name: 'Rename' })).toBe(rename) + expect(name).toHaveFocus() +}) + test('speaks Cargando to a Spanish reader while the trash runs, as WordPress es_ES does', async () => { const answer = gate() server.use( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 700b9605..c1b90972 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -141,8 +141,8 @@ importers: specifier: workspace:* version: link:../sdk/frontend '@gopherium/godmin': - specifier: 0.26.0 - version: 0.26.0(95cb8e83355dc3cd0ae6db4441fc2ea1) + specifier: 0.27.0 + version: 0.27.0(95cb8e83355dc3cd0ae6db4441fc2ea1) '@gopherium/gottext': specifier: 0.7.0 version: 0.7.0(@wordpress/i18n@6.30.0)(gettext-extractor@4.0.6)(react@19.3.1) @@ -269,8 +269,8 @@ importers: sdk/frontend: dependencies: '@gopherium/godmin': - specifier: 0.26.0 - version: 0.26.0(95cb8e83355dc3cd0ae6db4441fc2ea1) + specifier: 0.27.0 + version: 0.27.0(95cb8e83355dc3cd0ae6db4441fc2ea1) '@gopherium/react-auth': specifier: 0.15.0 version: 0.15.0(2d3f16a3b2e3dd70991c065c2099a2ee) @@ -1288,8 +1288,8 @@ packages: '@floating-ui/utils@0.2.12': resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} - '@gopherium/godmin@0.26.0': - resolution: {integrity: sha512-xRqCoi1nEmP/Nr6f1kOGY0U30OgZ3YRPb3wCFDfa4qxusNQSjIfNUrWP6X+7rj519cclIFl47LdCcBW9mWMVTA==} + '@gopherium/godmin@0.27.0': + resolution: {integrity: sha512-dUU163mS/fekq7Rk1qf0v3Ihq6uqe+CNlqyB/aQTv0bWNVkGT2/H/moItldRErrcGvMRB2EKoLvBCuyqkae2Kg==} peerDependencies: '@tanstack/react-router': ^1.170.44 '@testing-library/react': ^16.3.2 @@ -7008,7 +7008,7 @@ snapshots: '@floating-ui/utils@0.2.12': {} - '@gopherium/godmin@0.26.0(95cb8e83355dc3cd0ae6db4441fc2ea1)': + '@gopherium/godmin@0.27.0(95cb8e83355dc3cd0ae6db4441fc2ea1)': dependencies: '@wordpress/a11y': 4.57.0 '@wordpress/i18n': 6.30.0 diff --git a/sdk/frontend/index.ts b/sdk/frontend/index.ts index 20607473..81c3a06e 100644 --- a/sdk/frontend/index.ts +++ b/sdk/frontend/index.ts @@ -16,17 +16,26 @@ export type { RunConfirmProps, RunSettled, ToasterHandle, + Trigger, + UnsavedText, } from '@gopherium/godmin' export { ConfirmBody, FormBody, + ListEmpty, MenuCount, NavigationScreen, RenameBody, RunConfirm, + focusInDialog, + focusedTrigger, + markLatest, useBulkRun, + useCloseWhileOpen, + useFocusBack, useListRun, useToaster, + useUnsavedText, } from '@gopherium/godmin' export type { MenuItem } from '@gopherium/godmin/router' export { DrilldownItem, NavigationItem, NavigationList } from '@gopherium/godmin/router' diff --git a/sdk/frontend/package.json b/sdk/frontend/package.json index e94035cd..1378b954 100644 --- a/sdk/frontend/package.json +++ b/sdk/frontend/package.json @@ -11,7 +11,7 @@ "./testing": "./testing.tsx" }, "dependencies": { - "@gopherium/godmin": "0.26.0", + "@gopherium/godmin": "0.27.0", "@gopherium/react-auth": "0.15.0", "@tanstack/react-query": "^5.102.8", "@wordpress/a11y": "4.57.0", diff --git a/sdk/frontend/test/dialogs.test.ts b/sdk/frontend/test/dialogs.test.ts index b00a082b..defe9db2 100644 --- a/sdk/frontend/test/dialogs.test.ts +++ b/sdk/frontend/test/dialogs.test.ts @@ -12,7 +12,10 @@ import manifest from '../package.json' const exported: Record = manifest.exports test('hands plugins the dialogs the admin asks with outside a list, the very ones godmin ships', () => { - expect([ConfirmDialog, FormDialog]).toEqual([godmin.ConfirmDialog, godmin.FormDialog]) + const handed = [ConfirmDialog, FormDialog] + + expect(handed.map((piece) => typeof piece)).toEqual(['function', 'function']) + expect(handed).toEqual([godmin.ConfirmDialog, godmin.FormDialog]) }) test('hands plugins the types the dialogs are written in', () => { diff --git a/sdk/frontend/test/list-kit.test.ts b/sdk/frontend/test/list-kit.test.ts index 7413dd1b..b65af6ed 100644 --- a/sdk/frontend/test/list-kit.test.ts +++ b/sdk/frontend/test/list-kit.test.ts @@ -3,7 +3,22 @@ import * as godmin from '@gopherium/godmin' import { expect, test } from 'vitest' -import { ConfirmBody, FormBody, RenameBody, RunConfirm, useBulkRun, useListRun, useToaster } from '../index' +import { + ConfirmBody, + FormBody, + ListEmpty, + RenameBody, + RunConfirm, + focusInDialog, + focusedTrigger, + markLatest, + useBulkRun, + useCloseWhileOpen, + useFocusBack, + useListRun, + useToaster, + useUnsavedText, +} from '../index' import type { BulkRun, BulkWords, @@ -16,6 +31,8 @@ import type { RunConfirmProps, RunSettled, ToasterHandle, + Trigger, + UnsavedText, } from '../index' /** The words a plugin's bulk run reports its outcome in, typed through the SDK. */ @@ -24,18 +41,50 @@ const WORDS: BulkWords<{ id: string }> = { failed: (failures, asked) => `Failed: ${failures.length} of ${asked}.`, } +/** + * Expects every piece the SDK hands out to be a function, the very one godmin ships in its place. + * @param handed - The pieces the SDK hands out. + * @param shipped - The pieces godmin ships, in the same order. + */ +function expectGodmins(handed: unknown[], shipped: unknown[]): void { + expect(handed.map((piece) => typeof piece)).toEqual(shipped.map(() => 'function')) + expect(handed).toEqual(shipped) +} + test('hands plugins the list kit the admin runs, the very functions godmin ships', () => { - expect([RunConfirm, useBulkRun, useListRun]).toEqual([godmin.RunConfirm, godmin.useBulkRun, godmin.useListRun]) + expectGodmins([RunConfirm, useBulkRun, useListRun], [godmin.RunConfirm, godmin.useBulkRun, godmin.useListRun]) }) test('hands plugins the dialog bodies the admin asks in, the very ones godmin ships', () => { - expect([ConfirmBody, FormBody, RenameBody]).toEqual([godmin.ConfirmBody, godmin.FormBody, godmin.RenameBody]) + expectGodmins([ConfirmBody, FormBody, RenameBody], [godmin.ConfirmBody, godmin.FormBody, godmin.RenameBody]) }) test('hands plugins the toaster the admin reports a failed write in, the very one godmin ships', () => { const handle: Pick | undefined = undefined - expect([useToaster, handle]).toEqual([godmin.useToaster, undefined]) + expectGodmins([useToaster], [godmin.useToaster]) + expect(handle).toBeUndefined() +}) + +test('hands plugins the store a failed row write keeps its text in, the very one godmin ships', () => { + const kept: UnsavedText = new Map([['7', 'Harbor at dawn']]) + + expectGodmins([useUnsavedText, markLatest], [godmin.useUnsavedText, godmin.markLatest]) + expect(kept.get('7')).toBe('Harbor at dawn') +}) + +test('hands plugins the empty state the admin titles an empty list with, the very one godmin ships', () => { + expectGodmins([ListEmpty], [godmin.ListEmpty]) +}) + +test('hands plugins the hooks a dialog closes and gives the focus back with, the very ones godmin ships', () => { + const trigger: Trigger = 'actions' + + expectGodmins( + [useCloseWhileOpen, useFocusBack, focusedTrigger, focusInDialog], + [godmin.useCloseWhileOpen, godmin.useFocusBack, godmin.focusedTrigger, godmin.focusInDialog], + ) + expect(trigger).toBe('actions') }) test('hands plugins the types the list kit is written in', () => { diff --git a/test/e2e/tests/media.spec.ts b/test/e2e/tests/media.spec.ts index 020e2c8a..c009ee14 100644 --- a/test/e2e/tests/media.spec.ts +++ b/test/e2e/tests/media.spec.ts @@ -394,16 +394,16 @@ for (const { speed, hold } of LISTINGS) { await stored(page, title) await page.setViewportSize(LAPTOP) await page.goto(`/admin/media?search=${LIBRARY}`) - const door = cardOf(page, title).locator('.dataviews-title-field') + const trigger = cardOf(page, title).locator('.dataviews-title-field') - await door.click() + await trigger.click() const dialog = page.getByRole('dialog', { name: 'Edit media' }) await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') await hold(page) await dialog.getByRole('button', { name: 'Save' }).click() await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() - await expect(door).toBeFocused() + await expect(trigger).toBeFocused() }) } @@ -633,16 +633,16 @@ test.describe('on a phone', () => { const title = `${LIBRARY}-tap-${speed}` await stored(page, title) await page.goto(`/admin/media?search=${LIBRARY}`) - const door = page.getByRole('button', { name: new RegExp(`^${title}`) }) + const trigger = page.getByRole('button', { name: new RegExp(`^${title}`) }) - await door.tap() + await trigger.tap() const dialog = page.getByRole('dialog', { name: 'Edit media' }) await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') await hold(page) await dialog.getByRole('button', { name: 'Save' }).tap() await expect(page.locator('.godmin-toast', { hasText: 'Media file updated.' })).toBeVisible() - await expect(door).toBeFocused() + await expect(trigger).toBeFocused() }) } @@ -652,9 +652,9 @@ test.describe('on a phone', () => { const title = `${LIBRARY}-tap-failed` await stored(page, title) await page.goto(`/admin/media?search=${LIBRARY}`) - const door = page.getByRole('button', { name: new RegExp(`^${title}`) }) + const trigger = page.getByRole('button', { name: new RegExp(`^${title}`) }) - await door.tap() + await trigger.tap() const dialog = page.getByRole('dialog', { name: 'Edit media' }) await dialog.getByRole('textbox', { name: 'Alt text' }).fill('Boats leaving the harbor') await page.route('**/api/media/*', (route) => (route.request().method() === 'PATCH' ? route.abort() : route.continue())) @@ -662,6 +662,6 @@ test.describe('on a phone', () => { await dialog.getByRole('button', { name: 'Save' }).tap() await expect(page.locator('.godmin-toast', { hasText: 'Could not save image.' })).toBeVisible() - await expect(door).toBeFocused() + await expect(trigger).toBeFocused() }) })