ui: nine leaf files off Semantic, and the components they needed

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 `<Search category>`, 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 <th>
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.
This commit is contained in:
Roland 2026-07-30 09:31:15 -06:00
parent 57985cea94
commit 3454e042f9
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
19 changed files with 1030 additions and 224 deletions

View file

@ -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 <>
<Button icon color={buttonColor}
onClick={() => setOpen(true)}>
<Icon name='qrcode' size='big'/>
</Button>
<IconButton icon='qrcode' label={`Show ${header} QR code`} color={buttonColor}
onClick={() => setOpen(true)}/>
<Modal closeIcon
open={open}
onClose={() => setOpen(false)}
@ -28,7 +25,10 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) {
>
<Modal.Header>{header}</Modal.Header>
<Modal.Content>
<div style={{backgroundColor: '#FFFFFF', display: 'inline-block', padding: '1em'}}>
{/* `media`: the white field a scanner needs is filtered as a unit by night mode,
rather than sitting on screen as a lit panel. */}
<div className='media'
style={{backgroundColor: '#FFFFFF', display: 'inline-block', padding: '1em'}}>
<QRCode value={qrCodeValue} size={300}/>
</div>
</Modal.Content>
@ -39,17 +39,17 @@ function CoinQRButton({qrCodeValue, header, buttonColor}) {
function CoinRow({header, qrCodeValue, address, buttonColor}) {
return <>
<Header as='h3'>{header}</Header>
<Table stackable>
<TableBody>
<TableRow>
<TableCell width={1}>
<Table>
<Table.Body>
<Table.Row>
<Table.Cell style={{width: '1%', whiteSpace: 'nowrap'}}>
<CoinQRButton qrCodeValue={qrCodeValue} header={header} buttonColor={buttonColor}/>
</TableCell>
<TableCell width={15} textAlign='left'>
</Table.Cell>
<Table.Cell style={{textAlign: 'left'}}>
<pre>{address}</pre>
</TableCell>
</TableRow>
</TableBody>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</>
}
@ -58,31 +58,28 @@ export function DonatePage() {
useTitle('Donate');
return <PageContainer>
<Segment>
<Panel>
<Header as='h1'>Donate</Header>
<Header as='h3'>We appreciate any support you can provide to WROLPi!</Header>
<p>
<Button
size='huge'
size='xl'
icon='paypal'
as='a'
color='blue'
component='a'
role='primary'
href='https://www.paypal.com/donate/?hosted_button_id=ZH2CN92SMJ66N'
rel='noopener noreferrer'
target='_blank'
label={{
basic: true,
content: 'Donate on PayPal',
color: 'blue'
}}
/>
>
Donate on PayPal
</Button>
</p>
<CoinRow header='Monero' qrCodeValue={moneroContent} address={moneroAddress} buttonColor='orange'/>
<CoinRow header='Litecoin' qrCodeValue={litecoinContent} address={litecoinAddress} buttonColor='grey'/>
<CoinRow header='Ethereum' qrCodeValue={ethereumContent} address={ethereumAddress} buttonColor='blue'/>
<CoinRow header='Bitcoin' qrCodeValue={bitcoinContent} address={bitcoinAddress} buttonColor='yellow'/>
</Segment>
</Panel>
</PageContainer>;
}

View file

@ -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 (
<Container style={{padding: '2em'}}>
<Message negative icon>
<Icon name='warning sign'/>
<Message.Content>
<Message.Header>Something went wrong</Message.Header>
<p>{errorMessage}</p>
<Button.Group>
<Button onClick={this.handleReset} primary>
<Icon name='refresh'/> Try Again
</Button>
<Button onClick={this.handleReload}>
<Icon name='redo'/> Reload Page
</Button>
</Button.Group>
</Message.Content>
<Box style={{padding: '2em'}}>
<Message kind='error' icon='warning sign' title='Something went wrong'>
<p>{errorMessage}</p>
<ButtonGroup>
<Button onClick={this.handleReset} role='primary' icon='refresh'>
Try Again
</Button>
<Button onClick={this.handleReload} icon='redo'>
Reload Page
</Button>
</ButtonGroup>
</Message>
</Container>
</Box>
);
}
@ -96,11 +92,11 @@ class InlineErrorBoundary extends React.Component {
render() {
if (this.state.hasError) {
return (
<Segment basic>
<Message negative size='small'>
<Icon name='warning'/> Failed to load this section
<Panel>
<Message kind='error' icon='warning'>
Failed to load this section
</Message>
</Segment>
</Panel>
);
}
return this.props.children;

View file

@ -598,7 +598,7 @@ function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile
return (
<div style={{marginBottom: '4em'}} className={containerClassName}>
<SortableTable
tableProps={{unstackable: true, selectable: true, striped: true}}
tableProps={{striped: true}}
data={browseFiles}
tableHeaders={headers}
defaultSortColumn='path'

View file

@ -1,21 +1,17 @@
import {Placeholder, Segment} from "./Theme";
import {Image, PlaceholderLine} from "semantic-ui-react";
import {Grid, Panel, Placeholder} from "./ui";
import React from "react";
import {ThemeContext} from "../contexts/contexts";
import {encodeMediaPath, FileIcon, findPosterPath, PreviewLink} from "./Common";
import {Link} from "react-router";
import _ from "lodash";
import Grid from "semantic-ui-react/dist/commonjs/collections/Grid";
import {FileRowTagIcon} from "./Files";
export const HeadlineText = ({headline, openTag = '<u>', closeTag = '</u>'}) => {
const {s} = React.useContext(ThemeContext);
if (headline) {
headline = headline.replaceAll('<b>', openTag);
headline = headline.replaceAll('</b>', closeTag);
headline = headline.replaceAll('\n', '<br/>');
// FTS headlines use <b>...</b> tags which we will restyle.
return <span {...s} dangerouslySetInnerHTML={{__html: headline}}></span>
return <span dangerouslySetInnerHTML={{__html: headline}}></span>
}
}
@ -26,7 +22,7 @@ const FileHeadline = ({file, to}) => {
const posterPath = findPosterPath(file);
if (posterPath) {
const posterUrl = posterPath ? `/media/${encodeMediaPath(posterPath)}` : null;
poster = <Image src={posterUrl} size='tiny'/>;
poster = <img alt='poster' src={posterUrl} style={{maxWidth: '80px', maxHeight: '80px'}}/>;
} else {
poster = <FileIcon file={file}/>;
}
@ -48,32 +44,26 @@ const FileHeadline = ({file, to}) => {
</>;
}
return <Segment>
return <Panel>
<Grid>
<Grid.Row>
<Grid.Column mobile={4} computer={2}>{poster}</Grid.Column>
<Grid.Column mobile={12} computer={12}>
<big>
<FileRowTagIcon file={file}/>
{header}
</big>
</Grid.Column>
</Grid.Row>
<Grid.Col span={{base: 4, sm: 2}}>{poster}</Grid.Col>
<Grid.Col span={{base: 12, sm: 10}}>
<big>
<FileRowTagIcon file={file}/>
{header}
</big>
</Grid.Col>
</Grid>
<br/>
{body}
</Segment>
</Panel>
}
export const Headlines = ({results}) => {
if (results === null || results === undefined) {
return <Placeholder>
<PlaceholderLine/>
<PlaceholderLine/>
<PlaceholderLine/>
</Placeholder>
return <Placeholder lines={3}/>
} else if (results && results.length === 0) {
return <Segment>No results!</Segment>
return <Panel>No results!</Panel>
}
let headlines = [];
@ -93,4 +83,4 @@ export const Headlines = ({results}) => {
return <>
{headlines}
</>
}
}

View file

@ -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 = () => {
<Modal.Header>Share this page</Modal.Header>
<Modal.Content>
<Header as='h4'>Another user can scan this QR code to view this page</Header>
<div style={{padding: '1em', backgroundColor: '#ffffff', display: "inline-block"}}>
{/* `media`: filtered as a unit by night mode; see DonatePage. */}
<div className='media'
style={{padding: '1em', backgroundColor: '#ffffff', display: "inline-block"}}>
<QRCode value={window.location.href}/>
</div>
</Modal.Content>
<Modal.Actions>
<ShareEveryoneButton url={window.location.href} onClick={handleClose}/>
<Button onClick={handleClose}>Close</Button>
<Button role='cancel' onClick={handleClose}>Close</Button>
</Modal.Actions>
</Modal>
<a href='#' onClick={handleOpen}>
<IconGroup size='large'>
<Icon name='share'/>
</IconGroup>
<Icon name='share' size='large'/>
</a>
</>
}
}

View file

@ -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 <TableHeaderCell
const active = sortColumn === key;
return <Table.HeaderCell
key={key}
sorted={this.state['sortColumn'] === key ? direction : null}
onClick={() => this.changeSort(key)}
width={width || undefined}
sorted={active ? direction : null}
onSort={() => this.changeSort(key)}
>
{text}
</TableHeaderCell>;
</Table.HeaderCell>;
} else {
return <TableHeaderCell key={key} width={width}>{text}</TableHeaderCell>
return <Table.HeaderCell key={key}>{text}</Table.HeaderCell>
}
}
// Use placeholder while data is null.
let rows = <TableRow><TableCell colSpan={tableHeaders.length}><TextPlaceholder/></TableCell></TableRow>;
let rows = <Table.Row><Table.Cell colSpan={tableHeaders.length}><TextPlaceholder/></Table.Cell></Table.Row>;
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
|| <TableRow><TableCell colSpan={tableHeaders.length}>No results</TableCell></TableRow>;
|| <Table.Row><Table.Cell colSpan={tableHeaders.length}>No results</Table.Cell></Table.Row>;
}
}
return <Table sortable {...this.props.tableProps}>
<TableHeader>
<TableRow>
return <Table {...this.props.tableProps}>
<Table.Header>
<Table.Row>
{tableHeaders.map(tableHeader)}
</TableRow>
</TableHeader>
<TableBody>
</Table.Row>
</Table.Header>
<Table.Body>
{rows}
</TableBody>
</Table.Body>
{this.props.footer}
</Table>
}

