diff --git a/apps/web/e2e/duplicate.spec.ts b/apps/web/e2e/duplicate.spec.ts index 495d1b8..01f7e7f 100644 --- a/apps/web/e2e/duplicate.spec.ts +++ b/apps/web/e2e/duplicate.spec.ts @@ -26,6 +26,8 @@ for (const [layout, width] of WIDTHS) { await page.goto('/'); await createVault(page, PASSWORD); + // On mobile, the folders are in the sheet of the list's pill (D254). + if (layout === 'mobile') await page.getByTestId('folder-pill').click(); await button(page, '+ New folder').click(); await page.getByTestId('folder-name').fill('Work'); await button(page, 'Create').click(); diff --git a/apps/web/e2e/folders.spec.ts b/apps/web/e2e/folders.spec.ts index 8f61fa5..5a6ed51 100644 --- a/apps/web/e2e/folders.spec.ts +++ b/apps/web/e2e/folders.spec.ts @@ -24,10 +24,12 @@ function subfolder(page: Page, name: string) { return page.getByRole('list', { name: 'Folders' }).getByRole('button', { name, exact: true }); } -/** Shows the folder pane: on the root screen on mobile, in the drawer on tablet. */ +/** Shows the folder pane: in the list's sheet on mobile (D254), in the drawer on tablet. */ async function folders(page: Page, layout: Layout) { if (layout === 'mobile') { while (await button(page, 'Back').isVisible()) await button(page, 'Back').click(); + if (!(await button(page, 'All items').isVisible())) + await page.getByTestId('folder-pill').click(); } if (layout === 'tablet' && !(await button(page, 'All items').isVisible())) { await button(page, 'Folders').click(); @@ -108,7 +110,8 @@ for (const [layout, width] of WIDTHS) { await expect(button(page, 'Clients')).toHaveCount(0); await button(page, 'All items').click(); await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible(); - await folders(page, layout); + // On mobile, the Trash is in the bottom navigation (D245). + if (layout !== 'mobile') await folders(page, layout); await button(page, 'Trash').click(); await expect(page.getByText('Folder: Clients')).toBeVisible(); await expect(button(page, 'Contract')).toBeVisible(); diff --git a/apps/web/e2e/items.spec.ts b/apps/web/e2e/items.spec.ts index 0d721b6..64f9310 100644 --- a/apps/web/e2e/items.spec.ts +++ b/apps/web/e2e/items.spec.ts @@ -37,7 +37,7 @@ async function newLogin(page: Page) { await expect(page.getByRole('heading', { name: TITLE })).toBeVisible(); } -/** Opens a list from the folder pane: in the drawer on tablet, on the list screen on mobile. */ +/** Opens a list from the folder pane: in the drawer on tablet, the bottom navigation on mobile. */ async function showList(page: Page, layout: Layout, name: 'All items' | 'Trash') { if ( layout === 'mobile' && @@ -48,7 +48,12 @@ async function showList(page: Page, layout: Layout, name: 'All items' | 'Trash') if (layout === 'tablet' && !(await page.getByRole('button', { name, exact: true }).isVisible())) { await page.getByRole('button', { name: 'Folders', exact: true }).click(); } - await page.getByRole('button', { name, exact: true }).click(); + await page + .getByRole('button', { + name: layout === 'mobile' && name === 'All items' ? 'Items' : name, + exact: true, + }) + .click(); } for (const [layout, width] of WIDTHS) { diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts index 69cbccc..110ae9d 100644 --- a/apps/web/e2e/layout.spec.ts +++ b/apps/web/e2e/layout.spec.ts @@ -79,13 +79,18 @@ for (const [layout, width] of WIDTHS) { await expect(search).toBeVisible(); await expect(heading).toBeVisible(); } else { - // The detail alone, then back to the folders and the list. + // The detail alone, then back to the list, its folders in the sheet of its pill (D254). await expect(folders(page)).toBeHidden(); await expect(search).toBeHidden(); await page.getByRole('button', { name: 'Back', exact: true }).click(); await expect(heading).toBeHidden(); - await expect(folders(page)).toBeVisible(); + await expect(folders(page)).toBeHidden(); await expect(search).toBeVisible(); + await page.getByTestId('folder-pill').click(); + await expect(folders(page)).toBeVisible(); + await noOverflow(page, 'the folder sheet'); + await page.keyboard.press('Escape'); + await expect(folders(page)).toBeHidden(); await page.getByRole('button', { name: TITLE, exact: true }).click(); } diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts index b9ae598..07622e5 100644 --- a/apps/web/e2e/navigation.spec.ts +++ b/apps/web/e2e/navigation.spec.ts @@ -63,11 +63,12 @@ test('filters the list by type, with counts beside the types and the folder pane await createVault(page, PASSWORD); const pane = page.getByRole('navigation', { name: 'Folders' }); const list = page.getByRole('region', { name: 'Items' }).getByTestId('row-title'); + const pill = page.getByTestId('type-pill'); const filter = page.getByRole('group', { name: 'Filter by type' }); await newNote(page, 'Garden plan'); // One type: nothing to filter. - await expect(filter).toBeHidden(); + await expect(pill).toBeHidden(); await button(page, 'New item').click(); await button(page, 'Login').click(); await page.getByTestId('item-title').fill('Harbor Credit Union'); @@ -77,18 +78,29 @@ test('filters the list by type, with counts beside the types and the folder pane // The counts are seen, not part of the names. await expect(pane.getByRole('button', { name: 'All items', exact: true })).toContainText('2'); await expect(pane.getByRole('button', { name: 'Trash', exact: true })).toContainText('0'); - await expect(filter.getByRole('button')).toHaveText(['All2', 'Login1', 'Secure note1']); - await expect(filter.getByRole('button', { name: 'All', exact: true })).toHaveAttribute( + // The type filter is a pill whose choices open in a popover (D254). + await expect(pill).toHaveText('All types'); + await expect(filter).toBeHidden(); + await pill.click(); + await expect(pill).toHaveAttribute('aria-expanded', 'true'); + await expect(filter.getByRole('button')).toHaveText(['All types2', 'Login1', 'Secure note1']); + await expect(filter.getByRole('button', { name: 'All types', exact: true })).toHaveAttribute( 'aria-pressed', 'true', ); + // Choosing a type closes the popover, and the pill names it. await filter.getByRole('button', { name: 'Secure note', exact: true }).click(); + await expect(filter).toBeHidden(); + await expect(pill).toHaveText('Secure note'); await expect(list).toHaveText(['Garden plan']); + await pill.click(); await expect(filter.getByRole('button', { name: 'Secure note', exact: true })).toHaveAttribute( 'aria-pressed', 'true', ); + await page.keyboard.press('Escape'); + await expect(filter).toBeHidden(); // Harbor Credit Union, just saved, is still shown. The trash then holds one type, so no filter // applies there. @@ -96,7 +108,7 @@ test('filters the list by type, with counts beside the types and the folder pane await button(page, 'Move to trash').click(); await expect(pane.getByRole('button', { name: 'Trash', exact: true })).toContainText('1'); await pane.getByRole('button', { name: 'Trash', exact: true }).click(); - await expect(filter).toBeHidden(); + await expect(pill).toBeHidden(); await expect(page.getByRole('region', { name: 'Trash' }).getByTestId('row-title')).toHaveText([ 'Harbor Credit Union', ]); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 62d5e31..fe09e74 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -691,7 +691,8 @@ const en = { results: 'Results', noResults: 'No items match.', filterType: 'Filter by type', - allTypes: 'All', + allTypes: 'All types', + allFolders: 'All folders', copyPassword: 'Copy password', clockBehind: "This device's clock went back, so the vault is read-only until {date}. Sync still receives records.", @@ -1861,7 +1862,8 @@ const ptBR: Messages = { results: 'Resultados', noResults: 'Nenhum item corresponde.', filterType: 'Filtrar por tipo', - allTypes: 'Todos', + allTypes: 'Todos os tipos', + allFolders: 'Todas as pastas', copyPassword: 'Copiar a senha', clockBehind: 'O relógio deste dispositivo voltou, então o cofre está somente leitura até {date}. A sincronização continua recebendo registros.', diff --git a/apps/web/src/ui/Folders.tsx b/apps/web/src/ui/Folders.tsx index 8a81781..6519f79 100644 --- a/apps/web/src/ui/Folders.tsx +++ b/apps/web/src/ui/Folders.tsx @@ -141,6 +141,7 @@ function ListEntry({ className={`${ROW} flex items-center gap-2`} style={{ paddingLeft: `${String(0.5 + depth)}rem` }} aria-current={selected ? 'true' : undefined} + data-choice onClick={() => { nav.showList(list); }} diff --git a/apps/web/src/ui/ItemList.tsx b/apps/web/src/ui/ItemList.tsx index cddbcd9..b72ae71 100644 --- a/apps/web/src/ui/ItemList.tsx +++ b/apps/web/src/ui/ItemList.tsx @@ -9,11 +9,11 @@ import { type SearchIndex } from '../app/search'; import { listMove } from '../app/shortcuts'; import { useLanguage } from '../i18n/context'; import { type Messages } from '../i18n/messages'; -import { Button, INPUT, ROW, TARGET } from './components'; +import { Button, INPUT, Pill, ROW } from './components'; import { useLayout } from './hooks'; import { Icon } from './icons'; import { FirstSteps } from './FirstSteps'; -import { FolderActions, TrashedFolder } from './Folders'; +import { FolderActions, FolderPane, TrashedFolder } from './Folders'; import { ItemRow } from './ItemRow'; import { useCopy } from './Secret'; @@ -28,9 +28,6 @@ interface Row { type Template = keyof Messages['templates']; -/** A type filter's chip; the pressed one takes the accent's soft fill (D245). */ -const CHIP = `${TARGET} shrink-0 whitespace-nowrap rounded-full border border-line px-3 text-sm hover:bg-subtle aria-pressed:border-accent aria-pressed:bg-accent-soft aria-pressed:font-medium`; - /** The list shown: every item not in the trash, one folder's contents, or the trash. */ export function ItemList({ session, @@ -129,11 +126,13 @@ export function ItemList({ for (const { type: t } of items) if (t !== undefined) counts.set(t, (counts.get(t) ?? 0) + 1); // In the templates' order; an unknown template is counted in All only. const types = (Object.keys(m.templates) as Template[]).filter((t) => counts.has(t)); - // Applied only while its chips show, so no item is ever hidden by a filter out of sight. + // Applied only while its pill shows, so no item is ever hidden by a filter out of sight. const filter = types.length > 1 && type !== undefined && counts.has(type) ? type : undefined; const all = items.length; if (filter !== undefined) items = items.filter((row) => row.type === filter); const nothing = items.length + subfolders.length + trashedFolders.length === 0; + // On a phone the folders are a pill's sheet, not a screen of their own (D254). + const folderPill = layout === 'mobile' && list.name !== 'trash'; return (
{/* Stays at the top of its scrolling pane, so New item and search are always at hand. */} @@ -184,29 +183,61 @@ export function ItemList({ data-testid="search" /> ) : null} - {types.length > 1 ? ( - // The counts are seen only: the list read next announces its length (D245). -
- {[undefined, ...types].map((t) => ( - - ))} + + + ) : null} + {types.length > 1 ? ( + + {/* The counts are seen only: the list read next announces its length (D245). */} +
+ {/* A sheet is titled, as the folders' (D254). */} + {layout === 'mobile' ? ( + + ) : null} + {[undefined, ...types].map((t) => ( + + ))} +
+
+ ) : null}
) : null} diff --git a/apps/web/src/ui/VaultShell.tsx b/apps/web/src/ui/VaultShell.tsx index f89883b..49f14fe 100644 --- a/apps/web/src/ui/VaultShell.tsx +++ b/apps/web/src/ui/VaultShell.tsx @@ -369,9 +369,9 @@ export function VaultShell({ // the layout only styles or hides it. Settings hides the panes without unmounting them. const settings = screen.name === 'settings'; const detailShown = isDetail(screen); - // Mobile drills down: folders and all items, then a folder or the trash, then the detail. + // Mobile drills down: a list, then the detail; its folders are the list's pill (D254). const shown = { - folders: layout === 'desktop' || open || (layout === 'mobile' && screen.name === 'vault'), + folders: layout === 'desktop' || open, list: layout !== 'mobile' || !detailShown, detail: layout !== 'mobile' || detailShown, }; diff --git a/apps/web/src/ui/components.tsx b/apps/web/src/ui/components.tsx index 19f4e77..f6b263d 100644 --- a/apps/web/src/ui/components.tsx +++ b/apps/web/src/ui/components.tsx @@ -19,7 +19,7 @@ import { format } from '../i18n/messages'; import { AboutBuild } from './About'; import { useAbout } from './about-context'; import { useLayout } from './hooks'; -import { Icon } from './icons'; +import { Icon, type IconName } from './icons'; import { useUpdates } from './update-context'; // Touch targets of at least 44 px on coarse pointers (overview §13.1). @@ -246,6 +246,88 @@ export function Overflow({ ); } +/** + * A list filter's pill (D254): its choices open below it in a native popover, or as a bottom sheet + * on a phone. The browser closes it on Escape or a click outside; choosing an entry marked + * `data-choice` closes it too. + */ +export function Pill({ + label, + icon, + on, + children, + ...props +}: ComponentProps<'button'> & { + readonly label: string; + readonly icon?: IconName; + /** A choice other than all is in force. */ + readonly on: boolean; + readonly children: ReactNode; +}) { + const sheet = useLayout() === 'mobile'; + const [open, setOpen] = useState(false); + const id = useId(); + const button = useRef(null); + const popover = useRef(null); + useEffect(() => { + const pop = popover.current; + if (!pop) return; + const place = (e: Event) => { + const at = button.current?.getBoundingClientRect(); + if (!at || (e as ToggleEvent).newState !== 'open') return; + // A sheet sits at the bottom; a popover under its button, inside the viewport. + const width = document.documentElement.clientWidth; + pop.style.top = sheet ? '' : `${String(at.bottom + 4)}px`; + pop.style.left = sheet + ? '' + : `${String(Math.max(16, Math.min(at.left, width - 16 - 256)))}px`; + }; + const toggle = (e: Event) => { + setOpen((e as ToggleEvent).newState === 'open'); + }; + pop.addEventListener('beforetoggle', place); + pop.addEventListener('toggle', toggle); + return () => { + pop.removeEventListener('beforetoggle', place); + pop.removeEventListener('toggle', toggle); + }; + }, [sheet]); + return ( + <> + +
{ + if (e.target instanceof Element && e.target.closest('[data-choice]')) { + e.currentTarget.hidePopover(); + } + }} + > + {children} +
+ + ); +} + const TONES = { danger: 'bg-danger-soft text-danger-text', warning: 'bg-warning-soft text-warning-text', diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx index d1d7bd1..f8c64ff 100644 --- a/apps/web/src/ui/icons/index.tsx +++ b/apps/web/src/ui/icons/index.tsx @@ -7,6 +7,7 @@ import type { ReactNode } from 'react'; const PATHS = { check: , + 'chevron-down': , 'chevron-left': , 'chevron-right': , 'code-xml': ( diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e41df1..5413e48 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -434,7 +434,7 @@ and the password generator. Then `docs/record-content.md` vectors, `packages/vau - [x] Release v0.7.1 for another hands-on test - [x] Capture every v0.7.1 screen and approve an HTML prototype of the layout and presentation changes (`docs/prototypes/phase-7b-layout.html`, D254) - [x] Notices: the Preview badge, the storage warning on one line with Details, the backup reminder at the top of the list, a neutral lock badge (D254) -- [ ] Lists: folders and types as pills opening a bottom sheet on a phone, the toast above the bottom navigation (D254) +- [x] Lists: folders and types as pills opening a bottom sheet on a phone, the toast above the bottom navigation (D254) - [ ] Item detail and editor: the header bar, divided field rows, icon buttons on non-secret fields, the fixed Cancel and Save bar, the generator in a dialog or bottom sheet (D254) - [ ] Show and Hide on every password field, masked again on submit, on a screen change and on lock (D254) - [ ] Settings inside the vault frame on tablet and desktop; Devices with the Pair and Re-admit cards (D254)