From 3454e042f9382d6b7ca14022c26b4bd216247faa Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 09:31:15 -0600 Subject: [PATCH] ui: nine leaf files off Semantic, and the components they needed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2, part 2. Baseline 72 -> 63. Migrated: Tables, SortableTable, DonatePage, Share, Headline, ErrorBoundary, and the QR code, VIN decoder, and temperature calculators. New in the library, all in the gallery and tested: SearchBox Semantic's ``, keeping its grouped `results` shape because four call sites already build it. A real combobox: arrows move, Enter takes the highlighted suggestion or submits the raw text, Escape closes without clearing. Pagination Semantic's prop names, but onPageChange gets the page number rather than an (event, data) pair. TabBar the strip and its house style only; routing stays at the call site, which is what decides what is active. IconStack two glyphs composed into one symbol, named once for assistive technology (Semantic's IconGroup). Table.HeaderCell gains `sorted` and `onSort`. That last one is a fix, not just a port. Semantic's `sorted` prop set `aria-sort`; migrating a table by hand dropped it, leaving a clickable that no keyboard could reach and no screen reader could describe. Putting it in the library means the next table gets it right without anyone remembering. Night mode holes closed along the way: the QR codes on the donate, share, and calculator pages are white by necessity — a scanner needs the contrast — and they render as SVG, which the media filter does not target. In night mode that was a lit panel. They now carry `.media`, so the filter remaps the block as a unit and the contrast survives the mapping. Also: `paypal` joins the icon map rather than being imported ad hoc in a page, and FileBrowser stops passing Semantic-only table props into a DOM element. --- app/src/components/DonatePage.js | 51 ++-- app/src/components/ErrorBoundary.js | 38 ++- app/src/components/FileBrowser.js | 2 +- app/src/components/Headline.js | 40 ++- app/src/components/Share.js | 15 +- app/src/components/SortableTable.js | 37 ++- app/src/components/Tables.js | 41 ++- app/src/components/ThemeSamplePage.js | 61 ++++- .../calculators/QRCodeCalculator.js | 72 +++--- .../calculators/TemperatureCalculator.js | 55 ++-- .../calculators/VinDecoderCalculator.js | 42 ++-- app/src/components/ui/DataTable.tsx | 41 ++- app/src/components/ui/Icon.tsx | 24 ++ app/src/components/ui/Navigation.tsx | 74 ++++++ app/src/components/ui/SearchBox.tsx | 230 +++++++++++++++++ app/src/components/ui/index.ts | 2 + app/src/components/ui/ui.css | 237 ++++++++++++++++++ app/src/components/ui/ui.test.js | 183 ++++++++++++++ app/src/semantic-baseline.json | 9 - 19 files changed, 1030 insertions(+), 224 deletions(-) create mode 100644 app/src/components/ui/Navigation.tsx create mode 100644 app/src/components/ui/SearchBox.tsx 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",