Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)

This commit is contained in:
2026-06-07 20:00:40 +03:00
parent 9f7bf27b7a
commit 800a313be2
19 changed files with 1461 additions and 16 deletions
+5
View File
@@ -27,6 +27,7 @@ export type IconName =
| 'dataset' // "references a dataset" — Carbon DataTable
| 'delete' // delete — Carbon TrashCan
| 'add' // add / create-new — Carbon Add
| 'search' // live library search — Carbon Search
| 'settings' // per-pane settings disclosure (gear) — Carbon Settings
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
// glyph shows *which* pane it controls by position (left / centre / right).
@@ -50,6 +51,10 @@ const GLYPHS: Record<IconName, ReactNode> = {
<polygon points="17.4141 16 24 9.4141 22.5859 8 16 14.5859 9.4143 8 8 9.4141 14.5859 16 8 22.5859 9.4143 24 16 17.4141 22.5859 24 24 22.5859 17.4141 16" />
),
add: <polygon points="17,15 17,8 15,8 15,15 8,15 8,17 15,17 15,24 17,24 17,17 24,17 24,15" />,
// Carbon Search — magnifying glass, the active-search affordance (council SEARCH).
search: (
<path d="M29,27.5859l-7.5521-7.5521a11.0177,11.0177,0,1,0-1.4141,1.4141L27.5859,29ZM4,13a9,9,0,1,1,9,9A9.01,9.01,0,0,1,4,13Z" />
),
settings: (
<>
<path d="M27,16.76c0-.25,0-.5,0-.76s0-.51,0-.77l1.92-1.68A2,2,0,0,0,29.3,11L26.94,7a2,2,0,0,0-1.73-1,2,2,0,0,0-.64.1l-2.43.82a11.35,11.35,0,0,0-1.31-.75l-.51-2.52a2,2,0,0,0-2-1.61H13.64a2,2,0,0,0-2,1.61l-.51,2.52a11.48,11.48,0,0,0-1.32.75L7.43,6.06A2,2,0,0,0,6.79,6,2,2,0,0,0,5.06,7L2.7,11a2,2,0,0,0,.41,2.51L5,15.24c0,.25,0,.5,0,.76s0,.51,0,.77L3.11,18.45A2,2,0,0,0,2.7,21L5.06,25a2,2,0,0,0,1.73,1,2,2,0,0,0,.64-.1l2.43-.82a11.35,11.35,0,0,0,1.31.75l.51,2.52a2,2,0,0,0,2,1.61h4.72a2,2,0,0,0,2-1.61l.51-2.52a11.48,11.48,0,0,0,1.32-.75l2.42.82a2,2,0,0,0,.64.1,2,2,0,0,0,1.73-1L29.3,21a2,2,0,0,0-.41-2.51ZM25.21,24l-3.43-1.16a8.86,8.86,0,0,1-2.71,1.57L18.36,28H13.64l-.71-3.55a9.36,9.36,0,0,1-2.7-1.57L6.79,24,4.43,20l2.72-2.4a8.9,8.9,0,0,1,0-3.13L4.43,12,6.79,8l3.43,1.16a8.86,8.86,0,0,1,2.71-1.57L13.64,4h4.72l.71,3.55a9.36,9.36,0,0,1,2.7,1.57L25.21,8,27.57,12l-2.72,2.4a8.9,8.9,0,0,1,0,3.13L27.57,20Z" />
+111 -1
View File
@@ -4,7 +4,7 @@
height: 100%;
}
/* Library toolbar — heading + settings gear (sort/search will join, spec §02). */
/* Library toolbar — heading + date-format settings gear (spec §02 / §07). */
.toolbar {
flex: 0 0 auto;
display: flex;
@@ -47,6 +47,101 @@
background: var(--accent-hover);
}
/* Search + Sort row, pinned above the list (spec §02). */
.controls {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-2);
padding: 0 var(--space-4) var(--space-4);
}
/* Carbon active-search: leading magnifier, optional trailing clear (council). */
.search {
flex: 1 1 auto;
min-width: 0;
position: relative;
display: flex;
align-items: center;
}
.searchIcon {
position: absolute;
left: var(--space-3);
color: var(--text-secondary);
pointer-events: none;
}
.searchInput {
width: 100%;
height: 32px;
/* Room for the leading magnifier and the trailing clear button. */
padding: 0 32px 0 32px;
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.searchInput::placeholder {
color: var(--text-placeholder);
}
.searchInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
border-color: var(--accent);
}
/* Hide the browser's native search clear so we control the affordance. */
.searchInput::-webkit-search-cancel-button {
appearance: none;
}
.searchClear {
position: absolute;
right: var(--space-2);
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: var(--radius);
background: none;
color: var(--text-secondary);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.searchClear:hover {
background: var(--layer-01);
color: var(--text);
}
.searchClear:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* Visually-hidden live region for the search result count (council SEARCH). */
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.list {
list-style: none;
margin: 0;
@@ -57,12 +152,27 @@
border-top: var(--border-width) solid var(--border);
}
/* Empty states (spec §02; council EMPTY STATES) — a title + a one-line hint,
positive and concise. Two variants share this layout: an empty library and a
search with no matches. */
.empty {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-secondary);
font-size: 13px;
padding: var(--space-5) var(--space-4);
}
.emptyTitle {
font-weight: 600;
color: var(--text);
}
.emptyHint {
color: var(--text-secondary);
}
.item {
display: flex;
align-items: stretch;
+92 -2
View File
@@ -5,9 +5,20 @@ import { createSnippet } from '@core/snippet';
import { useSnippetStore } from '../stores/SnippetStore';
import { SnippetLibrary } from './SnippetLibrary';
// StorageMonitor (rendered at the bottom of the pane) fetches an async storage
// estimate on mount; stub it so its setState doesn't fire outside act() and add
// test noise. This suite is about the library, not the monitor.
vi.mock('../infrastructure/storage-estimate', () => ({
readStorageEstimate: () => Promise.resolve({ available: false }),
}));
// React 19 wants this flag set for act() to drive effects without warnings.
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
/** The metadata-panel name field — scoped so the new search input doesn't shadow it. */
const nameInput = () =>
document.querySelector('section[aria-label="Snippet details"] input') as HTMLInputElement;
let container: HTMLDivElement;
let root: Root;
@@ -51,7 +62,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
await Promise.resolve();
});
const name = container.querySelector('input') as HTMLInputElement;
const name = nameInput();
const comment = container.querySelector('textarea') as HTMLTextAreaElement;
expect(name.value).toBe('Bar chart');
expect(comment.value).toBe('a note');
@@ -68,7 +79,7 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
root.render(<SnippetLibrary />);
});
const name = container.querySelector('input') as HTMLInputElement;
const name = nameInput();
act(() => typeInto(name, 'Renamed'));
// Before the debounce fires, the store is unchanged.
expect(useSnippetStore.getState().snippets[0].name).toBe('Old');
@@ -79,6 +90,85 @@ describe('SnippetLibrary metadata panel (spec §02)', () => {
expect(useSnippetStore.getState().snippets[0].name).toBe('Renamed');
});
test('does not loop on a search/sort state change (render-loop guard)', async () => {
// A selector that returned a fresh filtered array would re-render forever
// (MEMORY → "Zustand stable selectors"); the component derives via useMemo.
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
useSnippetStore.getState().hydrate([a, b], 'a');
await act(async () => {
root.render(<SnippetLibrary />);
await Promise.resolve();
});
// Each store change below would throw "Maximum update depth" inside act() if
// a render loop existed. Bounded, finite settling = no loop.
await act(async () => {
useSnippetStore.getState().setSearch('beta');
await Promise.resolve();
});
await act(async () => {
useSnippetStore.getState().setSort('name');
await Promise.resolve();
});
// The list reflects the final derived view (only Beta matches "beta").
const names = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
expect(names.some((t) => t.includes('Beta'))).toBe(true);
expect(names.some((t) => t.includes('Alpha'))).toBe(false);
});
test('search filters the list and the clear control restores it + refocuses input', () => {
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
const b = createSnippet({ id: 'b', name: 'Beta', now: new Date('2026-02-01T00:00:00Z') });
useSnippetStore.getState().hydrate([a, b], 'a');
act(() => {
root.render(<SnippetLibrary />);
});
const search = container.querySelector('input[type="search"]') as HTMLInputElement;
act(() => typeInto(search, 'alpha'));
expect(useSnippetStore.getState().searchQuery).toBe('alpha');
let rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
expect(rows.some((t) => t.includes('Alpha'))).toBe(true);
expect(rows.some((t) => t.includes('Beta'))).toBe(false);
const clear = container.querySelector('button[aria-label="Clear search"]') as HTMLButtonElement;
act(() => clear.click());
expect(useSnippetStore.getState().searchQuery).toBe('');
// Clear returns focus to the input (council SEARCH).
expect(document.activeElement).toBe(container.querySelector('input[type="search"]'));
rows = [...container.querySelectorAll('li')].map((li) => li.textContent ?? '');
expect(rows.some((t) => t.includes('Beta'))).toBe(true);
});
test('shows the no-matches empty state when a search filters everything out', () => {
const a = createSnippet({ id: 'a', name: 'Alpha', now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([a], 'a');
act(() => {
root.render(<SnippetLibrary />);
});
const search = container.querySelector('input[type="search"]') as HTMLInputElement;
act(() => typeInto(search, 'zzz-no-match'));
expect(container.textContent).toContain('No snippets match your search');
});
test('shows the empty-library state when there are no snippets', () => {
useSnippetStore.getState().hydrate([], null);
act(() => {
root.render(<SnippetLibrary />);
});
expect(container.textContent).toContain('No snippets yet');
});
test('Duplicate adds an independent copy and makes it active', async () => {
const s = createSnippet({ id: 'a', name: 'Chart', now: new Date('2026-01-01T00:00:00Z') });
useSnippetStore.getState().hydrate([s], 'a');
+90 -10
View File
@@ -6,11 +6,11 @@
* draft status indicator, relative date, and size (spec §02). Below the list, the
* Selected-Snippet Metadata Panel exposes the active snippet's editable Name and
* Comment (auto-saved), its timestamps and linked datasets, and the Duplicate /
* Delete operations. Search, sort controls, and the storage monitor arrive in
* later milestones.
* Delete operations. A live Search box and a Sort disclosure sit above the list
* (spec §02), and a Storage Monitor pins to the bottom of the pane.
*/
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { formatDate } from '@core/date-format';
import {
@@ -19,6 +19,7 @@ import {
snippetSizeBytes,
type Snippet,
} from '@core/snippet';
import { filterAndSortSnippets } from '@core/snippet-sort';
import { confirm } from '../stores/ConfirmStore';
import { notify } from '../stores/NotificationStore';
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
@@ -26,6 +27,8 @@ import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow, SettingsPopover, TextControl } from './SettingsPopover';
import { SortControl } from './SortControl';
import StorageMonitor from './StorageMonitor';
import styles from './SnippetLibrary.module.css';
/** Date display modes (spec §07 → Formatting). */
@@ -181,10 +184,30 @@ export function SnippetLibrary() {
const selectSnippet = useSnippetStore((s) => s.selectSnippet);
const removeSnippet = useSnippetStore((s) => s.removeSnippet);
const duplicateActiveSnippet = useSnippetStore((s) => s.duplicateActiveSnippet);
const searchQuery = useSnippetStore((s) => s.searchQuery);
const sortBy = useSnippetStore((s) => s.sortBy);
const sortOrder = useSnippetStore((s) => s.sortOrder);
const setSearch = useSnippetStore((s) => s.setSearch);
const formatting = useUserSettingsStore((s) => s.saved.formatting);
const searchRef = useRef<HTMLInputElement>(null);
// Default ordering: newest-modified first (spec §02 → Sort).
const ordered = [...snippets].sort((a, b) => b.modified.localeCompare(a.modified));
// Derive the visible list with useMemo over PRIMITIVE store values — never
// inside a selector that builds a fresh array, which would loop the app
// (MEMORY → "Zustand stable selectors"; the pure pipeline lives in core).
const ordered = useMemo(
() => filterAndSortSnippets(snippets, searchQuery, { sortBy, sortOrder }),
[snippets, searchQuery, sortBy, sortOrder],
);
// Empty-state copy splits two cases (council EMPTY STATES; Carbon two types):
// a genuinely empty library vs. a search that matched nothing.
const searching = searchQuery.trim() !== '';
const noMatches = ordered.length === 0;
const clearSearch = () => {
setSearch('');
searchRef.current?.focus(); // clear returns focus to the input (council SEARCH)
};
const handleDelete = async (id: string, name: string) => {
// In-app confirmation (docs/architecture/03 → confirmation dialogs).
@@ -220,8 +243,7 @@ export function SnippetLibrary() {
return (
<div className={styles.library}>
{/* Library toolbar: heading + the date-format settings gear (sort/search
controls will join it here per spec §02). */}
{/* Library toolbar: heading + the date-format settings gear. */}
<div className={styles.toolbar}>
<span className={styles.heading}>Snippets</span>
<LibrarySettings />
@@ -234,16 +256,70 @@ export function SnippetLibrary() {
<Icon name="add" /> Create New Snippet
</button>
{/* Search + Sort controls, pinned above the list (spec §02; council
SEARCH + SORT). Search is an unlabelled Carbon active-search: a search
input with a leading magnifier and a clear (✕) that appears when
non-empty and returns focus to the input. Sort is a disclosure whose
trigger shows the current order for recognition. */}
<div className={styles.controls}>
<div className={styles.search}>
<Icon name="search" className={styles.searchIcon} />
<input
ref={searchRef}
type="search"
className={styles.searchInput}
// No visible label — the magnifier + placeholder name it (council SEARCH).
aria-label="Search snippets"
placeholder="Search snippets"
value={searchQuery}
onChange={(e) => setSearch(e.target.value)}
spellCheck={false}
/>
{searching && (
<button
type="button"
className={styles.searchClear}
aria-label="Clear search"
title="Clear search"
onClick={clearSearch}
>
<Icon name="close" />
</button>
)}
</div>
<SortControl />
</div>
{/* Polite result-count announcement (council SEARCH; Carbon "always
include the number of results, including no results"). Only announces
while searching, so it stays quiet for the default full list. */}
<div className={styles.srOnly} role="status" aria-live="polite">
{searching ? `${ordered.length} ${ordered.length === 1 ? 'result' : 'results'}` : ''}
</div>
<ul className={styles.list}>
{ordered.length === 0 && (
{noMatches && searching && (
// Empty state (b): a search that matched nothing (council EMPTY STATES).
<li className={styles.empty}>
No snippets yet create your first one with the button above.
<span className={styles.emptyTitle}>No snippets match your search</span>
<span className={styles.emptyHint}>Try a different term.</span>
</li>
)}
{noMatches && !searching && (
// Empty state (a): a genuinely empty library — guide to Create.
<li className={styles.empty}>
<span className={styles.emptyTitle}>No snippets yet</span>
<span className={styles.emptyHint}>Create your first one with the button above.</span>
</li>
)}
{ordered.map((s) => {
// Size is omitted under ~1 KB per spec §02; null collapses the suffix.
const size = formatSnippetSize(snippetSizeBytes(s));
const date = formatDate(s.modified, formatting.dateFormat, formatting.customDateFormat);
// When sorting by Created, the row shows the created date instead of
// modified, so the visible date matches the ordering key (spec §02 →
// List Item).
const stamp = sortBy === 'created' ? s.created : s.modified;
const date = formatDate(stamp, formatting.dateFormat, formatting.customDateFormat);
return (
<li key={s.id} className={`${styles.item} ${s.id === activeId ? styles.active : ''}`}>
{/* The row's selectable area is a real <button> so it's keyboard
@@ -309,6 +385,10 @@ export function SnippetLibrary() {
onDelete={() => void handleDelete(activeSnippet.id, activeSnippet.name)}
/>
)}
{/* Storage Monitor (spec §02 → Storage Monitor) — pinned at the bottom of
the library pane, below the metadata panel. Built by a parallel agent. */}
<StorageMonitor />
</div>
);
}
+96
View File
@@ -0,0 +1,96 @@
/* SortControl — the library sort disclosure (spec §02 → Sort; arch 10). */
.wrap {
position: relative;
display: inline-flex;
}
/* Trigger — shows the current field + direction for recognition (council SORT). */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
height: 28px;
padding: 0 var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.trigger:hover {
background: var(--layer-01);
color: var(--text);
}
.trigger[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* The disclosed panel — portaled to <body>, positioned fixed (mirrors the
settings popover) so it escapes the panes' overflow clipping. */
.pop {
position: fixed;
z-index: 1000;
min-width: 160px;
padding: var(--space-2);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.title {
margin: 0 0 var(--space-2);
padding: 0 var(--space-2);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
.field {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.field:hover {
background: var(--layer-02);
}
.field:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* The active field is the current sort; its trailing arrow shows the direction. */
.active {
font-weight: 600;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* SortControl — the library's sort disclosure (spec §02 → Sort; council SORT).
*
* A disclosure + non-modal popover, the same APG primitive as SettingsPopover
* (docs/architecture/10 → "settings are … a disclosure popover") — deliberately
* NOT an ARIA menu. It differs from SettingsPopover only in its **trigger**: per
* NN/g recognition-over-recall the trigger shows the current state ("Sort:
* Modified ↓") instead of a bare gear, so the user reads the order without
* opening the popover. The body is a labelled `group` of field buttons (radio
* group), each showing a direction arrow on the active field.
*
* Selection model (spec §02): re-selecting the ACTIVE field flips direction;
* selecting a DIFFERENT field switches to it and resets to descending — all
* encapsulated in `SnippetStore.setSort`. Keyboard/focus (APG disclosure): the
* shared `useSettingsPopoverStore` makes at most one popover open at a time;
* Enter/Space toggle the trigger; Esc closes and returns focus to the trigger;
* an outside click closes. The panel is portaled to <body> and positioned fixed
* because the panes clip their content.
*/
import { useCallback, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import type { SortBy } from '@core/snippet-sort';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import { useSnippetStore } from '../stores/SnippetStore';
import styles from './SortControl.module.css';
/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */
const GAP = 6;
const ID = 'library-sort';
/** Field labels in display order (the order the popover lists them). */
const FIELDS: ReadonlyArray<{ value: SortBy; label: string }> = [
{ value: 'modified', label: 'Modified' },
{ value: 'created', label: 'Created' },
{ value: 'name', label: 'Name' },
{ value: 'size', label: 'Size' },
];
function labelFor(by: SortBy): string {
return FIELDS.find((f) => f.value === by)?.label ?? by;
}
export function SortControl() {
const sortBy = useSnippetStore((s) => s.sortBy);
const sortOrder = useSnippetStore((s) => s.sortOrder);
const setSort = useSnippetStore((s) => s.setSort);
const open = useSettingsPopoverStore((s) => s.openId === ID);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const arrow = sortOrder === 'desc' ? '↓' : '↑';
// Position the (fixed) panel from the trigger's rect — no React state, so no
// re-render on scroll. Aligns to the trigger's left edge (the library pane is
// narrow; opening rightward keeps the panel on-screen).
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
pop.style.top = `${r.bottom + GAP}px`;
pop.style.left = `${r.left}px`;
pop.style.right = 'auto';
}, []);
useEffect(() => {
if (!open) return;
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [open, place]);
// Esc closes + restores focus to the trigger; an outside pointer click closes
// (APG disclosure; non-modal). Capture Esc so it settles here, not a parent.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
triggerRef.current?.focus();
}
};
const onPointer = (e: PointerEvent) => {
const t = e.target as Node;
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('keydown', onKey, true);
document.addEventListener('pointerdown', onPointer, true);
return () => {
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('pointerdown', onPointer, true);
};
}, [open, close]);
// On mount, position before paint and move focus to the active field button so
// keyboard users land inside the popover.
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
node.querySelector<HTMLElement>('[aria-checked="true"], button')?.focus();
}
},
[place],
);
return (
<div className={styles.wrap}>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-expanded={open}
aria-controls={ID}
// Trigger shows current state for recognition (council SORT, NN/g #6).
title={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
onClick={() => toggle(ID)}
>
Sort: {labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
</button>
{open &&
createPortal(
<div
ref={setPopNode}
id={ID}
className={styles.pop}
role="group"
aria-label="Sort snippets"
>
<h4 className={styles.title}>Sort by</h4>
{FIELDS.map((f) => {
const active = f.value === sortBy;
return (
<button
key={f.value}
type="button"
className={`${styles.field} ${active ? styles.active : ''}`}
// The active field carries the direction in its accessible name
// so AT hears "Modified, descending", not just "Modified".
aria-label={
active
? `${f.label}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`
: f.label
}
onClick={() => setSort(f.value)}
>
<span>{f.label}</span>
{active && <span aria-hidden="true">{arrow}</span>}
</button>
);
})}
</div>,
document.body,
)}
</div>
);
}
@@ -0,0 +1,86 @@
/*
* Storage Monitor — token-based styles.
* All colours are role tokens; no raw hexes, no hardcoded hues (arch 09 §3.3).
* Three visual levels — ok / warning / critical — driven by state classes on
* the root element; the fill bar picks up the level colour from a CSS var
* resolved locally so the cascade stays flat.
*/
.monitor {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-top: var(--border-width) solid var(--border);
/* Default fill colour token; overridden by level classes below. */
--fill-color: var(--accent);
}
/* Warning level — cautionary amber (uses the contrast-safe fg token so it
clears 4.5:1 on light surfaces; the raw yellow fails — arch 09 §3.3). */
.warning {
--fill-color: var(--support-warning-fg);
}
/* Critical level — error red. */
.critical {
--fill-color: var(--support-error);
}
/* Usage text row: "1.2 MB of 5.0 MB" */
.label {
display: flex;
align-items: baseline;
gap: 0;
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
}
.used {
font-weight: 600;
color: var(--text);
font-variant-numeric: tabular-nums;
}
.separator {
color: var(--text-secondary);
}
.quota {
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
/* The track that contains the fill bar. */
.track {
width: 100%;
height: 4px;
background: var(--layer-02, var(--border));
border-radius: var(--radius);
overflow: hidden;
}
/* The coloured fill — width is set inline from fraction; colour via --fill-color. */
.fill {
height: 100%;
background: var(--fill-color);
transition: width var(--dur-moderate) var(--ease);
}
/* The polite live-region announcement (warning / critical copy). Visually
muted; assistive tech reads it because of role="status" + aria-live="polite". */
.announcement {
margin: 0;
font-size: 11px;
color: var(--fill-color);
line-height: 1.4;
}
/* Suppress the fill bar transition for users who prefer reduced motion (arch 09 §3.5). */
@media (prefers-reduced-motion: reduce) {
.fill {
transition: none;
}
}
+199
View File
@@ -0,0 +1,199 @@
/**
* StorageMonitor render tests.
*
* Strategy: mock readStorageEstimate (the async browser adapter) so tests run
* in happy-dom without a real Storage Manager, then assert on the rendered DOM
* — text content, ARIA attributes, and CSS-module class presence for each level.
*
* CSS Modules are identity-mapped in Vitest's happy-dom environment (class names
* come through as-is), so we match on the raw class name tokens from the .module.css.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { StorageSummary } from '@core/storage-estimate';
import StorageMonitor from './StorageMonitor';
// Mock the browser adapter — tests must not touch navigator.storage.
vi.mock('../infrastructure/storage-estimate', () => ({
readStorageEstimate: vi.fn(),
}));
import { readStorageEstimate } from '../infrastructure/storage-estimate';
const mockEstimate = vi.mocked(readStorageEstimate);
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
// ---- helpers ----------------------------------------------------------------
/** Build a complete StorageSummary for test fixtures. */
function makeSummary(
overrides: Partial<StorageSummary> &
Pick<StorageSummary, 'usedBytes' | 'quotaBytes' | 'fraction' | 'level'>,
): StorageSummary {
return {
available: true,
...overrides,
};
}
// ---- test setup -------------------------------------------------------------
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
/** Render the component and wait for the async estimate to resolve. */
async function renderMonitor() {
await act(async () => {
root.render(<StorageMonitor />);
await Promise.resolve(); // flush the async readStorageEstimate() resolution
});
}
// ---- tests ------------------------------------------------------------------
describe('StorageMonitor', () => {
describe('when the estimate is available and at ok level', () => {
const okSummary = makeSummary({
usedBytes: 1.2 * 1024 * 1024, // ~1.2 MB
quotaBytes: 5 * 1024 * 1024, // 5 MB
fraction: 0.24,
level: 'ok',
});
test('renders usage text: used of quota', async () => {
mockEstimate.mockResolvedValue(okSummary);
await renderMonitor();
const label = container.querySelector('[aria-label="Storage usage"]');
expect(label).not.toBeNull();
// The humanizeBytes output for 1.2 MB appears somewhere in the label area.
expect(label!.textContent).toContain('MB');
expect(label!.textContent).toContain(' of ');
});
test('renders a meter element with ARIA attributes', async () => {
mockEstimate.mockResolvedValue(okSummary);
await renderMonitor();
const meter = container.querySelector('[role="meter"]');
expect(meter).not.toBeNull();
expect(meter!.getAttribute('aria-valuemin')).toBe('0');
expect(meter!.getAttribute('aria-valuemax')).toBe('100');
// aria-valuenow should be 24 (24% from fraction 0.24)
expect(meter!.getAttribute('aria-valuenow')).toBe('24');
expect(meter!.getAttribute('aria-valuetext')).toContain('%');
});
test('applies the ok class (no warning/critical) to the root element', async () => {
mockEstimate.mockResolvedValue(okSummary);
await renderMonitor();
const root_ = container.firstElementChild as HTMLElement | null;
expect(root_).not.toBeNull();
expect(root_!.className).toContain('ok');
expect(root_!.className).not.toContain('warning');
expect(root_!.className).not.toContain('critical');
});
test('does not render a live-region announcement at ok level', async () => {
mockEstimate.mockResolvedValue(okSummary);
await renderMonitor();
expect(container.querySelector('[role="status"]')).toBeNull();
});
});
describe('when the estimate is at warning level', () => {
const warnSummary = makeSummary({
usedBytes: 4.2 * 1024 * 1024,
quotaBytes: 5 * 1024 * 1024,
fraction: 0.84,
level: 'warning',
});
test('applies the warning class to the root element', async () => {
mockEstimate.mockResolvedValue(warnSummary);
await renderMonitor();
const root_ = container.firstElementChild as HTMLElement | null;
expect(root_!.className).toContain('warning');
expect(root_!.className).not.toContain('critical');
});
test('renders a polite live-region with warning copy', async () => {
mockEstimate.mockResolvedValue(warnSummary);
await renderMonitor();
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
expect(status!.getAttribute('aria-live')).toBe('polite');
expect(status!.textContent).toBeTruthy();
});
test('meter aria-valuenow reflects the fraction', async () => {
mockEstimate.mockResolvedValue(warnSummary);
await renderMonitor();
const meter = container.querySelector('[role="meter"]');
expect(meter!.getAttribute('aria-valuenow')).toBe('84');
});
});
describe('when the estimate is at critical level', () => {
const critSummary = makeSummary({
usedBytes: 4.9 * 1024 * 1024,
quotaBytes: 5 * 1024 * 1024,
fraction: 0.98,
level: 'critical',
});
test('applies the critical class to the root element', async () => {
mockEstimate.mockResolvedValue(critSummary);
await renderMonitor();
const root_ = container.firstElementChild as HTMLElement | null;
expect(root_!.className).toContain('critical');
expect(root_!.className).not.toContain('warning');
});
test('renders a polite live-region with critical copy mentioning deletion', async () => {
mockEstimate.mockResolvedValue(critSummary);
await renderMonitor();
const status = container.querySelector('[role="status"]');
expect(status).not.toBeNull();
expect(status!.getAttribute('aria-live')).toBe('polite');
// Critical copy must direct user to delete snippets.
expect(status!.textContent?.toLowerCase()).toContain('delete');
});
test('meter aria-valuenow is 98 and aria-valuetext contains the used bytes', async () => {
mockEstimate.mockResolvedValue(critSummary);
await renderMonitor();
const meter = container.querySelector('[role="meter"]');
expect(meter!.getAttribute('aria-valuenow')).toBe('98');
expect(meter!.getAttribute('aria-valuetext')).toContain('98%');
});
});
describe('graceful unavailable case', () => {
const unavailableSummary: StorageSummary = {
available: false,
usedBytes: 0,
quotaBytes: 0,
fraction: 0,
level: 'ok',
};
test('renders nothing when the Storage Manager API is unavailable', async () => {
mockEstimate.mockResolvedValue(unavailableSummary);
await renderMonitor();
// The component should return null — no DOM output.
expect(container.firstElementChild).toBeNull();
});
});
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Storage Monitor — library pane footer (spec §02 → Storage Monitor;
* spec §10 → "Warn before storage failure"; docs/architecture/10 §1 status
* indicator channel).
*
* Fetches the browser storage estimate on mount (readStorageEstimate), runs it
* through summarizeStorage, and renders:
* - human-readable "used of quota" text
* - a fill bar (meter) reflecting the percentage used
* - escalating visual treatment at warning / critical levels via design tokens
*
* When the Storage Manager API is unavailable (feature-detected by the adapter)
* the component renders nothing — a missing ambient indicator is harmless, and
* surfacing a "unavailable" line adds noise with no actionable value.
*
* Critical state is announced politely via `aria-live="polite"` so assistive
* technology is informed without interrupting the user mid-task (arch 10 §5 —
* status indicators are ambient, not assertive).
*/
import { useEffect, useState } from 'react';
import { humanizeBytes, type StorageSummary } from '@core/storage-estimate';
import { readStorageEstimate } from '../infrastructure/storage-estimate';
import styles from './StorageMonitor.module.css';
/** Level-to-label for the accessible announcement copy. */
const LEVEL_LABEL: Record<StorageSummary['level'], string> = {
ok: '',
warning: 'Storage is getting full.',
critical: 'Storage is almost full. Delete snippets to free space.',
};
export default function StorageMonitor() {
const [summary, setSummary] = useState<StorageSummary | null>(null);
useEffect(() => {
let cancelled = false;
void readStorageEstimate().then((s) => {
if (!cancelled) setSummary(s);
});
return () => {
cancelled = true;
};
}, []);
// While loading, or when the API is unavailable, render nothing.
if (!summary || !summary.available) return null;
const usedText = humanizeBytes(summary.usedBytes);
const quotaText = humanizeBytes(summary.quotaBytes);
const pct = Math.round(summary.fraction * 100);
const announcement = LEVEL_LABEL[summary.level];
return (
<div className={`${styles.monitor} ${styles[summary.level]}`} aria-label="Storage usage">
{/* Usage text */}
<div className={styles.label}>
<span className={styles.used}>{usedText}</span>
<span className={styles.separator}> of </span>
<span className={styles.quota}>{quotaText}</span>
</div>
{/*
* Fill bar — ARIA meter (WAI-ARIA 1.1).
* role="meter" conveys a scalar value within a known range; aria-valuetext
* gives a human-readable reading that matches the visible label.
*/}
<div
role="meter"
aria-label="Storage used"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-valuetext={`${usedText} of ${quotaText} (${pct}%)`}
className={styles.track}
>
<div className={styles.fill} style={{ width: `${pct}%` }} />
</div>
{/*
* Polite live region — announces the warning / critical state to assistive
* technology without interrupting ongoing work. Empty for the 'ok' level so
* there is no announcement when storage is healthy (arch 10 §1 — status
* indicators are ambient, not assertive; only escalation warrants notice).
*/}
{announcement && (
<p role="status" aria-live="polite" className={styles.announcement}>
{announcement}
</p>
)}
</div>
);
}