Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/e2e/duplicate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
7 changes: 5 additions & 2 deletions apps/web/e2e/folders.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
9 changes: 7 additions & 2 deletions apps/web/e2e/items.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand All @@ -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) {
Expand Down
9 changes: 7 additions & 2 deletions apps/web/e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
20 changes: 16 additions & 4 deletions apps/web/e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -77,26 +78,37 @@ 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.
await button(page, 'More actions').click();
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',
]);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.',
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/ui/Folders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}}
Expand Down
87 changes: 59 additions & 28 deletions apps/web/src/ui/ItemList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
Expand Down Expand Up @@ -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 (
<section aria-label={heading} className="flex min-w-0 flex-col gap-2 p-4">
{/* Stays at the top of its scrolling pane, so New item and search are always at hand. */}
Expand Down Expand Up @@ -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).
<div
role="group"
aria-label={m.vault.filterType}
className="-mx-4 flex gap-1 overflow-x-auto px-4"
>
{[undefined, ...types].map((t) => (
<button
key={t ?? ''}
type="button"
className={CHIP}
aria-pressed={filter === t}
onClick={() => {
setType(t);
}}
{folderPill || types.length > 1 ? (
<div className="flex gap-2">
{folderPill ? (
<Pill
// A new list closes the sheet, a folder just created included.
key={list.name === 'folder' ? hex(list.id) : list.name}
data-testid="folder-pill"
icon="folder"
label={
list.name === 'folder'
? (findNode(tree, list.id)?.folder?.name ?? m.vault.allFolders)
: m.vault.allFolders
}
on={list.name === 'folder'}
>
{t === undefined ? m.vault.allTypes : m.templates[t]}
<span aria-hidden="true" className="ms-1.5 text-xs">
{t === undefined ? all : counts.get(t)}
</span>
</button>
))}
<FolderPane session={session} nav={nav} onChange={onChange} />
</Pill>
) : null}
{types.length > 1 ? (
<Pill
data-testid="type-pill"
label={filter === undefined ? m.vault.allTypes : m.templates[filter]}
on={filter !== undefined}
>
{/* The counts are seen only: the list read next announces its length (D245). */}
<div role="group" aria-label={m.vault.filterType} className="flex flex-col gap-0.5">
{/* A sheet is titled, as the folders' (D254). */}
{layout === 'mobile' ? (
<h2
aria-hidden="true"
className="px-2 py-1 text-xs font-semibold uppercase text-muted"
>
{m.vault.filterType}
</h2>
) : null}
{[undefined, ...types].map((t) => (
<button
key={t ?? ''}
type="button"
data-choice
className={`${ROW} flex items-center justify-between gap-4 aria-pressed:bg-accent-soft aria-pressed:font-medium`}
aria-pressed={filter === t}
onClick={() => {
setType(t);
}}
>
{t === undefined ? m.vault.allTypes : m.templates[t]}
<span aria-hidden="true" className="text-xs text-muted">
{t === undefined ? all : counts.get(t)}
</span>
</button>
))}
</div>
</Pill>
) : null}
</div>
) : null}
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/ui/VaultShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
84 changes: 83 additions & 1 deletion apps/web/src/ui/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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<HTMLButtonElement>(null);
const popover = useRef<HTMLDivElement>(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 (
<>
<button
ref={button}
type="button"
popoverTarget={id}
aria-expanded={open}
data-on={on || undefined}
className={`${TARGET} inline-flex min-w-0 items-center gap-1.5 rounded-full border border-line bg-raised px-3 text-sm hover:bg-subtle data-on:border-accent data-on:bg-accent-soft data-on:text-accent-text`}
{...props}
>
{icon ? <Icon name={icon} className="shrink-0" /> : null}
<span className="truncate">{label}</span>
<Icon name="chevron-down" className="shrink-0" />
</button>
<div
ref={popover}
id={id}
popover="auto"
className={`fixed m-0 overflow-y-auto bg-raised p-1 text-fg shadow-raised ${
sheet
? 'inset-x-0 top-auto bottom-0 max-h-[80dvh] w-full max-w-none rounded-t-card p-2 pb-[max(1.5rem,env(safe-area-inset-bottom))] backdrop:bg-scrim'
: 'inset-auto max-h-[60dvh] w-64 rounded-card border border-line'
}`}
onClick={(e) => {
if (e.target instanceof Element && e.target.closest('[data-choice]')) {
e.currentTarget.hidePopover();
}
}}
>
{children}
</div>
</>
);
}

const TONES = {
danger: 'bg-danger-soft text-danger-text',
warning: 'bg-warning-soft text-warning-text',
Expand Down
Loading
Loading