View file

@ -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 ? <TableFooter>
<TableRow>
footer = footer ? <Table.Footer>
<Table.Row>
{footer}
</TableRow>
</TableFooter> : null;
</Table.Row>
</Table.Footer> : null;
return <Table unstackable striped compact='very'>
<TableHeader>
<TableRow>
{selectOn && <TableHeaderCell/>}
{headerContents.map(i => <TableHeaderCell key={i}>{i}</TableHeaderCell>)}
</TableRow>
</TableHeader>
<TableBody>
return <Table>
<Table.Header>
<Table.Row>
{selectOn && <Table.HeaderCell/>}
{headerContents.map(i => <Table.HeaderCell key={i}>{i}</Table.HeaderCell>)}
</Table.Row>
</Table.Header>
<Table.Body>
{rows.map(row =>
<SelectableRow key={row.key} selectOn={selectOn} onSelect={onSelect} selectedKeys={selectedKeys}>
{row}
</SelectableRow>)}
</TableBody>
</Table.Body>
{footer}
</Table>
}
@ -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 = <TableCell>
selectCell = <Table.Cell>
<Checkbox onChange={localOnSelect} checked={c}/>
</TableCell>;
</Table.Cell>;
}
return <TableRow key={key}>
return <Table.Row key={key}>
{selectCell}
{props.children}
</TableRow>
</Table.Row>
}

