+ {/* `media`: filtered as a unit by night mode; see DonatePage. */}
+
- Close
+ Close
-
-
-
+
>
-}
\ No newline at end of file
+}
diff --git a/app/src/components/SortableTable.js b/app/src/components/SortableTable.js
index 0c238f29..6dd82f9c 100644
--- a/app/src/components/SortableTable.js
+++ b/app/src/components/SortableTable.js
@@ -1,7 +1,6 @@
import React from "react";
import _ from "lodash";
-import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
-import {Table} from "./Theme";
+import {Table} from "./ui";
import {TextPlaceholder} from "./Placeholder";
export class SortableTable extends React.Component {
@@ -39,29 +38,29 @@ export class SortableTable extends React.Component {
render() {
let {rowFunc, tableHeaders, rowKey} = this.props;
- let {direction} = this.state;
+ let {direction, sortColumn} = this.state;
rowKey = rowKey || 'key';
const data = this.props['data'] ? this.sortData(this.props['data']) : null;
const tableHeader = (spec) => {
- const {key, text, sortBy, width} = spec;
+ const {key, text, sortBy} = spec;
if (sortBy) {
- return
this.changeSort(key)}
- width={width || undefined}
+ sorted={active ? direction : null}
+ onSort={() => this.changeSort(key)}
>
{text}
- ;
+ ;
} else {
- return
{text}
+ return
{text}
}
}
// Use placeholder while data is null.
- let rows =
;
+ let rows =
;
if (data !== null) {
if (data.length > 0) {
// Convert data to table rows.
@@ -69,19 +68,19 @@ export class SortableTable extends React.Component {
} else {
// No results in data.
rows = this.props.emptyRow
- ||
No results ;
+ ||
No results ;
}
}
- return
-
-
+ return
+
+
{tableHeaders.map(tableHeader)}
-
-
-
+
+
+
{rows}
-
+
{this.props.footer}
}
diff --git a/app/src/components/Tables.js b/app/src/components/Tables.js
index 7241012e..d5bfea53 100644
--- a/app/src/components/Tables.js
+++ b/app/src/components/Tables.js
@@ -1,27 +1,26 @@
-import {Table} from "./Theme";
-import {Checkbox, TableBody, TableCell, TableFooter, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react";
+import {Checkbox, Table} from "./ui";
import React from "react";
export function SelectableTable({headerContents, selectOn, onSelect, selectedKeys, footer, rows}) {
- footer = footer ?
-
+ footer = footer ?
+
{footer}
-
- : null;
+
+ : null;
- return
-
-
- {selectOn && }
- {headerContents.map(i => {i} )}
-
-
-
+ return
+
+
+ {selectOn && }
+ {headerContents.map(i => {i} )}
+
+
+
{rows.map(row =>
{row}
)}
-
+
{footer}
}
@@ -32,19 +31,19 @@ function SelectableRow(props) {
const c = selectedKeys && selectedKeys.indexOf(key) >= 0;
let selectCell;
if (selectOn) {
- const localOnSelect = (e, {checked}) => {
+ const localOnSelect = (e) => {
try {
- onSelect(key, checked);
+ onSelect(key, e.currentTarget.checked);
} catch (e) {
console.error('No onSelect declared');
}
};
- selectCell =
+ selectCell =
- ;
+ ;
}
- return
+ return
{selectCell}
{props.children}
-
+
}
diff --git a/app/src/components/ThemeSamplePage.js b/app/src/components/ThemeSamplePage.js
index 67650f84..0777ae96 100644
--- a/app/src/components/ThemeSamplePage.js
+++ b/app/src/components/ThemeSamplePage.js
@@ -14,12 +14,16 @@ import {
Loading,
Message,
Modal,
+ Pagination,
Panel,
Placeholder,
Progress,
+ SearchBox,
Select,
Statistic,
StatisticGroup,
+ TabBar,
+ tabClassName,
Status,
Table,
TextInput,
@@ -63,6 +67,9 @@ export function ThemeSamplePage() {
const [dismissed, setDismissed] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [realColors, setRealColors] = useState(false);
+ const [search, setSearch] = useState('');
+ const [activeTab, setActiveTab] = useState('Videos');
+ const [page, setPage] = useState(3);
return
Component gallery
@@ -211,8 +218,8 @@ export function ThemeSamplePage() {
- URL
- Downloader
+ {}}>URL
+ {}}>Downloader
Progress
Status
Size
@@ -293,6 +300,56 @@ export function ThemeSamplePage() {
+
+
+ toast({type: 'info', title: 'Searched', description: value})}
+ onResultSelect={result => setSearch(result.title)}
+ results={{
+ directories: {
+ name: 'Directories',
+ results: [{title: 'videos/'}, {title: 'archive/'}],
+ },
+ channels: {
+ name: 'Channels',
+ results: [
+ {title: 'videos/Wranglerstar', description: 'Wranglerstar'},
+ {title: 'videos/RoseRed Homestead', description: 'RoseRed Homestead'},
+ ],
+ },
+ }}
+ clearable
+ placeholder='Search directory names…'
+ />
+
+ Focus the field to see grouped suggestions. Arrow keys move, Enter takes the
+ highlighted one or submits what you typed, Escape closes the list without
+ clearing the text.
+
+
+
+
+
+
+ 1,432 videos}>
+ {['Videos', 'Channels', 'Playlists'].map(tab => setActiveTab(tab)}
+ >{tab} )}
+
+
+
+ Night and amber mark the current page with a heavier border rather than a
+ filled block, which would be a bright surface. The tab bar takes rendered
+ children, so routing stays with the caller.
+
+
+
+
diff --git a/app/src/components/calculators/QRCodeCalculator.js b/app/src/components/calculators/QRCodeCalculator.js
index 5ad22847..6446642a 100644
--- a/app/src/components/calculators/QRCodeCalculator.js
+++ b/app/src/components/calculators/QRCodeCalculator.js
@@ -1,50 +1,36 @@
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import React from "react";
-import {Header} from "../Theme";
import QRCode from "react-qr-code";
-import {Input} from "semantic-ui-react";
+import {Header, Stack, TextInput} from "../ui";
import {Media} from "../../contexts/contexts";
export function QRCodeCalculator() {
const [value, setValue] = React.useState('');
- return
-
-
-
-
-
-
-
- setValue(e.target.value)} placeholder='Enter link, etc.'/>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-}
\ No newline at end of file
+ return
+
+ setValue(e.target.value)} placeholder='Enter link, etc.'/>
+
+
+
+
+
+
+
+
+
+
+
+}
diff --git a/app/src/components/calculators/TemperatureCalculator.js b/app/src/components/calculators/TemperatureCalculator.js
index 979031bb..503bf1d6 100644
--- a/app/src/components/calculators/TemperatureCalculator.js
+++ b/app/src/components/calculators/TemperatureCalculator.js
@@ -1,7 +1,6 @@
import React from "react";
import {roundDigits} from "../Common";
-import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
-import {GridColumn, GridRow, Input} from "semantic-ui-react";
+import {Grid, NumberInput} from "../ui";
export function TemperatureCalculator() {
const [state, setState] = React.useState({
@@ -10,14 +9,15 @@ export function TemperatureCalculator() {
kelvin: '',
})
- const handleInputChange = (e, {name, value}) => {
- if (value === '') {
+ const handleInputChange = (name, value) => {
+ if (value === '' || value === undefined || value === null) {
// User has cleared the input;
setState({celsius: '', fahrenheit: '', kelvin: ''});
return
}
- const numValue = parseFloat(value.endsWith('.') ? `${value}0` : value);
+ const stringValue = `${value}`;
+ const numValue = parseFloat(stringValue.endsWith('.') ? `${stringValue}0` : stringValue);
console.debug(`Calculating ${value} ${name}`);
let newCelsius;
let newKelvin;
@@ -52,25 +52,30 @@ export function TemperatureCalculator() {
e.target.select();
}
- const commonInputProps = {
- fluid: true,
- labelPosition: 'right',
- type: 'number',
- onChange: handleInputChange,
- onClick: handleClick
- };
-
- return
-
-
-
-
-
-
-
-
-
-
-
+ return
+
+ handleInputChange('celsius', value)}
+ onClick={handleClick}
+ />
+
+
+ handleInputChange('fahrenheit', value)}
+ onClick={handleClick}
+ />
+
+
+ handleInputChange('kelvin', value)}
+ onClick={handleClick}
+ />
+
}
diff --git a/app/src/components/calculators/VinDecoderCalculator.js b/app/src/components/calculators/VinDecoderCalculator.js
index eeefa6aa..a1839658 100644
--- a/app/src/components/calculators/VinDecoderCalculator.js
+++ b/app/src/components/calculators/VinDecoderCalculator.js
@@ -1,37 +1,37 @@
-import React, {useContext} from 'react';
-import {Input, TableCell, TableRow} from "semantic-ui-react";
-import {ThemeContext} from "../../contexts/contexts";
+import React from 'react';
import {useVINDecoder} from "../../hooks/customHooks";
-import {Header, Table} from "../Theme";
+import {Header, Table, TextInput} from "../ui";
export function VinDecoderCalculator() {
- const {t} = useContext(ThemeContext);
-
const basicKeys = ['Country', 'Manufacturer', 'Region', 'Years'];
const detailsKeys = ['Body', 'Engine', 'Model', 'Plant', 'Transmission', 'Serial'];
const {value, setValue, vin} = useVINDecoder();
- let body = Enter a VIN number above
;
+ let body = Enter a VIN number above
;
if (value && !vin) {
- body = VIN number is invalid
;
+ body = VIN number is invalid
;
}
if (vin && vin['country']) {
- let details = No details
;
+ let details = No details
;
if (vin['body']) {
- details =
- {detailsKeys.map(i =>
- {i}
- {vin[i.toLowerCase()] || '(Unknown)'}
- )}
+ details =
+
+ {detailsKeys.map(i =>
+ {i}
+ {vin[i.toLowerCase()] || '(Unknown)'}
+ )}
+
;
}
body = <>
-
- {basicKeys.map(i =>
- {i}
- {vin[i.toLowerCase()]}
- )}
+
+
+ {basicKeys.map(i =>
+ {i}
+ {vin[i.toLowerCase()]}
+ )}
+
{details}
@@ -40,8 +40,8 @@ export function VinDecoderCalculator() {
return <>
- setValue(e.target.value)}
diff --git a/app/src/components/ui/DataTable.tsx b/app/src/components/ui/DataTable.tsx
index 81119a44..52532d98 100644
--- a/app/src/components/ui/DataTable.tsx
+++ b/app/src/components/ui/DataTable.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import {Table as MTable, TableProps as MTableProps} from '@mantine/core';
+import {Icon} from './Icon';
/*
* Tables.
@@ -54,19 +55,55 @@ function Cell({numeric, style, ...props}: CellProps) {
/>
}
+export type SortDirection = 'ascending' | 'descending';
+
+export interface HeaderCellProps extends React.ComponentPropsWithoutRef<'th'> {
+ /** This column's current sort, or null/undefined when it is not the sorted one. */
+ sorted?: SortDirection | null;
+ /** Makes the header a sort control. Receives nothing; the caller knows its column. */
+ onSort?: () => void;
+}
+
+/**
+ * A header cell, optionally a sort control.
+ *
+ * When `onSort` is given the label becomes a real `` — a click handler on
+ * the `` alone is unreachable by keyboard — and the cell carries `aria-sort`,
+ * so the sort state is announced rather than left to the arrow glyph.
+ */
+function HeaderCell({sorted, onSort, children, ...props}: HeaderCellProps) {
+ if (!onSort) return {children}
+
+ return
+
+ {children}
+
+
+
+}
+
export const Table = Object.assign(TableBase, {
Header: MTable.Thead,
Body: MTable.Tbody,
Footer: MTable.Tfoot,
Row,
Cell,
- HeaderCell: MTable.Th,
+ HeaderCell,
// Mantine's own names, for new code.
Thead: MTable.Thead,
Tbody: MTable.Tbody,
Tfoot: MTable.Tfoot,
Tr: Row,
Td: Cell,
- Th: MTable.Th,
+ Th: HeaderCell,
ScrollContainer: MTable.ScrollContainer,
});
diff --git a/app/src/components/ui/Icon.tsx b/app/src/components/ui/Icon.tsx
index dffd2c29..c505a43a 100644
--- a/app/src/components/ui/Icon.tsx
+++ b/app/src/components/ui/Icon.tsx
@@ -92,6 +92,7 @@ const semanticNames: Record = {
'map outline': 'IconMap',
'microchip': 'IconCpu',
'minus': 'IconMinus',
+ 'paypal': 'IconBrandPaypal',
'play': 'IconPlayerPlay',
'plug': 'IconPlug',
'plus': 'IconPlus',
@@ -134,6 +135,29 @@ const semanticNames: Record = {
'x': 'IconX',
};
+export interface IconStackProps {
+ /** The main glyph. */
+ children: React.ReactNode;
+ /** A smaller glyph pinned to the bottom-right, qualifying the first. */
+ corner: React.ReactNode;
+ /** Accessible name for the pair; the two glyphs mean one thing together. */
+ label: string;
+}
+
+/**
+ * Two icons composed into one symbol — a wifi glyph with a question mark on it,
+ * say. Replaces Semantic's IconGroup.
+ *
+ * The corner glyph gets a background matching the surface so it stays legible
+ * over the icon beneath it.
+ */
+export function IconStack({children, corner, label}: IconStackProps) {
+ return
+ {children}
+ {corner}
+
+}
+
export interface IconProps extends Omit, 'name' | 'ref'> {
/** A Semantic UI icon name, for migrated call sites. */
name?: string;
diff --git a/app/src/components/ui/Navigation.tsx b/app/src/components/ui/Navigation.tsx
new file mode 100644
index 00000000..ef02c2f2
--- /dev/null
+++ b/app/src/components/ui/Navigation.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import {Pagination as MPagination} from '@mantine/core';
+
+/*
+ * Navigation: pagination and tab bars.
+ *
+ * Neither knows anything about routing — the app owns its links. The tab bar
+ * takes rendered children and supplies only the strip and its house style, so
+ * call sites keep using react-router's NavLink and its `isActive` render prop.
+ */
+
+// --------------------------------------------------------------- Pagination
+
+export interface PaginationProps {
+ /** 1-based. */
+ activePage: number;
+ totalPages?: number;
+ onPageChange: (page: number) => void;
+ /** Pages shown either side of the active one. Fewer on a phone. */
+ siblingRange?: number;
+ /** Include jump-to-first and jump-to-last controls. */
+ showFirstAndLast?: boolean;
+ size?: string;
+ disabled?: boolean;
+}
+
+/**
+ * Page controls. Replaces Semantic's Pagination, keeping its prop names
+ * (`activePage`, `totalPages`, `onPageChange`) so call sites migrate as a
+ * rename — but `onPageChange` receives the page number directly rather than
+ * Semantic's `(event, data)` pair.
+ */
+export function Pagination({
+ activePage,
+ totalPages,
+ onPageChange,
+ siblingRange = 2,
+ showFirstAndLast = false,
+ size = 'sm',
+ disabled,
+}: PaginationProps) {
+ return ({'aria-label': `Page ${page}`})}
+ />
+}
+
+// ----------------------------------------------------------------- Tab bars
+
+/** A row of tabs. Children are the links; routing stays with the caller. */
+export function TabBar({children, right}: {children: React.ReactNode; right?: React.ReactNode}) {
+ return
+ {children}
+ {right && {right}
}
+
+}
+
+/**
+ * Class name for one tab. Exported rather than baked into a component so a
+ * caller can hand it straight to NavLink's `className` render prop, which is
+ * how the app decides what is active.
+ */
+export const tabClassName = (active: boolean): string =>
+ `wrolpi-tab${active ? ' wrolpi-tab-active' : ''}`;
diff --git a/app/src/components/ui/SearchBox.tsx b/app/src/components/ui/SearchBox.tsx
new file mode 100644
index 00000000..affd60f7
--- /dev/null
+++ b/app/src/components/ui/SearchBox.tsx
@@ -0,0 +1,230 @@
+import React, {useEffect, useId, useRef, useState} from 'react';
+import {Icon} from './Icon';
+import {IconButton} from './Button';
+import {Loader} from './Feedback';
+
+/*
+ * The search box, with optional grouped suggestions.
+ *
+ * Replaces Semantic's ``, and deliberately keeps its `results`
+ * shape — `{groupKey: {name, results: [{title, description}]}}` — because four
+ * call sites already build that structure from the API.
+ *
+ * Suggestions are a combobox: the input keeps focus and owns the keyboard, while
+ * `aria-activedescendant` points at the highlighted option. Arrow keys move,
+ * Enter takes the highlighted suggestion or submits what was typed, Escape
+ * closes the list without clearing the text.
+ */
+
+export interface SearchResult {
+ /** The value put into the input when chosen. Also the visible first line. */
+ title: string;
+ /** Second line, for context: a channel name, a domain. */
+ description?: string;
+
+ [key: string]: any;
+}
+
+export interface SearchResultGroup {
+ /** Group heading. */
+ name: string;
+ results: SearchResult[];
+}
+
+export type SearchResults = Record;
+
+export interface SearchBoxProps {
+ value?: string;
+ onChange?: (value: string) => void;
+ /** Enter, or the search button. Receives the current text. */
+ onSubmit?: (value: string) => void;
+ /** Called with the chosen suggestion. */
+ onResultSelect?: (result: SearchResult) => void;
+ onBlur?: React.FocusEventHandler;
+ results?: SearchResults;
+ /** Suggestions are being fetched; shows a spinner rather than "no results". */
+ loading?: boolean;
+ placeholder?: string;
+ /** Show a button that empties the input. */
+ clearable?: boolean;
+ /** Force the clear button's disabled state; defaults to "nothing typed". */
+ clearDisabled?: boolean | null;
+ onClear?: () => void;
+ disabled?: boolean;
+ required?: boolean;
+ inputRef?: React.Ref;
+ /** Accessible name. Falls back to the placeholder. */
+ label?: string;
+ className?: string;
+ autoFocus?: boolean;
+ name?: string;
+}
+
+/** Flatten the groups into the order they are displayed, for keyboard movement. */
+const flatten = (results?: SearchResults): SearchResult[] =>
+ Object.values(results ?? {}).flatMap(group => group?.results ?? []);
+
+export function SearchBox({
+ value,
+ onChange,
+ onSubmit,
+ onResultSelect,
+ onBlur,
+ results,
+ loading,
+ placeholder = 'Search...',
+ clearable,
+ clearDisabled = null,
+ onClear,
+ disabled,
+ required,
+ inputRef,
+ label,
+ className,
+ autoFocus,
+ name,
+}: SearchBoxProps) {
+ const [open, setOpen] = useState(false);
+ const [highlighted, setHighlighted] = useState(-1);
+ const containerRef = useRef(null);
+ const listId = useId();
+
+ const text = value ?? '';
+ const flat = flatten(results);
+ const hasSuggestions = onResultSelect !== undefined || !!results;
+
+ // Close when focus or a click leaves the box entirely. A blur handler alone would
+ // fire before a click on a suggestion registers.
+ useEffect(() => {
+ if (!open) return;
+ const onDocumentClick = (event: MouseEvent) => {
+ if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
+ };
+ document.addEventListener('mousedown', onDocumentClick);
+ return () => document.removeEventListener('mousedown', onDocumentClick);
+ }, [open]);
+
+ const choose = (result: SearchResult) => {
+ setOpen(false);
+ setHighlighted(-1);
+ onResultSelect?.(result);
+ }
+
+ const handleChange = (event: React.ChangeEvent) => {
+ setHighlighted(-1);
+ if (hasSuggestions) setOpen(true);
+ onChange?.(event.currentTarget.value);
+ }
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
+ if (!flat.length) return;
+ event.preventDefault();
+ setOpen(true);
+ const step = event.key === 'ArrowDown' ? 1 : -1;
+ setHighlighted(current => {
+ // Wrap, so holding a key cannot strand the user at either end.
+ const next = current + step;
+ if (next < 0) return flat.length - 1;
+ if (next >= flat.length) return 0;
+ return next;
+ });
+ } else if (event.key === 'Enter') {
+ event.preventDefault();
+ if (open && highlighted >= 0 && flat[highlighted]) {
+ choose(flat[highlighted]);
+ } else {
+ setOpen(false);
+ onSubmit?.(text);
+ }
+ } else if (event.key === 'Escape') {
+ // Closes the list; the typed text is left alone.
+ setOpen(false);
+ setHighlighted(-1);
+ }
+ }
+
+ const handleClear = () => {
+ onChange?.('');
+ onSubmit?.('');
+ onClear?.();
+ setOpen(false);
+ }
+
+ let index = -1;
+
+ return
+
+
+ hasSuggestions && (flat.length > 0 || loading) && setOpen(true)}
+ onBlur={onBlur}
+ role={hasSuggestions ? 'combobox' : undefined}
+ aria-expanded={hasSuggestions ? open : undefined}
+ aria-controls={hasSuggestions ? listId : undefined}
+ aria-autocomplete={hasSuggestions ? 'list' : undefined}
+ aria-activedescendant={open && highlighted >= 0 ? `${listId}-${highlighted}` : undefined}
+ />
+ {loading && }
+ {clearable && }
+
+
+ {open && hasSuggestions &&
+ {loading && !flat.length &&
Searching…
}
+ {!loading && !flat.length &&
No results
}
+ {Object.entries(results ?? {}).map(([key, group]) => {
+ if (!group?.results?.length) return null;
+ return
+
{group.name}
+ {group.results.map((result, resultIndex) => {
+ index += 1;
+ const optionIndex = index;
+ return
{
+ event.preventDefault();
+ choose(result);
+ }}
+ onMouseEnter={() => setHighlighted(optionIndex)}
+ >
+
{result.title}
+ {result.description &&
+ {result.description}
+
}
+
+ })}
+
+ })}
+
}
+
+}
diff --git a/app/src/components/ui/index.ts b/app/src/components/ui/index.ts
index cb36342e..6e2ae8d5 100644
--- a/app/src/components/ui/index.ts
+++ b/app/src/components/ui/index.ts
@@ -16,6 +16,8 @@ export * from './Button';
export * from './Feedback';
export * from './Surfaces';
export * from './DataTable';
+export * from './Navigation';
+export * from './SearchBox';
export * from './Inputs';
export * from './Overlays';
export * from './ThemePicker';
diff --git a/app/src/components/ui/ui.css b/app/src/components/ui/ui.css
index 64bd8911..a99d1641 100644
--- a/app/src/components/ui/ui.css
+++ b/app/src/components/ui/ui.css
@@ -26,6 +26,26 @@
}
}
+.wrolpi-icon-stack {
+ position: relative;
+ display: inline-flex;
+ line-height: 0;
+}
+
+/*
+ * The corner glyph sits on the main one, so it carries the surface color behind
+ * it — without that the two sets of strokes cross and neither reads.
+ */
+.wrolpi-icon-stack-corner {
+ position: absolute;
+ right: -3px;
+ bottom: -3px;
+ display: flex;
+ background: var(--bg);
+ border-radius: 50%;
+ padding: 1px;
+}
+
/* ---------------------------------------------------------------- Buttons */
/*
* Dashed borders mean "destructive or failed", and nothing else may use them.
@@ -117,6 +137,188 @@ html[data-theme="amber"] .wrolpi-theme-option-active {
border-top: 1px solid var(--border);
}
+/* ------------------------------------------------------------ Pagination */
+
+.wrolpi-pagination button {
+ background: var(--panel);
+ color: var(--text);
+ border: 1px solid var(--border);
+}
+
+.wrolpi-pagination button[data-active] {
+ background: var(--blue);
+ color: var(--btn-text);
+ border-color: var(--blue);
+}
+
+/* Night and amber have no second hue to spend, so the current page is marked by a
+ heavier border and the panel surface instead of a filled block of color. */
+html[data-theme="night"] .wrolpi-pagination button[data-active],
+html[data-theme="amber"] .wrolpi-pagination button[data-active] {
+ background: var(--panel);
+ color: var(--text);
+ border: 2px solid var(--text);
+}
+
+.wrolpi-pagination button:disabled {
+ color: var(--muted);
+ background: var(--bg);
+}
+
+/* ---------------------------------------------------------------- Tab bar */
+
+.wrolpi-tabs {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 12px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 14px;
+}
+
+.wrolpi-tabs-links {
+ display: flex;
+ flex-wrap: wrap;
+}
+
+.wrolpi-tabs-right {
+ padding-bottom: 6px;
+}
+
+.wrolpi-tab {
+ display: inline-block;
+ padding: 8px 14px;
+ color: var(--muted);
+ text-decoration: none;
+ font-size: 13px;
+ border: 1px solid transparent;
+ /* Sits on the bar's own rule, so the active tab can erase it and read as
+ connected to the panel below. */
+ margin-bottom: -1px;
+}
+
+.wrolpi-tab:hover {
+ color: var(--text);
+}
+
+.wrolpi-tab-active {
+ color: var(--text);
+ background: var(--panel);
+ border-color: var(--border);
+ border-bottom-color: var(--panel);
+ font-weight: 600;
+}
+
+.wrolpi-tab:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: -2px;
+}
+
+/* ------------------------------------------------------------- Search box */
+
+.wrolpi-searchbox {
+ position: relative;
+ width: 100%;
+}
+
+.wrolpi-searchbox-control {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ padding: 0 8px;
+}
+
+.wrolpi-searchbox-control:focus-within {
+ border-color: var(--blue);
+}
+
+html[data-theme="night"] .wrolpi-searchbox-control:focus-within,
+html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
+ border-color: var(--text);
+}
+
+.wrolpi-searchbox-icon {
+ display: flex;
+ color: var(--muted);
+}
+
+.wrolpi-searchbox-input {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 0;
+ background: none;
+ border: none;
+ outline: none;
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+}
+
+.wrolpi-searchbox-input::placeholder {
+ color: var(--muted);
+}
+
+.wrolpi-searchbox-loading,
+.wrolpi-searchbox-clear {
+ flex: none;
+}
+
+/*
+ * The suggestion list overlays the page rather than pushing it down — a search
+ * that reflows the results underneath it is unusable while typing.
+ */
+.wrolpi-searchbox-results {
+ position: absolute;
+ z-index: 20;
+ top: 100%;
+ left: 0;
+ right: 0;
+ max-height: 320px;
+ overflow-y: auto;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-top: none;
+}
+
+.wrolpi-searchbox-group-name {
+ padding: 6px 10px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--muted);
+ background: var(--head);
+}
+
+.wrolpi-searchbox-result {
+ padding: 7px 10px;
+ cursor: pointer;
+ border-top: 1px solid var(--border);
+}
+
+.wrolpi-searchbox-result-active {
+ background: var(--head);
+}
+
+.wrolpi-searchbox-result-title {
+ font-size: 13px;
+ color: var(--text);
+ overflow-wrap: anywhere;
+}
+
+.wrolpi-searchbox-result-description {
+ font-size: 11px;
+ color: var(--muted);
+}
+
+.wrolpi-searchbox-empty {
+ padding: 10px;
+ font-size: 12px;
+ color: var(--muted);
+}
+
/* ---------------------------------------------------------------- Modals */
/*
* Actions sit below the body, separated by a hairline rather than a raised bar.
@@ -460,6 +662,41 @@ html[data-theme="night"] .wrolpi-status-failed {
/* ----------------------------------------------------------------- Tables */
+/* A sortable header: a real button, so the column is reachable by keyboard. */
+.wrolpi-th-sort {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ background: none;
+ border: none;
+ padding: 0;
+ margin: 0;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+/* The unsorted columns show a faint arrow: the affordance is visible, but it does
+ not compete with the column actually doing the sorting. */
+.wrolpi-th-sort-idle {
+ opacity: 0.35;
+}
+
+.wrolpi-th-sort:hover .wrolpi-th-sort-idle {
+ opacity: 0.7;
+}
+
+.wrolpi-th-sort:focus-visible {
+ outline: 2px solid var(--blue);
+ outline-offset: 2px;
+}
+
+html[data-theme="night"] .wrolpi-th-sort:focus-visible,
+html[data-theme="amber"] .wrolpi-th-sort:focus-visible {
+ outline-color: var(--text);
+}
+
/* A failed row carries the danger border, matching failed messages. */
.wrolpi-row-failed > td {
border-top: 1.5px dashed var(--red);
diff --git a/app/src/components/ui/ui.test.js b/app/src/components/ui/ui.test.js
index f6f59782..3cfdc35d 100644
--- a/app/src/components/ui/ui.test.js
+++ b/app/src/components/ui/ui.test.js
@@ -12,6 +12,11 @@ import {
Confirm,
Header,
Icon,
+ IconStack,
+ Pagination,
+ SearchBox,
+ TabBar,
+ tabClassName,
IconButton,
Label,
Message,
@@ -280,6 +285,30 @@ describe('Table', () => {
expect(container.querySelector('.wrolpi-row-failed')).toBeInTheDocument();
});
+ it('makes a sortable column a keyboard-reachable button that announces its state', async () => {
+ // A click handler on the alone is unreachable by keyboard, and the arrow
+ // glyph says nothing to a screen reader.
+ const onSort = jest.fn();
+ renderUI(
+
+
+ Size
+ Name
+ Actions
+
+
+
);
+
+ expect(screen.getByRole('columnheader', {name: /Size/})).toHaveAttribute('aria-sort', 'descending');
+ // Sortable but not currently sorted.
+ expect(screen.getByRole('columnheader', {name: /Name/})).toHaveAttribute('aria-sort', 'none');
+ // Not sortable at all.
+ expect(screen.getByRole('columnheader', {name: 'Actions'})).not.toHaveAttribute('aria-sort');
+
+ await userEvent.click(screen.getByRole('button', {name: /Size/}));
+ expect(onSort).toHaveBeenCalled();
+ });
+
it('keeps wide tables from scrolling the page sideways', () => {
const {container} = renderUI(
wide
@@ -409,6 +438,160 @@ describe('ThemePicker', () => {
});
});
+describe('Pagination', () => {
+ it('reports the current page and moves when one is clicked', async () => {
+ const onPageChange = jest.fn();
+ renderUI();
+
+ await userEvent.click(screen.getByRole('button', {name: 'Page 5'}));
+
+ expect(onPageChange).toHaveBeenCalledWith(5);
+ });
+
+ it('still renders one page when the total is unknown', () => {
+ // Downloads render the paginator before the first response arrives; collapsing to
+ // nothing and back shifts the whole page.
+ renderUI();
+
+ expect(screen.getByRole('button', {name: 'Page 1'})).toBeInTheDocument();
+ });
+});
+
+describe('TabBar', () => {
+ it('marks the active tab with a class the themes key on', () => {
+ const {container} = renderUI(
+ Videos
+ Channels
+ );
+
+ expect(container.querySelectorAll('.wrolpi-tab')).toHaveLength(2);
+ expect(container.querySelectorAll('.wrolpi-tab-active')).toHaveLength(1);
+ });
+});
+
+describe('SearchBox', () => {
+ const results = {
+ directories: {name: 'Directories', results: [{title: 'videos/'}]},
+ channels: {name: 'Channels', results: [
+ {title: 'videos/Wranglerstar', description: 'Wranglerstar'},
+ {title: 'videos/RoseRed', description: 'RoseRed Homestead'},
+ ]},
+ };
+
+ it('submits what was typed', async () => {
+ const onSubmit = jest.fn();
+ renderUI();
+
+ await userEvent.type(screen.getByRole('textbox'), '{Enter}');
+
+ expect(onSubmit).toHaveBeenCalledWith('axe');
+ });
+
+ it('shows suggestions under their group headings', async () => {
+ renderUI();
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ expect(screen.getByText('Directories')).toBeInTheDocument();
+ expect(screen.getAllByRole('option')).toHaveLength(3);
+ });
+
+ it('selects a suggestion by click', async () => {
+ const onResultSelect = jest.fn();
+ renderUI();
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.click(screen.getByText('videos/Wranglerstar'));
+
+ expect(onResultSelect).toHaveBeenCalledWith(
+ expect.objectContaining({title: 'videos/Wranglerstar'}));
+ });
+
+ it('moves through suggestions with the arrow keys and takes one with Enter', async () => {
+ const onResultSelect = jest.fn();
+ const onSubmit = jest.fn();
+ renderUI();
+
+ const input = screen.getByRole('combobox');
+ await userEvent.click(input);
+ await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}');
+
+ expect(onResultSelect).toHaveBeenCalledWith(
+ expect.objectContaining({title: 'videos/Wranglerstar'}));
+ // Enter took the highlighted suggestion instead of submitting the raw text.
+ expect(onSubmit).not.toHaveBeenCalled();
+ });
+
+ it('wraps around rather than stranding the user at the end of the list', async () => {
+ const onResultSelect = jest.fn();
+ renderUI();
+
+ await userEvent.click(screen.getByRole('combobox'));
+ await userEvent.keyboard('{ArrowUp}{Enter}');
+
+ expect(onResultSelect).toHaveBeenCalledWith(
+ expect.objectContaining({title: 'videos/RoseRed'}));
+ });
+
+ it('closes on Escape without clearing what was typed', async () => {
+ const onChange = jest.fn();
+ renderUI();
+ await userEvent.click(screen.getByRole('combobox'));
+
+ await userEvent.keyboard('{Escape}');
+
+ expect(screen.queryByRole('option')).not.toBeInTheDocument();
+ expect(screen.getByRole('combobox')).toHaveValue('vid');
+ expect(onChange).not.toHaveBeenCalled();
+ });
+
+ it('says it is searching rather than "no results" while suggestions are pending', async () => {
+ renderUI();
+
+ await userEvent.click(screen.getByRole('combobox'));
+
+ expect(screen.getByText(/searching/i)).toBeInTheDocument();
+ });
+
+ it('clears the input and submits the empty search', async () => {
+ const onChange = jest.fn();
+ const onSubmit = jest.fn();
+ renderUI( );
+
+ await userEvent.click(screen.getByRole('button', {name: 'Clear search'}));
+
+ expect(onChange).toHaveBeenCalledWith('');
+ expect(onSubmit).toHaveBeenCalledWith('');
+ });
+
+ it('is a plain search field when no suggestions are wired up', () => {
+ // Without this, a box that can never show options still claims to be a combobox.
+ renderUI();
+
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+});
+
+describe('IconStack', () => {
+ it('names the pair once, and hides the corner glyph from assistive tech', () => {
+ const {container} = renderUI(
+ } label='WiFi status unknown'>
+
+
+ );
+
+ expect(screen.getByRole('img', {name: 'WiFi status unknown'})).toBeInTheDocument();
+ expect(container.querySelector('.wrolpi-icon-stack-corner')).toHaveAttribute('aria-hidden', 'true');
+ });
+});
+
describe('Confirm', () => {
it('names the action on its confirming button', async () => {
const onConfirm = jest.fn();
diff --git a/app/src/semantic-baseline.json b/app/src/semantic-baseline.json
index 9f4e7af7..5c858fca 100644
--- a/app/src/semantic-baseline.json
+++ b/app/src/semantic-baseline.json
@@ -14,14 +14,11 @@
"src/components/Common.js",
"src/components/DatesSelector.js",
"src/components/Docs.js",
- "src/components/DonatePage.js",
"src/components/Download.js",
- "src/components/ErrorBoundary.js",
"src/components/FileBrowser.js",
"src/components/FilePreview.js",
"src/components/Files.js",
"src/components/Flasher.js",
- "src/components/Headline.js",
"src/components/HelpModal.js",
"src/components/Map.js",
"src/components/MapSearchView.js",
@@ -30,9 +27,6 @@
"src/components/Placeholder.js",
"src/components/Playlists.js",
"src/components/Search.js",
- "src/components/Share.js",
- "src/components/SortableTable.js",
- "src/components/Tables.js",
"src/components/TaggedDeleteConfirmModal.js",
"src/components/Theme.tsx",
"src/components/Upload.js",
@@ -48,11 +42,8 @@
"src/components/calculators/FoodStorageCalculator.js",
"src/components/calculators/HamCalculators.js",
"src/components/calculators/OneTimePadCalculator.js",
- "src/components/calculators/QRCodeCalculator.js",
"src/components/calculators/RatioCalculator.js",
"src/components/calculators/RationCalculator.js",
- "src/components/calculators/TemperatureCalculator.js",
- "src/components/calculators/VinDecoderCalculator.js",
"src/components/calculators/WaterCalculator.js",
"src/components/collections/BatchReorganizeModal.js",
"src/components/collections/CollectionEditForm.js",