diff --git a/app/src/components/DonatePage.js b/app/src/components/DonatePage.js index 59c32517..7d024052 100644 --- a/app/src/components/DonatePage.js +++ b/app/src/components/DonatePage.js @@ -1,7 +1,6 @@ import React, {useState} from "react"; import {PageContainer, useTitle} from "./Common"; -import {Button, Header, Modal, Segment, Table} from "./Theme"; -import {Icon, TableBody, TableCell, TableRow} from "semantic-ui-react"; +import {Button, Header, IconButton, Modal, Panel, Table} from "./ui"; import QRCode from "react-qr-code"; const bitcoinAddress = '1mi1ddLSd6LmkuwJd1ttZsKuED724vYjS'; @@ -17,10 +16,8 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) { const [open, setOpen] = useState(false); return <> - + setOpen(true)}/> setOpen(false)} @@ -28,7 +25,10 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) { > {header} -
+ {/* `media`: the white field a scanner needs is filtered as a unit by night mode, + rather than sitting on screen as a lit panel. */} +
@@ -39,17 +39,17 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) { function CoinRow({header, qrCodeValue, address, buttonColor}) { return <>
{header}
- - - - +
+ + + - - + +
{address}
- - - +
+
+
} @@ -58,31 +58,28 @@ export function DonatePage() { useTitle('Donate'); return - +
Donate
We appreciate any support you can provide to WROLPi!

-
+
; } diff --git a/app/src/components/ErrorBoundary.js b/app/src/components/ErrorBoundary.js index c417d688..9a05513d 100644 --- a/app/src/components/ErrorBoundary.js +++ b/app/src/components/ErrorBoundary.js @@ -1,5 +1,5 @@ import React from 'react'; -import {Button, Container, Icon, Message, Segment} from 'semantic-ui-react'; +import {Box, Button, ButtonGroup, Message, Panel} from './ui'; /** * Error Boundary component that catches JavaScript errors in child components @@ -52,23 +52,19 @@ class ErrorBoundary extends React.Component { const errorMessage = error?.message || 'An unexpected error occurred'; return ( - - - - - Something went wrong -

{errorMessage}

- - - - -
+ + +

{errorMessage}

+ + + +
-
+ ); } @@ -96,11 +92,11 @@ class InlineErrorBoundary extends React.Component { render() { if (this.state.hasError) { return ( - - - Failed to load this section + + + Failed to load this section - + ); } return this.props.children; diff --git a/app/src/components/FileBrowser.js b/app/src/components/FileBrowser.js index 61995c85..d69f2150 100644 --- a/app/src/components/FileBrowser.js +++ b/app/src/components/FileBrowser.js @@ -598,7 +598,7 @@ function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile return (
{ - const {s} = React.useContext(ThemeContext); if (headline) { headline = headline.replaceAll('', openTag); headline = headline.replaceAll('', closeTag); headline = headline.replaceAll('\n', '
'); // FTS headlines use ... tags which we will restyle. - return + return } } @@ -26,7 +22,7 @@ const FileHeadline = ({file, to}) => { const posterPath = findPosterPath(file); if (posterPath) { const posterUrl = posterPath ? `/media/${encodeMediaPath(posterPath)}` : null; - poster = ; + poster = poster; } else { poster = ; } @@ -48,32 +44,26 @@ const FileHeadline = ({file, to}) => { ; } - return + return - - {poster} - - - - {header} - - - + {poster} + + + + {header} + +
{body} -
+ } export const Headlines = ({results}) => { if (results === null || results === undefined) { - return - - - - + return } else if (results && results.length === 0) { - return No results! + return No results! } let headlines = []; @@ -93,4 +83,4 @@ export const Headlines = ({results}) => { return <> {headlines} -} \ No newline at end of file +} diff --git a/app/src/components/Share.js b/app/src/components/Share.js index 1b719612..e84c5de6 100644 --- a/app/src/components/Share.js +++ b/app/src/components/Share.js @@ -1,6 +1,5 @@ import React from "react"; -import {Button, Header, Icon, Modal} from "./Theme"; -import {IconGroup} from "semantic-ui-react"; +import {Button, Header, Icon, Modal} from "./ui"; import QRCode from "react-qr-code"; import {sendNotification} from "../api"; @@ -46,19 +45,19 @@ export const ShareButton = () => { Share this page
Another user can scan this QR code to view this page
-
+ {/* `media`: filtered as a unit by night mode; see DonatePage. */} +
- + - - - + -} \ 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 => )} + + +

+ 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 - - -
QR Code Calculator
-
-
- - - setValue(e.target.value)} placeholder='Enter link, etc.'/> - - - - - -
- -
-
- -
- -
-
-
-
-
-} \ No newline at end of file + return +
QR Code Calculator
+ 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
{details} @@ -40,8 +40,8 @@ export function VinDecoderCalculator() { return <>
VIN Number Decoder
- 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 ` + +} + 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} + + +} + 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 +} + +/** + * 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( + + + ); + + 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",