mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add snippet-library search, sort, empty states, storage monitor (M6, §02/§09D)
This commit is contained in:
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
loadPanelLayout,
|
||||
loadPaneVisibility,
|
||||
loadSnippetSort,
|
||||
savePanelLayout,
|
||||
savePaneVisibility,
|
||||
saveSnippetSort,
|
||||
} from './ux-prefs';
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
@@ -98,3 +100,33 @@ describe('ux-prefs · paneVisibility', () => {
|
||||
expect(stored.paneVisibility.editor).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ux-prefs · snippetSort', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('defaults to Modified, descending when nothing is stored', () => {
|
||||
expect(loadSnippetSort()).toEqual({ by: 'modified', order: 'desc' });
|
||||
});
|
||||
|
||||
it('round-trips a stored sort', () => {
|
||||
saveSnippetSort({ by: 'name', order: 'asc' });
|
||||
expect(loadSnippetSort()).toEqual({ by: 'name', order: 'asc' });
|
||||
});
|
||||
|
||||
it('falls back to defaults for invalid stored values', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ snippetSort: { by: 'bogus', order: 'sideways' } }));
|
||||
expect(loadSnippetSort()).toEqual({ by: 'modified', order: 'desc' });
|
||||
});
|
||||
|
||||
it('preserves panelLayout when writing the sort (separate keys)', () => {
|
||||
savePanelLayout({ libraryWidth: 280 });
|
||||
saveSnippetSort({ by: 'size', order: 'desc' });
|
||||
const stored = JSON.parse(localStorage.getItem(KEY)!) as {
|
||||
panelLayout: { libraryWidth: number };
|
||||
snippetSort: { by: string; order: string };
|
||||
};
|
||||
expect(stored.panelLayout.libraryWidth).toBe(280);
|
||||
expect(stored.snippetSort).toEqual({ by: 'size', order: 'desc' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* These preferences persist **separately** from UserSettings so they can change
|
||||
* frequently (a drag emits many width updates) without rewriting the settings
|
||||
* record. Its own key, `astrolabe:ux-prefs`, holds the snippet sort preference
|
||||
* (lands with M5/M6) and the panel layout (per-pane widths + visibility).
|
||||
* (spec §02 → Sort) and the panel layout (per-pane widths + visibility).
|
||||
*
|
||||
* This slice persists the panel **widths** and per-pane **visibility** (spec §01A).
|
||||
* Read-with-fallback + write-through merge, the same contract as the settings
|
||||
@@ -15,6 +15,13 @@
|
||||
* `localStorage`; everything else goes through these typed functions.
|
||||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_SORT_BY,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type SortBy,
|
||||
type SortOrder,
|
||||
} from '@core/snippet-sort';
|
||||
|
||||
const KEY = 'astrolabe:ux-prefs';
|
||||
|
||||
/** Per-pane widths (px). Optional — a missing field falls back to its default. */
|
||||
@@ -30,9 +37,16 @@ export interface PaneVisibilityPref {
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
/** The persisted library sort (spec §02 → Sort; persists across sessions). */
|
||||
export interface SnippetSortPref {
|
||||
by: SortBy;
|
||||
order: SortOrder;
|
||||
}
|
||||
|
||||
interface StoredPrefs {
|
||||
panelLayout?: PanelLayout;
|
||||
paneVisibility?: PaneVisibilityPref;
|
||||
snippetSort?: Partial<SnippetSortPref>;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -106,3 +120,29 @@ export function savePaneVisibility(visibility: PaneVisibilityPref): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, paneVisibility: { ...current.paneVisibility, ...visibility } });
|
||||
}
|
||||
|
||||
/** Guards against junk in storage — only the known sort fields survive. */
|
||||
function sortBy(v: unknown): SortBy | undefined {
|
||||
return v === 'modified' || v === 'created' || v === 'name' || v === 'size' ? v : undefined;
|
||||
}
|
||||
function sortOrder(v: unknown): SortOrder | undefined {
|
||||
return v === 'asc' || v === 'desc' ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The persisted library sort, falling back to the spec default (Modified, desc)
|
||||
* for anything missing or invalid in storage.
|
||||
*/
|
||||
export function loadSnippetSort(): SnippetSortPref {
|
||||
const stored = readRaw().snippetSort ?? {};
|
||||
return {
|
||||
by: sortBy(stored.by) ?? DEFAULT_SORT_BY,
|
||||
order: sortOrder(stored.order) ?? DEFAULT_SORT_ORDER,
|
||||
};
|
||||
}
|
||||
|
||||
/** Persist the library sort, preserving every other key already in the record. */
|
||||
export function saveSnippetSort(sort: SnippetSortPref): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, snippetSort: { ...current.snippetSort, ...sort } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Snippet-sort orchestration — bridges the (browser-free) SnippetStore's library
|
||||
* sort to the ux-prefs adapter (spec §02 → Sort; persists across sessions). Same
|
||||
* store↔adapter pattern as theme / pane-layout / preferences.
|
||||
*
|
||||
* `initSnippetSort` hydrates the persisted field+direction into the store before
|
||||
* render, so the library opens the way the user left it. `wireSnippetSort`
|
||||
* persists the choice on change — written **immediately** (a sort change is a
|
||||
* single discrete click, not a stream like a resize drag).
|
||||
*
|
||||
* Search is intentionally NOT persisted: it's a transient narrowing of the view,
|
||||
* not a remembered preference (spec §02 — search affects visibility only).
|
||||
*/
|
||||
|
||||
import { loadSnippetSort, saveSnippetSort } from '../infrastructure/ux-prefs';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** Hydrate the persisted library sort into the store. Call before render. */
|
||||
export function initSnippetSort(): void {
|
||||
const { by, order } = loadSnippetSort();
|
||||
// Pass the explicit order so setSort hydrates the exact stored state rather
|
||||
// than running its flip/reset selection logic.
|
||||
useSnippetStore.getState().setSort(by, order);
|
||||
}
|
||||
|
||||
/** Persist the sort on change. Returns a teardown that detaches the subscriber. */
|
||||
export function wireSnippetSort(): () => void {
|
||||
return useSnippetStore.subscribe((state, prev) => {
|
||||
if (state.sortBy === prev.sortBy && state.sortOrder === prev.sortOrder) return;
|
||||
saveSnippetSort({ by: state.sortBy, order: state.sortOrder });
|
||||
});
|
||||
}
|
||||
@@ -410,3 +410,55 @@ describe('renameDatasetRefs', () => {
|
||||
expect(store().renameDatasetRefs('Unknown', 'Other')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('library view state (spec §02 → Search / Sort)', () => {
|
||||
test('setSearch updates the query without touching the active snippet', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
const b = createSnippet({ id: 'b', now: new Date('2026-02-01T00:00:00Z') });
|
||||
store().hydrate([a, b], 'a');
|
||||
|
||||
store().setSearch('bar');
|
||||
expect(store().searchQuery).toBe('bar');
|
||||
expect(store().activeSnippetId).toBe('a'); // search is visibility-only
|
||||
});
|
||||
|
||||
test('defaults to Modified, descending', () => {
|
||||
expect(store().sortBy).toBe('modified');
|
||||
expect(store().sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
test('re-selecting the active field flips direction', () => {
|
||||
expect(store().sortOrder).toBe('desc');
|
||||
store().setSort('modified');
|
||||
expect(store().sortBy).toBe('modified');
|
||||
expect(store().sortOrder).toBe('asc');
|
||||
store().setSort('modified');
|
||||
expect(store().sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
test('selecting a different field switches to it and resets to descending', () => {
|
||||
store().setSort('name'); // from modified/desc → name/desc
|
||||
expect(store().sortBy).toBe('name');
|
||||
expect(store().sortOrder).toBe('desc');
|
||||
|
||||
store().setSort('name'); // flip to asc
|
||||
expect(store().sortOrder).toBe('asc');
|
||||
|
||||
store().setSort('size'); // different field resets to desc, not carrying asc
|
||||
expect(store().sortBy).toBe('size');
|
||||
expect(store().sortOrder).toBe('desc');
|
||||
});
|
||||
|
||||
test('an explicit order hydrates the exact stored state (no flip logic)', () => {
|
||||
store().setSort('name', 'asc');
|
||||
expect(store().sortBy).toBe('name');
|
||||
expect(store().sortOrder).toBe('asc');
|
||||
});
|
||||
|
||||
test('sort changes never touch the active snippet', () => {
|
||||
const a = createSnippet({ id: 'a', now: new Date('2026-01-01T00:00:00Z') });
|
||||
store().hydrate([a], 'a');
|
||||
store().setSort('name');
|
||||
expect(store().activeSnippetId).toBe('a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,12 @@ import {
|
||||
type Snippet,
|
||||
} from '@core/snippet';
|
||||
import { recomputeDatasetRefs, renameDatasetInSpec } from '@core/spec-refs';
|
||||
import {
|
||||
DEFAULT_SORT_BY,
|
||||
DEFAULT_SORT_ORDER,
|
||||
type SortBy,
|
||||
type SortOrder,
|
||||
} from '@core/snippet-sort';
|
||||
|
||||
/** Which version of the active snippet the editor is showing (spec §03D). */
|
||||
export type EditorView = 'draft' | 'published';
|
||||
@@ -44,6 +50,17 @@ export interface SnippetState {
|
||||
*/
|
||||
bufferEpoch: number;
|
||||
|
||||
/**
|
||||
* Library view state (spec §02 → Search / Sort). These affect only which
|
||||
* snippets are shown and in what order — never `activeSnippetId` or any data.
|
||||
* The component derives its visible list from these primitives via `useMemo`
|
||||
* (core `filterAndSortSnippets`), NOT through a selector that builds a fresh
|
||||
* array (which would loop the app — MEMORY → "Zustand stable selectors").
|
||||
*/
|
||||
searchQuery: string;
|
||||
sortBy: SortBy;
|
||||
sortOrder: SortOrder;
|
||||
|
||||
/** Replace the library from storage and choose an active snippet. */
|
||||
hydrate: (snippets: Snippet[], activeId?: string | null) => void;
|
||||
/**
|
||||
@@ -72,6 +89,16 @@ export interface SnippetState {
|
||||
* an unchanged comment. `now` injectable.
|
||||
*/
|
||||
setComment: (id: string, comment: string, now?: Date) => void;
|
||||
/** Set the live search query (spec §02 → Search). Visibility only — never
|
||||
* touches `activeSnippetId` or any snippet data. */
|
||||
setSearch: (query: string) => void;
|
||||
/**
|
||||
* Set the library sort (spec §02 → Sort): re-selecting the **active** field
|
||||
* flips direction; choosing a **different** field switches to it and resets to
|
||||
* descending. Persisted by an orchestration subscriber; never touches the
|
||||
* active snippet. `order` may be passed to hydrate an exact stored state.
|
||||
*/
|
||||
setSort: (by: SortBy, order?: SortOrder) => void;
|
||||
/**
|
||||
* Duplicate the active snippet (spec §02 → Duplicate): flushes the live buffer
|
||||
* into the source draft first so the copy reflects in-progress edits, then
|
||||
@@ -143,6 +170,9 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
draftText: '',
|
||||
editorView: 'draft',
|
||||
bufferEpoch: 0,
|
||||
searchQuery: '',
|
||||
sortBy: DEFAULT_SORT_BY,
|
||||
sortOrder: DEFAULT_SORT_ORDER,
|
||||
|
||||
hydrate: (snippets, activeId) => {
|
||||
const id = activeId !== undefined ? activeId : newestId(snippets);
|
||||
@@ -243,6 +273,21 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
setSearch: (searchQuery) => set({ searchQuery }),
|
||||
|
||||
setSort: (by, order) => {
|
||||
set((s) => {
|
||||
// An explicit order hydrates a stored state directly (orchestration init).
|
||||
if (order !== undefined) return { sortBy: by, sortOrder: order };
|
||||
// Re-selecting the active field flips direction; a different field resets
|
||||
// to descending (spec §02 → Sort).
|
||||
if (by === s.sortBy) {
|
||||
return { sortOrder: s.sortOrder === 'desc' ? 'asc' : 'desc' };
|
||||
}
|
||||
return { sortBy: by, sortOrder: 'desc' };
|
||||
});
|
||||
},
|
||||
|
||||
duplicateActiveSnippet: (now, id) => {
|
||||
// Flush the live buffer into the source draft first, so the copy faithfully
|
||||
// mirrors what the user currently sees, not the last auto-saved draft.
|
||||
@@ -396,6 +441,9 @@ export const useSnippetStore = create<SnippetState>((set, get) => ({
|
||||
draftText: '',
|
||||
editorView: 'draft',
|
||||
bufferEpoch: 0,
|
||||
searchQuery: '',
|
||||
sortBy: DEFAULT_SORT_BY,
|
||||
sortOrder: DEFAULT_SORT_ORDER,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { createSnippet, type Snippet } from './snippet';
|
||||
import {
|
||||
filterAndSortSnippets,
|
||||
snippetMatchesQuery,
|
||||
snippetSortSize,
|
||||
sortSnippets,
|
||||
type SortState,
|
||||
} from './snippet-sort';
|
||||
|
||||
/** Build a snippet with the fields the sort/search cares about. */
|
||||
function snip(over: Partial<Snippet> & { id: string }): Snippet {
|
||||
const base = createSnippet({ id: over.id, now: new Date('2026-01-01T00:00:00Z') });
|
||||
return { ...base, ...over };
|
||||
}
|
||||
|
||||
describe('sortSnippets · fields', () => {
|
||||
const a = snip({
|
||||
id: 'a',
|
||||
name: 'banana',
|
||||
created: '2026-01-03T00:00:00Z',
|
||||
modified: '2026-01-05T00:00:00Z',
|
||||
spec: '{"x":1}',
|
||||
});
|
||||
const b = snip({
|
||||
id: 'b',
|
||||
name: 'Apple',
|
||||
created: '2026-01-01T00:00:00Z',
|
||||
modified: '2026-01-09T00:00:00Z',
|
||||
spec: '{"longer":true,"value":2}',
|
||||
});
|
||||
const c = snip({
|
||||
id: 'c',
|
||||
name: 'cherry',
|
||||
created: '2026-01-08T00:00:00Z',
|
||||
modified: '2026-01-02T00:00:00Z',
|
||||
spec: '{}',
|
||||
});
|
||||
const all = [a, b, c];
|
||||
|
||||
const order = (s: SortState) => sortSnippets(all, s).map((x) => x.id);
|
||||
|
||||
test('modified desc / asc', () => {
|
||||
expect(order({ sortBy: 'modified', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']);
|
||||
expect(order({ sortBy: 'modified', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('created desc / asc', () => {
|
||||
expect(order({ sortBy: 'created', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']);
|
||||
expect(order({ sortBy: 'created', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('name asc / desc — case-insensitive (Apple sorts before banana)', () => {
|
||||
expect(order({ sortBy: 'name', sortOrder: 'asc' })).toEqual(['b', 'a', 'c']);
|
||||
expect(order({ sortBy: 'name', sortOrder: 'desc' })).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('size sorts by published-spec character length', () => {
|
||||
expect(snippetSortSize(c)).toBeLessThan(snippetSortSize(a));
|
||||
expect(snippetSortSize(a)).toBeLessThan(snippetSortSize(b));
|
||||
expect(order({ sortBy: 'size', sortOrder: 'asc' })).toEqual(['c', 'a', 'b']);
|
||||
expect(order({ sortBy: 'size', sortOrder: 'desc' })).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
test('does not mutate the input array', () => {
|
||||
const input = [a, b, c];
|
||||
sortSnippets(input, { sortBy: 'name', sortOrder: 'asc' });
|
||||
expect(input.map((x) => x.id)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sortSnippets · stable tiebreak', () => {
|
||||
test('equal keys break by id deterministically', () => {
|
||||
const ts = '2026-01-01T00:00:00Z';
|
||||
const x = snip({ id: 'x', modified: ts });
|
||||
const y = snip({ id: 'y', modified: ts });
|
||||
const z = snip({ id: 'z', modified: ts });
|
||||
// Whatever the input order, equal modified times resolve by id ascending.
|
||||
expect(
|
||||
sortSnippets([z, x, y], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id),
|
||||
).toEqual(['x', 'y', 'z']);
|
||||
expect(
|
||||
sortSnippets([y, z, x], { sortBy: 'modified', sortOrder: 'asc' }).map((s) => s.id),
|
||||
).toEqual(['x', 'y', 'z']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('snippetMatchesQuery', () => {
|
||||
const s = snip({
|
||||
id: 'a',
|
||||
name: 'Sales Bar Chart',
|
||||
comment: 'Quarterly revenue',
|
||||
draftSpec: '{"mark":"bar","encoding":{"x":{"field":"category"}}}',
|
||||
});
|
||||
|
||||
test('matches across name, comment, and spec content — case-insensitive', () => {
|
||||
expect(snippetMatchesQuery(s, 'sales')).toBe(true); // name, lowercased query
|
||||
expect(snippetMatchesQuery(s, 'REVENUE')).toBe(true); // comment, uppercased query
|
||||
expect(snippetMatchesQuery(s, 'category')).toBe(true); // inside the spec text
|
||||
expect(snippetMatchesQuery(s, 'bar')).toBe(true);
|
||||
});
|
||||
|
||||
test('empty / whitespace query matches everything', () => {
|
||||
expect(snippetMatchesQuery(s, '')).toBe(true);
|
||||
expect(snippetMatchesQuery(s, ' ')).toBe(true);
|
||||
});
|
||||
|
||||
test('no match returns false', () => {
|
||||
expect(snippetMatchesQuery(s, 'pie')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterAndSortSnippets', () => {
|
||||
const a = snip({
|
||||
id: 'a',
|
||||
name: 'Alpha',
|
||||
comment: '',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-01T00:00:00Z',
|
||||
});
|
||||
const b = snip({
|
||||
id: 'b',
|
||||
name: 'Beta',
|
||||
comment: 'alpha note',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-02T00:00:00Z',
|
||||
});
|
||||
const c = snip({
|
||||
id: 'c',
|
||||
name: 'Gamma',
|
||||
comment: '',
|
||||
draftSpec: '{}',
|
||||
modified: '2026-01-03T00:00:00Z',
|
||||
});
|
||||
|
||||
test('filters then sorts', () => {
|
||||
// "alpha" matches a (name) and b (comment); modified-desc puts b first.
|
||||
expect(
|
||||
filterAndSortSnippets([a, b, c], 'alpha', { sortBy: 'modified', sortOrder: 'desc' }).map(
|
||||
(x) => x.id,
|
||||
),
|
||||
).toEqual(['b', 'a']);
|
||||
});
|
||||
|
||||
test('empty query returns all, sorted', () => {
|
||||
expect(
|
||||
filterAndSortSnippets([a, b, c], '', { sortBy: 'modified', sortOrder: 'asc' }).map(
|
||||
(x) => x.id,
|
||||
),
|
||||
).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('no matches returns an empty array', () => {
|
||||
expect(filterAndSortSnippets([a, b, c], 'zzz', { sortBy: 'name', sortOrder: 'asc' })).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Snippet sort + search — pure library-ordering logic (spec §02 → Sort, Search).
|
||||
*
|
||||
* Portable core: no browser APIs, no React. The library's filter+sort pipeline
|
||||
* lives here so it is unit-tested in isolation and the component can derive its
|
||||
* view with a plain `useMemo` over primitive store values (snippets, query,
|
||||
* sortBy, sortOrder) — never inside a Zustand selector that builds a fresh array
|
||||
* (which would loop the app; see MEMORY → "Zustand stable selectors").
|
||||
*
|
||||
* Council resolution (recorded in docs/architecture/10):
|
||||
* - SORT: fields Modified / Created / Name / Size; re-selecting the active field
|
||||
* flips direction, a different field resets to descending (spec §02).
|
||||
* - SEARCH: case-insensitive substring across name + comment + spec content; we
|
||||
* match the **draft** spec text (`draftSpec`) — the working version the user is
|
||||
* editing and what the spec §02 calls "the current working/draft spec text".
|
||||
*/
|
||||
|
||||
import type { Snippet } from './snippet';
|
||||
|
||||
/** The four sortable fields (spec §02 → Sort). */
|
||||
export type SortBy = 'modified' | 'created' | 'name' | 'size';
|
||||
|
||||
/** Sort direction. */
|
||||
export type SortOrder = 'asc' | 'desc';
|
||||
|
||||
/** The library's default ordering: newest changes first (spec §02 → Sort). */
|
||||
export const DEFAULT_SORT_BY: SortBy = 'modified';
|
||||
export const DEFAULT_SORT_ORDER: SortOrder = 'desc';
|
||||
|
||||
export interface SortState {
|
||||
sortBy: SortBy;
|
||||
sortOrder: SortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Size" of a snippet for sorting (spec §02 → "Size sorts by stored snippet
|
||||
* size"). Defined as the **character length of the published spec text** — a
|
||||
* stable storage proxy: it's the persisted bytes-ish footprint of the record's
|
||||
* main payload, doesn't fluctuate with un-published in-progress typing, and needs
|
||||
* no `TextEncoder`. (The library *row* displays the draft's UTF-8 size for an
|
||||
* at-a-glance hint; sorting uses the published text so the order is stable.)
|
||||
*/
|
||||
export function snippetSortSize(snippet: Snippet): number {
|
||||
return snippet.spec.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two snippets by a field, ascending. Name is case-insensitive
|
||||
* (locale-aware); timestamps compare lexicographically (ISO-8601 sorts
|
||||
* chronologically as text); size compares numerically. Ties break by `id` so the
|
||||
* order is **stable** and deterministic across renders.
|
||||
*/
|
||||
function compareAsc(a: Snippet, b: Snippet, by: SortBy): number {
|
||||
let primary = 0;
|
||||
switch (by) {
|
||||
case 'name':
|
||||
primary = a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
break;
|
||||
case 'created':
|
||||
primary = a.created.localeCompare(b.created);
|
||||
break;
|
||||
case 'modified':
|
||||
primary = a.modified.localeCompare(b.modified);
|
||||
break;
|
||||
case 'size':
|
||||
primary = snippetSortSize(a) - snippetSortSize(b);
|
||||
break;
|
||||
}
|
||||
// Stable tiebreak so equal-keyed rows keep a fixed, deterministic order.
|
||||
return primary !== 0 ? primary : a.id.localeCompare(b.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a new array of `snippets` ordered by the given sort state. Pure — never
|
||||
* mutates the input (sorts a copy).
|
||||
*/
|
||||
export function sortSnippets(
|
||||
snippets: readonly Snippet[],
|
||||
{ sortBy, sortOrder }: SortState,
|
||||
): Snippet[] {
|
||||
const dir = sortOrder === 'asc' ? 1 : -1;
|
||||
return [...snippets].sort((a, b) => dir * compareAsc(a, b, sortBy));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a snippet matches a search query (spec §02 → Search). Case-insensitive
|
||||
* substring across the snippet **name**, **comment**, and **spec content** (the
|
||||
* working draft text). An empty/whitespace query matches everything (search
|
||||
* affects visibility only — it never excludes when nothing was asked for).
|
||||
*/
|
||||
export function snippetMatchesQuery(snippet: Snippet, query: string): boolean {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (q === '') return true;
|
||||
return (
|
||||
snippet.name.toLowerCase().includes(q) ||
|
||||
snippet.comment.toLowerCase().includes(q) ||
|
||||
snippet.draftSpec.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The library view: snippets filtered by `query` then ordered by `sort`. This is
|
||||
* the single function the component memoizes over primitive store values.
|
||||
*/
|
||||
export function filterAndSortSnippets(
|
||||
snippets: readonly Snippet[],
|
||||
query: string,
|
||||
sort: SortState,
|
||||
): Snippet[] {
|
||||
const filtered =
|
||||
query.trim() === '' ? [...snippets] : snippets.filter((s) => snippetMatchesQuery(s, query));
|
||||
return sortSnippets(filtered, sort);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { App } from './app/App';
|
||||
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
||||
import { initSettings, wireSettings } from './app/orchestration/settings';
|
||||
import { initSnippetSort, wireSnippetSort } from './app/orchestration/snippet-sort';
|
||||
import { initApp } from './app/orchestration/startup';
|
||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||
import '../styles/base.css';
|
||||
@@ -20,6 +21,11 @@ wirePreviewFitMode();
|
||||
initPanes();
|
||||
wirePanes();
|
||||
|
||||
// Hydrate + persist the library sort (spec §02 → Sort) so the list opens ordered
|
||||
// the way the user left it. Hydrate before render; persist on change.
|
||||
initSnippetSort();
|
||||
wireSnippetSort();
|
||||
|
||||
// Hydrate + persist the full UserSettings record (editor / performance /
|
||||
// formatting) before render, so the editor, preview, and library read the user's
|
||||
// settings from the first paint (spec §07 → Startup load).
|
||||
|
||||
Reference in New Issue
Block a user