View file

@ -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 <div style={{maxWidth: 1060, margin: '0 auto', padding: '20px 16px 60px', color: 'var(--text)'}}>
<h1 style={{fontSize: 21, fontWeight: 600, margin: '8px 0 4px'}}>Component gallery</h1>
@ -211,8 +218,8 @@ export function ThemeSamplePage() {
<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell>URL</Table.HeaderCell>
<Table.HeaderCell>Downloader</Table.HeaderCell>
<Table.HeaderCell sorted='ascending' onSort={() => {}}>URL</Table.HeaderCell>
<Table.HeaderCell onSort={() => {}}>Downloader</Table.HeaderCell>
<Table.HeaderCell style={{width: 150}}>Progress</Table.HeaderCell>
<Table.HeaderCell>Status</Table.HeaderCell>
<Table.HeaderCell>Size</Table.HeaderCell>
@ -293,6 +300,56 @@ export function ThemeSamplePage() {
</Panel>
</Section>
<Section label='Search'>
<Panel>
<SearchBox
value={search}
onChange={setSearch}
onSubmit={value => 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…'
/>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0, marginTop: 12}}>
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.
</p>
</Panel>
</Section>
<Section label='Pagination and tabs'>
<Panel>
<TabBar right={<Label color='blue'>1,432 videos</Label>}>
{['Videos', 'Channels', 'Playlists'].map(tab => <button
key={tab}
type='button'
className={tabClassName(tab === activeTab)}
onClick={() => setActiveTab(tab)}
>{tab}</button>)}
</TabBar>
<Pagination activePage={page} totalPages={12} onPageChange={setPage} showFirstAndLast/>
<p style={{fontSize: 12, color: 'var(--muted)', marginBottom: 0, marginTop: 12}}>
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.
</p>
</Panel>
</Section>
<Section label='Labels'>
<Panel>
<Row>

View file

@ -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 <Grid>
<Grid.Row>
<Grid.Column>
<Header as='h1'>QR Code Calculator</Header>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Input fluid value={value} onChange={e => setValue(e.target.value)} placeholder='Enter link, etc.'/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<Media at='mobile'>
<div style={{
height: "auto",
margin: "0 auto",
width: '332px',
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={300}/>
</div>
</Media>
<Media greaterThanOrEqual='tablet'>
<div style={{
height: "auto",
margin: "0 auto",
maxWidth: '632px',
width: "100%",
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={600}/>
</div>
</Media>
</Grid.Column>
</Grid.Row>
</Grid>
}
return <Stack>
<Header as='h1'>QR Code Calculator</Header>
<TextInput value={value} onChange={e => setValue(e.target.value)} placeholder='Enter link, etc.'/>
<Media at='mobile'>
<div className='media' style={{
height: "auto",
margin: "0 auto",
width: '332px',
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={300}/>
</div>
</Media>
<Media greaterThanOrEqual='tablet'>
<div className='media' style={{
height: "auto",
margin: "0 auto",
maxWidth: '632px',
width: "100%",
background: 'white',
padding: '16px'
}}>
<QRCode value={value} size={600}/>
</div>
</Media>
</Stack>
}

View file

@ -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 <Grid columns={3}>
<GridRow>
<GridColumn>
<Input value={state.celsius} label='°C' name='celsius' {...commonInputProps}/>
</GridColumn>
<GridColumn>
<Input value={state.fahrenheit} label='°F' name='fahrenheit' {...commonInputProps}/>
</GridColumn>
<GridColumn>
<Input value={state.kelvin} label='K' name='kelvin' {...commonInputProps}/>
</GridColumn>
</GridRow>
return <Grid>
<Grid.Col span={4}>
<NumberInput
value={state.celsius}
rightSection='°C'
onChange={value => handleInputChange('celsius', value)}
onClick={handleClick}
/>
</Grid.Col>
<Grid.Col span={4}>
<NumberInput
value={state.fahrenheit}
rightSection='°F'
onChange={value => handleInputChange('fahrenheit', value)}
onClick={handleClick}
/>
</Grid.Col>
<Grid.Col span={4}>
<NumberInput
value={state.kelvin}
rightSection='K'
onChange={value => handleInputChange('kelvin', value)}
onClick={handleClick}
/>
</Grid.Col>
</Grid>
}

View file

@ -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 = <p {...t}>Enter a VIN number above</p>;
let body = <p>Enter a VIN number above</p>;
if (value && !vin) {
body = <p {...t}>VIN number is invalid</p>;
body = <p>VIN number is invalid</p>;
}
if (vin && vin['country']) {
let details = <p {...t}>No details</p>;
let details = <p>No details</p>;
if (vin['body']) {
details = <Table celled columns={2} {...t}>
{detailsKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</TableCell>
</TableRow>)}
details = <Table>
<Table.Body>
{detailsKeys.map(i => <Table.Row key={i}>
<Table.Cell width={5}><b>{i}</b></Table.Cell>
<Table.Cell width={11}>{vin[i.toLowerCase()] || '(Unknown)'}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>;
}
body = <>
<Table celled columns={2} {...t}>
{basicKeys.map(i => <TableRow key={i}>
<TableCell width={5}><b>{i}</b></TableCell>
<TableCell width={11}>{vin[i.toLowerCase()]}</TableCell>
</TableRow>)}
<Table>
<Table.Body>
{basicKeys.map(i => <Table.Row key={i}>
<Table.Cell width={5}><b>{i}</b></Table.Cell>
<Table.Cell width={11}>{vin[i.toLowerCase()]}</Table.Cell>
</Table.Row>)}
</Table.Body>
</Table>
<Header as='h3'>Details</Header>
{details}
@ -40,8 +40,8 @@ export function VinDecoderCalculator() {
return <>
<Header as='h1'>VIN Number Decoder</Header>
<Input
size='large'
<TextInput
size='lg'
label='VIN'
value={value}
onChange={e => setValue(e.target.value)}

View file

@ -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 `<button>` — a click handler on
* the `<th>` 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 <MTable.Th {...props}>{children}</MTable.Th>
return <MTable.Th
// `none` (not omitted) on the other columns tells assistive tech they are
// sortable but not currently sorted.
aria-sort={sorted ? sorted : 'none'}
{...props}
>
<button type='button' className='wrolpi-th-sort' onClick={onSort}>
{children}
<Icon
name={sorted === 'descending' ? 'arrow down' : 'arrow up'}
size={14}
className={sorted ? undefined : 'wrolpi-th-sort-idle'}
/>
</button>
</MTable.Th>
}
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,
});

View file

@ -92,6 +92,7 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'map outline': 'IconMap',
'microchip': 'IconCpu',
'minus': 'IconMinus',
'paypal': 'IconBrandPaypal',
'play': 'IconPlayerPlay',
'plug': 'IconPlug',
'plus': 'IconPlus',
@ -134,6 +135,29 @@ const semanticNames: Record<string, keyof typeof Tabler> = {
'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 <span className='wrolpi-icon-stack' role='img' aria-label={label}>
{children}
<span className='wrolpi-icon-stack-corner' aria-hidden='true'>{corner}</span>
</span>
}
export interface IconProps extends Omit<React.SVGProps<SVGSVGElement>, 'name' | 'ref'> {
/** A Semantic UI icon name, for migrated call sites. */
name?: string;

View file

@ -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 <MPagination
className='wrolpi-pagination'
value={activePage}
// A single empty page still renders, so the control never collapses to nothing
// and shift the layout when results arrive.
total={totalPages || 1}
onChange={onPageChange}
siblings={siblingRange}
withEdges={showFirstAndLast}
size={size}
disabled={disabled}
radius={0}
getItemProps={page => ({'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 <nav className='wrolpi-tabs'>
<div className='wrolpi-tabs-links'>{children}</div>
{right && <div className='wrolpi-tabs-right'>{right}</div>}
</nav>
}
/**
* 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' : ''}`;

View file

@ -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 `<Search category>`, 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<string, SearchResultGroup>;
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<HTMLInputElement>;
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<HTMLInputElement>;
/** 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<HTMLDivElement>(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<HTMLInputElement>) => {
setHighlighted(-1);
if (hasSuggestions) setOpen(true);
onChange?.(event.currentTarget.value);
}
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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 <div
ref={containerRef}
className={['wrolpi-searchbox', className].filter(Boolean).join(' ')}
>
<div className='wrolpi-searchbox-control'>
<span className='wrolpi-searchbox-icon'><Icon name='search'/></span>
<input
ref={inputRef}
type='text'
name={name}
className='wrolpi-searchbox-input'
placeholder={placeholder}
aria-label={label ?? placeholder}
value={text}
disabled={disabled}
required={required}
autoFocus={autoFocus}
onChange={handleChange}
onKeyDown={handleKeyDown}
// Open on focus when there is something to say — suggestions, or the fact
// that they are still being fetched. Not when the list would just read
// "No results" at a caller that has not searched yet.
onFocus={() => 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 && <span className='wrolpi-searchbox-loading'><Loader size='xs'/></span>}
{clearable && <IconButton
icon='close'
label='Clear search'
className='wrolpi-searchbox-clear'
onClick={handleClear}
disabled={clearDisabled !== null ? clearDisabled : !text}
/>}
</div>
{open && hasSuggestions && <div className='wrolpi-searchbox-results' id={listId} role='listbox'>
{loading && !flat.length && <div className='wrolpi-searchbox-empty'>Searching…</div>}
{!loading && !flat.length && <div className='wrolpi-searchbox-empty'>No results</div>}
{Object.entries(results ?? {}).map(([key, group]) => {
if (!group?.results?.length) return null;
return <div key={key} className='wrolpi-searchbox-group'>
<div className='wrolpi-searchbox-group-name'>{group.name}</div>
{group.results.map((result, resultIndex) => {
index += 1;
const optionIndex = index;
return <div
key={`${key}-${resultIndex}`}
id={`${listId}-${optionIndex}`}
role='option'
aria-selected={optionIndex === highlighted}
className={`wrolpi-searchbox-result${
optionIndex === highlighted ? ' wrolpi-searchbox-result-active' : ''}`}
// mousedown, not click: the input must not blur first.
onMouseDown={event => {
event.preventDefault();
choose(result);
}}
onMouseEnter={() => setHighlighted(optionIndex)}
>
<div className='wrolpi-searchbox-result-title'>{result.title}</div>
{result.description && <div className='wrolpi-searchbox-result-description'>
{result.description}
</div>}
</div>
})}
</div>
})}
</div>}
</div>
}

View file

@ -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';

View file

@ -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);

View file

@ -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 <th> alone is unreachable by keyboard, and the arrow
// glyph says nothing to a screen reader.
const onSort = jest.fn();
renderUI(<Table>
<Table.Header>
<Table.Row>
<Table.HeaderCell sorted='descending' onSort={onSort}>Size</Table.HeaderCell>
<Table.HeaderCell onSort={jest.fn()}>Name</Table.HeaderCell>
<Table.HeaderCell>Actions</Table.HeaderCell>
</Table.Row>
</Table.Header>
</Table>);
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(<Table><Table.Body>
<Table.Row><Table.Cell>wide</Table.Cell></Table.Row>
@ -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(<Pagination activePage={3} totalPages={12} onPageChange={onPageChange}/>);
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(<Pagination activePage={1} totalPages={undefined} onPageChange={jest.fn()}/>);
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(<TabBar>
<button className={tabClassName(true)}>Videos</button>
<button className={tabClassName(false)}>Channels</button>
</TabBar>);
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(<SearchBox value='axe' onChange={jest.fn()} onSubmit={onSubmit}/>);
await userEvent.type(screen.getByRole('textbox'), '{Enter}');
expect(onSubmit).toHaveBeenCalledWith('axe');
});
it('shows suggestions under their group headings', async () => {
renderUI(<SearchBox value='vid' onChange={jest.fn()} results={results}
onResultSelect={jest.fn()}/>);
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(<SearchBox value='vid' onChange={jest.fn()} results={results}
onResultSelect={onResultSelect}/>);
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(<SearchBox value='vid' onChange={jest.fn()} results={results}
onResultSelect={onResultSelect} onSubmit={onSubmit}/>);
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(<SearchBox value='vid' onChange={jest.fn()} results={results}
onResultSelect={onResultSelect}/>);
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(<SearchBox value='vid' onChange={onChange} results={results}
onResultSelect={jest.fn()}/>);
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(<SearchBox value='vid' onChange={jest.fn()} results={{}} loading
onResultSelect={jest.fn()}/>);
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(<SearchBox value='axe' onChange={onChange} onSubmit={onSubmit} clearable/>);
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(<SearchBox value='' onChange={jest.fn()} onSubmit={jest.fn()}/>);
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(
<IconStack corner={<Icon name='question'/>} label='WiFi status unknown'>
<Icon name='wifi'/>
</IconStack>
);
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();

View file

@ -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",