mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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:
parent
57985cea94
commit
3454e042f9
19 changed files with 1030 additions and 224 deletions
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
74
app/src/components/ui/Navigation.tsx
Normal file
74
app/src/components/ui/Navigation.tsx
Normal 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' : ''}`;
|
||||
230
app/src/components/ui/SearchBox.tsx
Normal file
230
app/src/components/ui/SearchBox.tsx
Normal 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>
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue