From 6ea9d3645f81b2883dcdf0c558cfcb3a6895593f Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 30 Jul 2026 10:00:48 -0600 Subject: [PATCH] ui: Common.js and thirteen more files off Semantic Phase 2, part 3. Baseline 63 -> 50. Common.js is the one that mattered: 51 files import it, so its Semantic usage was propping up the rest of the migration. Everything in it now comes from the library -- Paginator, the two search inputs, DirectorySearch, TabLinks, the toggles, the messages, APIButton, Breadcrumbs, SimpleAccordion, IframeViewer, CardPoster, FileIcon, LoadStatistic, the theme menu, and the hotspot icons. Also migrated: Placeholder, Apps, ArchivePreview, CbzViewer, AddToPlaylist, TaggedDeleteConfirmModal, the collection edit/tag forms, and four inventory components. New in the library, all in the gallery and tested: SearchBox Semantic's `` with its grouped results shape kept, because four call sites already build it. A real combobox rather than a div that listens for clicks. Pagination Semantic's prop names; onPageChange gets the page number. TabBar the strip only -- routing stays with the caller. IconStack Semantic's IconGroup, named once for assistive technology. Table.HeaderCell sorted/onSort -- a real button carrying aria-sort. Button/IconButton translate Semantic's size names. That last one is defensive: unmigrated callers still pass size='tiny', and Mantine drops a size it does not recognise without a word, so the button quietly renders at the wrong size. Mapping here keeps the mixed state honest. Three real bugs found and fixed on the way, none of them cosmetic: * `fileSuffixIconName` returned fourteen icon names the map did not have, so opening a folder with a PDF, a Word document or an archive in it logged an error and drew a fallback glyph. All fourteen mapped, with a test that walks the mapper's own return values, and another asserting every mapped name resolves to a Tabler component that exists. * `Statistic` relied on Semantic's `Statistic.Group` for its spacing, so on the dashboard the load averages ran together into "1 MIN. LOAD5 MIN. LOAD15 MIN. LOAD". It carries its own spacing now. * Semantic's inverted stylesheet colors every descendant of an inverted segment, so Toggle labels inherited #dddddd -- white pixels in night mode, on the Settings page, which is exactly where someone adjusts their WROLPi in the dark. Restated from tokens. The baseline ratchet gained a third assertion: a migrated file may still lean on Semantic's CSS classes without importing the library, which the import check cannot see and which would collapse silently when Phase 4 deletes the stylesheet. There is one such bridge today and it is now declared. --- app/src/components/AddToPlaylist.js | 71 +- app/src/components/Apps.js | 135 ++- app/src/components/ArchivePreview.js | 27 +- app/src/components/CbzViewer.js | 36 +- app/src/components/Common.js | 887 +++++++----------- app/src/components/Common.test.js | 48 +- app/src/components/DesktopToggle.test.js | 8 +- app/src/components/FileBrowser.test.js | 4 +- app/src/components/Placeholder.js | 53 +- app/src/components/SearchFilterModal.test.js | 26 +- .../components/TaggedDeleteConfirmModal.js | 57 +- app/src/components/VncToggle.test.js | 2 +- .../calculators/RationCalculator.test.js | 2 +- .../collections/CollectionEditForm.js | 57 +- .../collections/CollectionEditForm.test.js | 23 +- .../collections/CollectionTagModal.js | 66 +- .../collections/CollectionTagModal.test.js | 37 +- .../ConflictResolutionModal.test.js | 7 +- app/src/components/inventory/CatalogEditor.js | 111 ++- .../inventory/InventoryExportPanel.js | 47 +- .../inventory/InventoryItemsMobile.js | 39 +- .../inventory/InventoryItemsMobile.test.js | 19 +- .../components/inventory/InventorySummary.js | 70 +- .../inventory/InventorySummary.test.js | 9 +- app/src/components/ui/Button.tsx | 26 + app/src/components/ui/Icon.tsx | 18 +- app/src/components/ui/Surfaces.tsx | 7 +- app/src/components/ui/ui.css | 73 ++ app/src/components/ui/ui.test.js | 36 + app/src/hooks/customHooks.test.js | 2 +- app/src/hooks/useForm.test.js | 2 +- app/src/hooks/useKeyboardShortcuts.test.js | 23 +- app/src/semantic-baseline.json | 13 - app/src/semantic-baseline.test.js | 17 + app/src/setupTests.js | 15 + 35 files changed, 978 insertions(+), 1095 deletions(-) diff --git a/app/src/components/AddToPlaylist.js b/app/src/components/AddToPlaylist.js index af717c33..3e282f79 100644 --- a/app/src/components/AddToPlaylist.js +++ b/app/src/components/AddToPlaylist.js @@ -1,8 +1,5 @@ import React, {useEffect, useState} from "react"; -import {Input, Message} from "semantic-ui-react"; -import {toast} from "react-semantic-toasts-2"; - -import {Button, Form, List, Loader, Modal} from "./Theme"; +import {Button, IconButton, Loading, Message, Modal, Radio, Stack, TextInput, toast} from "./ui"; import {addPlaylistItem, createPlaylist, fetchPlaylists} from "../api"; @@ -68,32 +65,38 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) { return Add to Playlist - {playlists === undefined && Could not load playlists} - {playlists === null && } + {playlists === undefined && } + {playlists === null && } {Array.isArray(playlists) && playlists.length > 0 && - - {playlists.map(p => setSelected(p.id)}> - - {p.name} - )} - } + + {playlists.map(p => setSelected(p.id)} + />)} + } {Array.isArray(playlists) && playlists.length === 0 &&

No playlists yet — create one below.

} -
- - - setNewName(value)}/> - -
+
{ + e.preventDefault(); + handleCreateAndAdd(); + }} style={{marginTop: '1em'}}> + setNewName(e.currentTarget.value)} + /> +
- + {creating - ? - : } + ? + : }
; } @@ -104,9 +107,12 @@ export function AddToPlaylistModal({open, onClose, items = [], onComplete}) { * - one FileGroup (fileGroupId) or many (fileGroupIds), * - a Zim article (zim={zimId, entry, title}), or * - a URL (url={url, title}) — link to anything the WROLPi browser can open (e.g. a map location). - * Extra props are forwarded to the Button (color, size, disabled, icon, content, ...). + * Extra props are forwarded to the Button (size, disabled, ...). */ -export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist', onComplete, ...buttonProps}) { +export function AddToPlaylistButton({ + fileGroupId, fileGroupIds, zim, url, content = 'Add to Playlist', + title, onComplete, ...buttonProps + }) { const [open, setOpen] = useState(false); let items; @@ -119,13 +125,16 @@ export function AddToPlaylistButton({fileGroupId, fileGroupIds, zim, url, conten items = ids.map(id => ({item_kind: 'file', file_group_id: id})); } - // An empty `content` makes a compact icon-only button (omit content entirely so Semantic does - // not reserve label padding); otherwise render the labeled "Add to Playlist" button. - const contentProps = content ? {content} : {}; + // An empty/null `content` makes a compact icon-only button; the accessible name comes + // from `title` (kept for callers that already pass it) or falls back to the action name. + const iconOnly = !content; return <> - } setOpen(false)} items={items} onComplete={onComplete}/> ; diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js index 5085c65b..d7f1093c 100644 --- a/app/src/components/Apps.js +++ b/app/src/components/Apps.js @@ -1,5 +1,4 @@ -import React, {useContext} from 'react'; -import {Input, Label, StatisticLabel, StatisticValue} from "semantic-ui-react"; +import React from 'react'; import {Link, Route, Routes} from "react-router"; import { ErrorMessage, @@ -10,16 +9,13 @@ import { toLocaleString, useTitle } from "./Common"; -import {ThemeContext} from "../contexts/contexts"; -import {Header, Loader, Segment, Statistic} from "./Theme"; +import {Header, Label, Loading, Panel, Statistic, StatisticGroup, TextInput} from "./ui"; import {useStatistics} from "../hooks/customHooks"; import {CalculatorsPage} from "./Calculators"; function StatisticsPage() { useTitle('Statistics'); - const {s} = useContext(ThemeContext); - const {statistics} = useStatistics(); if (statistics === undefined) { @@ -49,90 +45,56 @@ function StatisticsPage() { return <>
Statistics
Files
- - - - {toLocaleString(total_count)} - All Files - - - {humanFileSize(total_size)} - Total Size - - - - - + + + + + + + + - - {toLocaleString(video_count)} - Videos - + - - {toLocaleString(pdf_count)} - PDFs - - - {toLocaleString(ebook_count)} - eBooks - - - {toLocaleString(archive_count)} - Archives - - - {toLocaleString(image_count)} - Images - - - - - - - {toLocaleString(zip_count)} - ZIP - - - {toLocaleString(audio_count)} - Audio - - - + + + + + + + + + + + +
Tags
- - - - {humanNumber(tags_count)} - Tags - - - {humanNumber(tagged_files)} - Tagged Files - - - {humanNumber(tagged_zims)} - Tagged Zims - - - + + + + + + +
Database
- - - - {humanFileSize(db_size)} - Size - - - + + + + + ; } return <>
Statistics
- + ; } @@ -146,7 +108,16 @@ export function MoreRoute(props) { } -export function ColoredInput({name, value, label, color, ...props}) { - label = label ? : null; - return +/** + * A text input with a colored tag attached to its leading (or trailing) edge. + * Replaces Semantic's `} labelPosition=.../>`. + */ +export function ColoredInput({name, value, label, color, labelPosition = 'left', fluid, style, ...props}) { + const labelNode = label ? : null; + + return
+ {labelNode && labelPosition === 'left' && labelNode} + + {labelNode && labelPosition !== 'left' && labelNode} +
} diff --git a/app/src/components/ArchivePreview.js b/app/src/components/ArchivePreview.js index cdbb7df4..3c959a96 100644 --- a/app/src/components/ArchivePreview.js +++ b/app/src/components/ArchivePreview.js @@ -1,8 +1,7 @@ import React from "react"; -import {Icon, Loader, Message, Placeholder, PlaceholderLine} from "semantic-ui-react"; import {downloadArchiveMember, getArchiveContents} from "../api"; import {fileSuffixIconName, humanFileSize} from "./Common"; -import {Modal, Segment, Table} from "./Theme"; +import {Icon, Message, Modal, Panel, Placeholder, Table} from "./ui"; function ArchiveEntry({entry, depth, archivePath}) { const [expanded, setExpanded] = React.useState(depth === 0); @@ -19,9 +18,9 @@ function ArchiveEntry({entry, depth, archivePath}) { {indent} - {entry.name}/ + {' '}{entry.name}/ - + — @@ -37,11 +36,11 @@ function ArchiveEntry({entry, depth, archivePath}) { {indent} - {entry.name} + {' '}{entry.name} {' '} - + - + {humanFileSize(entry.size)} @@ -52,7 +51,7 @@ function SkeletonRows() { {[1, 2, 3].map(i => - + )} @@ -88,24 +87,24 @@ export function ArchivePreviewContent({previewFile}) { if (error) { return - - Cannot read archive +

{error}

} return - {!loading && contents && + {!loading && contents && + {' '} {contents.total_files} files,{' '} {humanFileSize(contents.total_size)} total - } - + } +
Name - Size + Size diff --git a/app/src/components/CbzViewer.js b/app/src/components/CbzViewer.js index 7690ad33..9e3feaa4 100644 --- a/app/src/components/CbzViewer.js +++ b/app/src/components/CbzViewer.js @@ -1,7 +1,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; +import {IconArrowLeft, IconArrowRight} from '@tabler/icons-react'; import {getArchiveContents, getArchiveMemberUrl} from '../api'; -import {Icon, Label, Loader, Message} from 'semantic-ui-react'; -import {Button} from './Theme'; +import {IconButton, Label, Loading, Message} from './ui'; import {useLocalStorage} from './Common'; const IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp|bmp|tiff?)$/i; @@ -77,15 +77,13 @@ export function CbzViewer({path}) { }, [onLeftClick, onRightClick]); if (error) { - return - Cannot load comic + return

{error}

; } - if (!pages) return ; + if (!pages) return ; if (pages.length === 0) { - return - No images found + return

This archive does not contain any image files.

; } @@ -94,12 +92,12 @@ export function CbzViewer({path}) { const hasNext = currentPage < pages.length - 1; return
-
- + setRtl(r => !r)}/> - + - +
; } diff --git a/app/src/components/Common.js b/app/src/components/Common.js index 5b8f5363..a83fb145 100644 --- a/app/src/components/Common.js +++ b/app/src/components/Common.js @@ -1,83 +1,52 @@ import React, {useContext, useEffect, useState} from "react"; -import { - AccordionContent, - AccordionTitle, - BreadcrumbSection, - Button as SButton, - ButtonGroup, - Card, - Container, - Dimmer, - DimmerDimmable, - Dropdown, - GridColumn, - GridRow, - Icon as SIcon, - IconGroup, - Input, - Label, - Pagination, - Search, - Transition -} from 'semantic-ui-react'; import {Link, NavLink, useNavigate} from "react-router"; -import Message from "semantic-ui-react/dist/commonjs/collections/Message"; -import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useVnc, useWROLMode} from "../hooks/customHooks"; -import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts"; import { Accordion, - Breadcrumb, + Breadcrumbs as UIBreadcrumbs, Button, - Card as ThemedCard, + ButtonGroup, + CardGroup, Confirm, - Form, Header, Icon, - Loader, + IconButton, + IconStack, + Label, + Loading, Menu, + Message, Modal, - Popup, - Segment, + Pagination, + Panel, + SearchBox, Statistic, - systemTheme, - themeChoices -} from "./Theme"; + TabBar, + tabClassName, + TextInput, + Toggle as UIToggle, + Tooltip, +} from "./ui"; +import {useBluetooth, useDesktop, useHotspot, useSearchDirectories, useSearchOrder, useThrottle, useVnc, useWROLMode} from "../hooks/customHooks"; +import {Media, SettingsContext, StatusContext, ThemeContext} from "../contexts/contexts"; +import {themeChoices} from "./Theme"; import {FilePreviewContext} from "./FilePreview"; import _ from "lodash"; import {killDownloads, startDownloads, unlockCookies} from "../api"; -import {allFrequencyOptions, HELP_VIEWER_URI, NAME, semanticUIColorMap, validUrlRegex} from "./Vars"; -import Grid from "semantic-ui-react/dist/commonjs/collections/Grid"; +import {allFrequencyOptions, HELP_VIEWER_URI, NAME, validUrlRegex} from "./Vars"; export function Paginator({activePage, onPageChange, totalPages, showFirstAndLast, size = 'mini'}) { - const handlePageChange = (e, {activePage}) => { - onPageChange(activePage); - } + // Fewer page numbers on a phone: the strip has to fit without wrapping. + const pager = (siblingRange) => ; return <> - - - - - - + {pager(1)} + {pager(3)} } @@ -142,11 +111,9 @@ export function secondsToElapsedPopup(seconds) { if (!elapsed) { return <>; } - return {elapsed}} - /> + return + {elapsed} + } export function isoDatetimeToElapsedPopup(dt) { @@ -161,12 +128,9 @@ export function isoDatetimeToAgoPopup(dt, short = true) { if (seconds === 0 || !elapsed) { return <>; } - const trigger = {isoDatetimeToString(dt)}; - return {elapsed} ago} - on='hover' - trigger={trigger} - /> + return + {isoDatetimeToString(dt)} + } export function secondsToHMS(totalSeconds) { @@ -431,86 +395,37 @@ export function scrollToTopOfElement(element, smooth = true) { }); } -const useClearableInput = (searchStr, onChange, onClear, onSubmit, size = 'small', placeholder = 'Search...', icon = 'search', clearDisabled = null, inputRef = null) => { +/** + * Shared behaviour for the search inputs: keep a local value so typing is not + * throttled by the parent, but follow `searchStr` when it changes underneath. + */ +const useSearchValue = (searchStr, onChange) => { const [value, setValue] = useState(searchStr || ''); - const [submitted, setSubmitted] = useState(false); React.useEffect(() => { - // `searchStr` is the source of truth from the parent (often an async URL query param). - // Depend ONLY on `searchStr` -- never on `value` -- otherwise a transient render where the - // local `value` has advanced (e.g. "ex") but `searchStr` still trails ("e") fires this - // effect and reverts `value`, garbling fast typing (flaky filter test). + // `searchStr` is the source of truth from the parent (often an async URL query + // param). Depend ONLY on `searchStr` -- never on `value` -- otherwise a transient + // render where the local `value` has advanced ("ex") but `searchStr` still trails + // ("e") fires this effect and reverts `value`, garbling fast typing. if ((searchStr || '') !== value) { setValue(searchStr || ''); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchStr]); - const handleChange = (e) => { - if (e) { - e.preventDefault(); - } - setValue(e.target.value); - setSubmitted(false); + const handleChange = (newValue) => { + setValue(newValue); if (onChange) { // Try to call the remote function, don't let its failure break this. try { - onChange(e.target.value); + onChange(newValue); } catch (e) { - console.error(`Call to ${onChange} failed`); + console.error('Call to onChange failed', e); } } } - const handleClearSearch = (e) => { - e.preventDefault(); - // Clear the input when the "clear" button is clicked, search again. - setValue(''); - setSubmitted(false); - if (onSubmit) { - onSubmit(''); - } - if (onClear) { - onClear(); - } - } - - const localOnSubmit = (e) => { - // Send the value up when submitting. - e.preventDefault(); - setSubmitted(true); - if (onSubmit) { - onSubmit(value); - } else { - console.debug('No onSubmit defined'); - } - } - - const clearButton = + ; @@ -691,16 +608,12 @@ export function CookiesLockedMessage() { } return <> - - - - Cookies Locked - You have encrypted cookies stored, but they are currently locked. - setModalOpen(true)}> - Unlock Cookies - - + + You have encrypted cookies stored, but they are currently locked. + setModalOpen(false)}/> @@ -722,53 +635,34 @@ export function textEllipsis(str, maxLength = 100, {side = "end", ellipsis = ".. } export function TabLinks({links, right}) { - return + return {links.map((link) => { - const active = link.isActive ? link.isActive() : isActive; - return active ? 'item active' : 'item'; - }} + className={({isActive}) => tabClassName(link.isActive ? link.isActive() : isActive)} > {link.text} )} - {right && - - {right} - } - + } export function PageContainer(props) { return <> - - {props.children} - +
{props.children}
- - {props.children} - +
{props.children}
; } export function CardGroupCentered(props) { + // One responsive grid; it centres what it cannot fill, so the two viewports no + // longer need separate markup. return
- - - {props.children} - - - - - {props.children} - - + {props.children}
} @@ -832,14 +726,12 @@ export function findPosterPath(file) { } export function CardPoster({to, file}) { - const {s} = useContext(ThemeContext); // Used to center posters in CardIcon. - const style = {display: 'flex', justifyContent: 'center', ...s['style']}; + const style = {display: 'flex', justifyContent: 'center'}; const navigate = useNavigate(); - const cardTagIcon =
- -
; + // Marks a tagged file, pinned to the poster's corner. + const cardTagIcon =
; let imageLabel = !_.isEmpty(file.tags) ? cardTagIcon : null; let posterPath = findPosterPath(file); @@ -872,18 +764,18 @@ export function CardPoster({to, file}) { if (!to || (to.startsWith('/media/') || to.startsWith('/download/'))) { // "to" is a downloadable file outside the app, preview the file. return - +
{imageLabel} - +
} else if (!posterPath && to) { // Link to the full page in this App. return - navigate(to)}> +
navigate(to)}> {imageLabel} - +
} } @@ -899,16 +791,10 @@ export function InfoPopup({ iconStyle = { margin: '0.5em'}, ...props }) { - const trigger = ; - return + return {header}
{content} : content} + multiline w={260} {...props}> + +
} export function InfoHeader({ @@ -946,7 +832,8 @@ export function HelpModal({ const style = {position: 'relative', height: '75vh', width: '100%', border: 'none'}; return <> - setOpen(true)}/> + setOpen(true)}/> setOpen(false)} size={modalSize} closeIcon> {title && {title}} @@ -1007,12 +894,13 @@ function SubsystemToggle({label, on, onChange, unsupportedMessage, info = null, {confirmStop && setConfirmOpen(false)} - onClose={() => setConfirmOpen(false)} onConfirm={handleConfirm} - header={confirmStop.header} - content={confirmStop.content} - confirmButton={confirmStop.button} - />} + title={confirmStop.header} + confirmLabel={confirmStop.button} + destructive + > + {confirmStop.content} + } { - }; - if (onChange) { - onMouseUp = (e) => { - if (disabled) { - return; - } - e.preventDefault(); - if (onChange) { - onChange(!checked); - } - } - } - - if (icon) { - icon = - } - - const body = -
- - -
- - {icon} - {label} - {info && } - -
+ // Keeps this call signature -- `onChange` receives the new boolean, not an event -- + // because a dozen call sites are written against it. + const body = + + {icon && } + {label} + } + checked={checked === true} + disabled={disabled === true} + onChange={(e) => onChange && onChange(e.currentTarget.checked)} + data-testid='toggle' + /> + {info && } + ; if (popupContent) { - return + return {body} } return body } @@ -1170,14 +1024,12 @@ export function DisableDownloadsToggle() { } const on = downloads && downloads['disabled'] === false && downloads['stopped'] === false; - return
- - ; + return ; } export function emptyToNull(obj) { @@ -1356,26 +1208,34 @@ export function fileSuffixIconName(filename) { return 'file'; } -export function FileIcon({file, disabled = true, size = 'huge', ...props}) { - // Default to a grey file icon. +export function FileIcon({file, disabled = true, size = 48, ...props}) { const {mimetype, path, primary_path} = file; // `file` may be a file_group or a file. const lowerPath = primary_path ? primary_path.toLocaleString() : path.toLowerCase(); - props['name'] = mimetypeIconName(mimetype, lowerPath); - props['color'] = mimetypeColor(mimetype, lowerPath); - return + const name = mimetypeIconName(mimetype, lowerPath); + // The mimetype picks a hue; the token picks what that hue is in this theme. + const color = mimetypeColor(mimetype, lowerPath); + return } export function LoadStatistic({label, value, cores, ...props}) { + // Load is a warning above half the cores, and a problem above three quarters. const quarter = cores / 4; + let color; if (cores && value >= (quarter * 3)) { - props['color'] = 'red'; + color = 'red'; } else if (cores && value >= (quarter * 2)) { - props['color'] = 'orange'; + color = 'orange'; } return ; } @@ -1383,23 +1243,29 @@ export function DarkModeToggle() { const {savedTheme, setTheme} = useContext(ThemeContext); const active = themeChoices.find(i => i.value === savedTheme) || themeChoices[0]; - return } - title={`Theme: ${active.text}`} - style={{padding: 0, marginRight: '0.8em'}} - > - - - {themeChoices.map(choice => + + + + + Theme + {themeChoices.map(choice => } onClick={() => setTheme(choice.value)} - />)} - - + // The chosen theme is marked by weight as well as color, so the mark + // survives in the monochrome themes. + style={choice.value === active.value ? {fontWeight: 700} : undefined} + > + {choice.text} + )} + + } @@ -1408,28 +1274,36 @@ export function UnsupportedModal(header, message, icon) { const onOpen = () => setOpen(true); const onClose = () => setOpen(false); - const modal = -
- - {header || 'Unsupported'} -
- - - {message} - - + const modal = + + + + {header || 'Unsupported'} + + + {message} - + ; return {modal, doClose: onClose, doOpen: onOpen}; } +/** + * The hotspot glyph in the nav bar: a wifi icon with a corner mark for the state. + * A button, not an anchor -- every one of these opens a dialog or toggles the + * hotspot, and `` was neither keyboard-honest nor navigable. + */ +function HotspotButton({label, corner, on, onClick}) { + const wifi = ; + return + corner + ? } label={label}>{wifi} + : wifi + }/> +} + export function HotspotStatusIcon() { const {on, inUse, setHotspot, dockerized, hotspotSsid} = useHotspot(); const {modal: unsupportedModal, doOpen: openUnsupportedModal} = @@ -1459,55 +1333,39 @@ export function HotspotStatusIcon() { return <> setInUseOpen(false)} - onClose={() => setInUseOpen(false)} onConfirm={handleConfirmInUse} - header='Wifi is in-use' - content={content} - confirmButton='Start Hotspot' - /> - setInUseOpen(true)}> - - - - - + title='Wifi is in-use' + confirmLabel='Start Hotspot' + > + {content} + + setInUseOpen(true)}/> } else if (dockerized === false && on === true) { return <> setStopHotspotOpen(false)} - onClose={() => setStopHotspotOpen(false)} onConfirm={handleConfirmStop} - header='Stop the hotspot' - content='You will be disconnected when using the hotspot. Are you sure?' - confirmButton='Stop' - /> - setStopHotspotOpen(true)}> - - - {on === false && } - {on === null && } - - + title='Stop the hotspot' + confirmLabel='Stop' + destructive + > + You will be disconnected when using the hotspot. Are you sure? + + setStopHotspotOpen(true)}/> } else if (dockerized === false && on === false) { - return setHotspot(true)}> - - - - - + return setHotspot(true)}/> } // Hotspot is not available, or, status has not yet been fetched. return <> - - - - - - + {unsupportedModal} } @@ -1566,8 +1424,6 @@ export function DirectorySearch({onSelect, value, disabled, required, ...props}) const newDirectory = isDir ? {} : { newDirectory: { name: 'New Directory', - // title must not be null; an unset form field passes a null value and - // Semantic UI's Search crashes reading null.length. results: [{title: directoryName || ''}], } }; @@ -1601,21 +1457,15 @@ export function DirectorySearch({onSelect, value, disabled, required, ...props}) loading, ]); - const handleSearchChange = (e, data) => { - if (e) { - e.preventDefault(); - } - setDirectoryName(data.value); + const handleSearchChange = (value) => { + setDirectoryName(value); } - const handleResultSelect = (e, data) => { - if (e) { - e.preventDefault(); - } - setDirectoryName(data.result.title); + const handleResultSelect = (result) => { + setDirectoryName(result.title); // title is the relative path. if (onSelect) { - onSelect(data.result.title); + onSelect(result.title); } } @@ -1626,22 +1476,23 @@ export function DirectorySearch({onSelect, value, disabled, required, ...props}) } } - return } export const BackButton = ({...props}) => { const navigate = useNavigate(); - return ; } export const filterToMimetypes = (filter) => { @@ -1750,7 +1601,8 @@ export function SortButton({sorts = []}) { let sortFields; if (sorts && sorts.length) { sortFields = sorts.map((i) => { - return + return }) } @@ -1764,17 +1616,21 @@ export function SortButton({sorts = []}) { {sortFields}
- - } export function TagIcon() { - return + return
- - - File - Tags - - - - {(taggedFileGroups || []).map((fg, idx) => ( - - {fg.primary_path || fg.name} - {(fg.tags || []).join(', ')} - - ))} - -
-
- - - - - ; + return Tagged Files Will Be Deleted} + confirmLabel='Delete' + destructive + onConfirm={onConfirm} + onCancel={onCancel} + > +

The following tagged files will be deleted:

+ + + + File + Tags + + + + {(taggedFileGroups || []).map((fg, idx) => ( + + {fg.primary_path || fg.name} + {(fg.tags || []).join(', ')} + + ))} + +
+
; } diff --git a/app/src/components/VncToggle.test.js b/app/src/components/VncToggle.test.js index 9c2c37ce..6c7408c1 100644 --- a/app/src/components/VncToggle.test.js +++ b/app/src/components/VncToggle.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import {render, screen} from '@testing-library/react'; +import {render, screen} from '../test-utils'; import userEvent from '@testing-library/user-event'; import {VncToggle} from './Common'; diff --git a/app/src/components/calculators/RationCalculator.test.js b/app/src/components/calculators/RationCalculator.test.js index f6953fc3..80f9de99 100644 --- a/app/src/components/calculators/RationCalculator.test.js +++ b/app/src/components/calculators/RationCalculator.test.js @@ -1,5 +1,5 @@ import React from "react"; -import {fireEvent, render, screen, within} from "@testing-library/react"; +import {fireEvent, render, screen, within} from "../../test-utils"; import { dailyCalorieDemand, daysOfFood, findCaloriesKey, findCountKey, RationEstimatePanel, totalCalories, } from "./RationCalculator"; diff --git a/app/src/components/collections/CollectionEditForm.js b/app/src/components/collections/CollectionEditForm.js index 0e152baa..f0354cee 100644 --- a/app/src/components/collections/CollectionEditForm.js +++ b/app/src/components/collections/CollectionEditForm.js @@ -1,6 +1,5 @@ import React from 'react'; -import {Button, Form, Grid, Message} from 'semantic-ui-react'; -import {Header, Segment} from '../Theme'; +import {Button, Header, Message, Panel} from '../ui'; import {TagsContext} from '../../Tags'; import {WROLModeMessage} from '../Common'; @@ -37,51 +36,63 @@ export function CollectionEditForm({ } }; - return + return {title &&
{title}
} {wrolModeContent && } {/* Display form-level errors */} - {form.error && - Error + {form.error &&

{form.error}

} -
- + + {/* + * `ui stackable grid` replicates Semantic's own Grid CSS classes (still loaded + * globally via semantic-ui-offline while other call sites remain unmigrated). + * Callers of this component (Channels.js, Archive.js, Playlists.js) still pass + * Semantic / children -- they have not migrated to the + * component library yet. This div keeps their layout without importing + * semantic-ui-react here. Replace with the real `Grid`/`Grid.Col` once every + * caller has migrated. + */} +
{children} - {appliedTagName && - + {appliedTagName &&
+
- - } +
+
} - - +
+
{actionButtons} {onCancel && } - - +
+
- - - - - ; +
+
+
+ +
; } diff --git a/app/src/components/collections/CollectionEditForm.test.js b/app/src/components/collections/CollectionEditForm.test.js index 34fb8070..7cf2f7e2 100644 --- a/app/src/components/collections/CollectionEditForm.test.js +++ b/app/src/components/collections/CollectionEditForm.test.js @@ -2,7 +2,6 @@ import React from 'react'; import { createMockDomain, createTestForm, - hasInvertedStyling, render, renderInDarkMode, renderInLightMode, @@ -210,7 +209,11 @@ describe('CollectionEditForm', () => { }); describe('Theme Integration', () => { - it('applies inverted styling to Segment in dark mode', () => { + // Panel/Header are token-driven (see ui/Surfaces.tsx): their colors come from CSS + // variables scoped on `[data-theme]`, not from JS branching on the theme. There is + // no more "inverted" prop/class to assert on -- just that the same markup renders + // in both modes. + it('renders a token-driven Panel in dark mode', () => { const form = createTestForm(mockCollection); const {container} = renderInDarkMode( @@ -219,12 +222,10 @@ describe('CollectionEditForm', () => { ); - const segment = container.querySelector('.ui.segment'); - expect(segment).toBeInTheDocument(); - expect(hasInvertedStyling(segment)).toBe(true); + expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument(); }); - it('does not apply inverted styling in light mode', () => { + it('renders a token-driven Panel in light mode', () => { const form = createTestForm(mockCollection); const {container} = renderInLightMode( @@ -233,12 +234,10 @@ describe('CollectionEditForm', () => { ); - const segment = container.querySelector('.ui.segment'); - expect(segment).toBeInTheDocument(); - expect(hasInvertedStyling(segment)).toBe(false); + expect(container.querySelector('.wrolpi-panel')).toBeInTheDocument(); }); - it('applies dark theme styling to Header in dark mode', () => { + it('renders a token-driven Header for the title', () => { const form = createTestForm(mockCollection); const {container} = renderInDarkMode( @@ -247,9 +246,9 @@ describe('CollectionEditForm', () => { ); - const header = container.querySelector('.ui.header'); + const header = container.querySelector('.wrolpi-header'); expect(header).toBeInTheDocument(); - expect(header.style.color).toBe('rgb(238, 238, 238)'); // #eeeeee + expect(header).toHaveTextContent('Test Collection'); }); }); diff --git a/app/src/components/collections/CollectionTagModal.js b/app/src/components/collections/CollectionTagModal.js index fdbc6b96..5ba70b41 100644 --- a/app/src/components/collections/CollectionTagModal.js +++ b/app/src/components/collections/CollectionTagModal.js @@ -1,7 +1,5 @@ import React, {useCallback, useEffect, useState} from 'react'; -import {Grid, Input} from 'semantic-ui-react'; -import Message from 'semantic-ui-react/dist/commonjs/collections/Message'; -import {Button, Modal} from '../Theme'; +import {Button, Message, Modal, Stack, TextInput} from '../ui'; import {TagsSelector} from '../../Tags'; import {APIButton, Toggle} from '../Common'; @@ -107,54 +105,36 @@ export function CollectionTagModal({ > {modalTitle} - - - - handleTagChange(null)} - /> - - + + handleTagChange(null)} + /> {hasDirectory && ( - - - - - + )} {hasDirectory && ( - - - setNewTagDirectory(value)} - disabled={!moveToTagDirectory} - /> - - + setNewTagDirectory(e.currentTarget.value)} + disabled={!moveToTagDirectory} + /> )} {hasDirectory && conflictMessage && ( - - - - Directory Conflict -

{conflictMessage}

-
-
-
+ +

{conflictMessage}

+
)} -
+
- + { - const MockModal = ({open, onClose, children, closeIcon}) => { - if (!open) return null; - return ( -
- {closeIcon && } - {children} -
- ); - }; - MockModal.Header = ({children}) =>
{children}
; - MockModal.Content = ({children}) =>
{children}
; - MockModal.Actions = ({children}) =>
{children}
; - MockModal.Description = ({children}) =>
{children}
; - - return { - ...jest.requireActual('../Theme'), - Modal: MockModal, - Button: ({children, onClick, ...props}) => ( - - ), - }; -}); - // Mock Common components jest.mock('../Common', () => ({ ...jest.requireActual('../Common'), @@ -76,22 +51,22 @@ describe('CollectionTagModal', () => { describe('Modal Rendering', () => { it('renders modal when open is true', () => { render(); - expect(screen.getByTestId('modal')).toBeInTheDocument(); + expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('does not render modal when open is false', () => { render(); - expect(screen.queryByTestId('modal')).not.toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); }); it('shows "Add Tag" header when no current tag', () => { render(); - expect(screen.getByTestId('modal-header')).toHaveTextContent('Add Tag'); + expect(screen.getByRole('heading')).toHaveTextContent('Add Tag'); }); it('shows "Modify Tag" header when there is a current tag', () => { render(); - expect(screen.getByTestId('modal-header')).toHaveTextContent('Modify Tag'); + expect(screen.getByRole('heading')).toHaveTextContent('Modify Tag'); }); }); @@ -308,7 +283,7 @@ describe('CollectionTagModal', () => { const mockOnClose = jest.fn(); render(); - const cancelButton = screen.getByTestId('button-Cancel'); + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); await userEvent.click(cancelButton); expect(mockOnClose).toHaveBeenCalled(); @@ -341,7 +316,7 @@ describe('CollectionTagModal', () => { }); // Close the modal - const cancelButton = screen.getByTestId('button-Cancel'); + const cancelButton = screen.getByRole('button', {name: 'Cancel'}); await userEvent.click(cancelButton); // Reopen modal diff --git a/app/src/components/collections/ConflictResolutionModal.test.js b/app/src/components/collections/ConflictResolutionModal.test.js index f24dfaa0..27a90054 100644 --- a/app/src/components/collections/ConflictResolutionModal.test.js +++ b/app/src/components/collections/ConflictResolutionModal.test.js @@ -340,10 +340,9 @@ describe('ConflictResolutionModal', () => { // Confirming should re-call deleteFileGroups with force=true. api.deleteFileGroups.mockResolvedValueOnce({}); - // The TaggedDeleteConfirmModal's Delete button has the trash icon. - const confirmButton = screen.getAllByRole('button') - .find(b => b.classList.contains('red') && b.textContent.includes('Delete') - && b.querySelector('.trash.icon')); + // The TaggedDeleteConfirmModal (a Confirm) names its destructive action "Delete", + // distinct from the "Delete this file" icon buttons queried above. + const confirmButton = screen.getByRole('button', {name: 'Delete'}); fireEvent.click(confirmButton); await waitFor(() => { diff --git a/app/src/components/inventory/CatalogEditor.js b/app/src/components/inventory/CatalogEditor.js index 3927a89d..2c087a5d 100644 --- a/app/src/components/inventory/CatalogEditor.js +++ b/app/src/components/inventory/CatalogEditor.js @@ -1,18 +1,16 @@ import React, {useState} from "react"; -import {Input, Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; -import {Button, Header, Icon, Modal, Table} from "../Theme"; +import {Button, IconButton, Modal, Select, Table, Text, TextInput} from "../ui"; import {ALL_UNITS} from "./units"; -import {ThemeContext} from "../../contexts/contexts"; -const UNIT_OPTIONS = [{key: '', value: '', text: '—'}, ...ALL_UNITS.map(u => ({key: u, value: u, text: u}))]; +const UNIT_OPTIONS = [{value: '', label: '—'}, ...ALL_UNITS.map(u => ({value: u, label: u}))]; const COLUMNS = [ - {key: 'name', label: 'Name', width: 4}, - {key: 'category', label: 'Category', width: 3}, - {key: 'subcategory', label: 'Subcategory', width: 3}, - {key: 'item_size', label: 'Size', width: 2}, - {key: 'item_size_unit', label: 'Unit', width: 2}, - {key: 'calories', label: 'kcal', width: 2}, + {key: 'name', label: 'Name'}, + {key: 'category', label: 'Category'}, + {key: 'subcategory', label: 'Subcategory'}, + {key: 'item_size', label: 'Size'}, + {key: 'item_size_unit', label: 'Unit'}, + {key: 'calories', label: 'kcal'}, ]; function blankEntry() { @@ -25,7 +23,6 @@ function blankEntry() { */ export function CatalogEditor({catalog, open, onClose, onSave}) { const [draft, setDraft] = useState([]); - const {t} = React.useContext(ThemeContext); React.useEffect(() => { if (open) { @@ -45,66 +42,66 @@ export function CatalogEditor({catalog, open, onClose, onSave}) { onClose(); }; - return + return Food Catalog - -

+ + Known items used to autocomplete and pre-fill the entry form. kcal is the total calories for one package of the given size. -

- - - - - {COLUMNS.map(c => {c.label})} - - - - + + +
+ + + {COLUMNS.map(c => {c.label})} + + + + {draft.map((entry, index) => { const aria = (col) => `${entry.name || 'new item'} ${col}`; - return - - update(index, 'name', e.target.value)}/> - - - + + update(index, 'name', e.currentTarget.value)}/> + + + update(index, 'category', e.target.value)}/> - - - update(index, 'category', e.currentTarget.value)}/> + + + update(index, 'subcategory', e.target.value)}/> - - - update(index, 'subcategory', e.currentTarget.value)}/> + + + update(index, 'item_size', e.target.value)}/> - - - update(index, 'item_size_unit', data.value)}/> - - - update(index, 'item_size_unit', value)}/> + + + update(index, 'calories', e.target.value)}/> - - - - - ; + onChange={e => update(index, 'calories', e.currentTarget.value)}/> + + + remove(index)}/> + + ; })} - +
- - + +
; } diff --git a/app/src/components/inventory/InventoryExportPanel.js b/app/src/components/inventory/InventoryExportPanel.js index 7d7ec0f1..b91f00c7 100644 --- a/app/src/components/inventory/InventoryExportPanel.js +++ b/app/src/components/inventory/InventoryExportPanel.js @@ -1,11 +1,9 @@ import React from "react"; -import {Select} from "semantic-ui-react"; -import {Button, Header, Icon, Segment} from "../Theme"; +import {Button, Group, Header, Panel, Select, Text} from "../ui"; import {downloadCSV, inventoryExportFilename, toCSV} from "./inventoryExport"; import {groupFieldsOf, summableFieldsOf} from "./summarize"; -import {ThemeContext} from "../../contexts/contexts"; -const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label})); +const fieldOptions = (fs) => fs.map(f => ({value: f.key, label: f.label})); /** * The "Export" tab: explains each export format and provides the action to produce it. CSV is the raw items table, @@ -16,47 +14,46 @@ export function InventoryExportPanel({name, fields, items, groupKey, sumKey, onG const count = (items || []).length; const groupFields = groupFieldsOf(fields); const summableFields = summableFieldsOf(fields); - const {t} = React.useContext(ThemeContext); const exportCSV = () => downloadCSV(inventoryExportFilename(name, 'csv'), toCSV(fields, items)); return <> - -
CSV
-

+ +

CSV
+ A comma-separated spreadsheet of all {count} item{count === 1 ? '' : 's'} and every field, openable in Excel, Numbers, or Google Sheets. Generated entirely on this device. -

- -
+ - -
PDF
-

+ +

PDF
+ A printable table of the whole inventory, followed by a summary grouped by the field you choose. This opens your browser's print dialog — choose "Save as PDF" as the destination to get a PDF file, or print it on paper. -

+
{groupFields.length > 0 && -
+ Summary group:{' '} - onGroupKey(value)}/> {summableFields.length > 0 && Sum:{' '} - onSumKey(value || undefined)}/> } -
} - -
+ ; } diff --git a/app/src/components/inventory/InventoryItemsMobile.js b/app/src/components/inventory/InventoryItemsMobile.js index fa3c478a..f85f6574 100644 --- a/app/src/components/inventory/InventoryItemsMobile.js +++ b/app/src/components/inventory/InventoryItemsMobile.js @@ -1,8 +1,6 @@ import React from "react"; -import {TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; -import {Icon, Table} from "../Theme"; +import {Icon, Table, Text} from "../ui"; import {formatValue, isItemExpired} from "./InventoryTable"; -import {ThemeContext} from "../../contexts/contexts"; // Fallback columns for inventories whose schema predates the per-field `mobile` flag: these preferred keys in // order, else the first few fields. @@ -31,33 +29,32 @@ export function mobileColumns(fields) { */ export function InventoryItemsMobile({fields, items}) { const columns = mobileColumns(fields); - const {t} = React.useContext(ThemeContext); return <> -

+ Read-only — rotate to landscape to edit. -

+ {(items || []).length === 0 - ?

No items yet.

- : - - - {columns.map(f => {f.label})} - - - + ? No items yet. + :
+ + + {columns.map(f => {f.label})} + + + {items.map(item => { const expired = isItemExpired(item, fields); - return - {columns.map((f, idx) => + return + {columns.map((f, idx) => {idx === 0 && expired && - } + } {formatValue(item, f)} - )} - ; + )} + ; })} - +
} ; } diff --git a/app/src/components/inventory/InventoryItemsMobile.test.js b/app/src/components/inventory/InventoryItemsMobile.test.js index 9d644674..84a4007b 100644 --- a/app/src/components/inventory/InventoryItemsMobile.test.js +++ b/app/src/components/inventory/InventoryItemsMobile.test.js @@ -1,5 +1,6 @@ import React from "react"; -import {render, screen} from "@testing-library/react"; +import {screen} from "@testing-library/react"; +import {renderWithProviders} from "../../test-utils"; import {InventoryItemsMobile} from "./InventoryItemsMobile"; const FIELDS = [ @@ -19,7 +20,7 @@ describe('InventoryItemsMobile', () => { {key: 'count', label: 'Count', type: 'number', order: 2, mobile: true}, {key: 'expiration_date', label: 'Expires', type: 'date', order: 3, mobile: true}, ]; - render(); + renderWithProviders(); const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent); expect(headers).toEqual(['Name', 'Count', 'Expires']); @@ -28,7 +29,7 @@ describe('InventoryItemsMobile', () => { }); test('falls back to default columns when no field is flagged mobile', () => { - render(); + renderWithProviders(); const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent); expect(headers).toEqual(['Name', 'Sub-category', 'Size', 'Count']); @@ -44,7 +45,7 @@ describe('InventoryItemsMobile', () => { {key: 'purchase_date', label: 'Purchased', type: 'date', order: 3}, {key: 'location', label: 'Location', type: 'location', order: 4}, ]; - render(); + renderWithProviders(); const headers = [...document.querySelectorAll('thead th')].map(th => th.textContent); expect(headers).toEqual(['Fuel', 'Container', 'Volume', 'Purchased']); }); @@ -56,17 +57,17 @@ describe('InventoryItemsMobile', () => { {id: 2, name: 'Fresh', subcategory: 'beans', item_size: '10', item_size_unit: 'lb', count: '4', expiration_date: '2999-01-01'}, ]; - const {container} = render(); + renderWithProviders(); expect(screen.getByText('5 lb')).toBeTruthy(); const rowOf = (name) => screen.getByText(name).closest('tr'); - expect(rowOf('Old').className).toContain('negative'); - expect(rowOf('Fresh').className).not.toContain('negative'); - expect(container.querySelectorAll('i.warning.sign.icon').length).toBe(1); + expect(rowOf('Old').className).toContain('wrolpi-row-failed'); + expect(rowOf('Fresh').className).not.toContain('wrolpi-row-failed'); + expect(screen.getAllByLabelText('Expired').length).toBe(1); }); test('shows an empty state when there are no items', () => { - render(); + renderWithProviders(); expect(screen.getByText('No items yet.')).toBeTruthy(); }); }); diff --git a/app/src/components/inventory/InventorySummary.js b/app/src/components/inventory/InventorySummary.js index e3cdbd25..2ce0a8da 100644 --- a/app/src/components/inventory/InventorySummary.js +++ b/app/src/components/inventory/InventorySummary.js @@ -1,11 +1,9 @@ import React, {useMemo, useState} from "react"; -import {Select, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow} from "semantic-ui-react"; -import {Header, Table} from "../Theme"; +import {Group, Header, Select, Table, Text} from "../ui"; import { defaultGroupKey, defaultSumKey, findCaloriesKey, findCountKey, groupFieldsOf, sortSummaryRows, summableFieldsOf, summarizeInventory, } from "./summarize"; -import {ThemeContext} from "../../contexts/contexts"; // Aggregate the inventory client-side: group items by a chosen text/select field and sum a chosen quantity, number, // or calories field. The grouping math lives in summarize.js (shared with the PDF export). The ration estimate @@ -15,7 +13,6 @@ export function InventorySummary({fields, items}) { const summableFields = summableFieldsOf(fields); const caloriesKey = findCaloriesKey(fields); const countKey = findCountKey(fields); - const {t} = React.useContext(ThemeContext); // Default to grouping by Category (easier to see grains/dairy/etc.), falling back to the first group field. const [groupKey, setGroupKey] = useState(defaultGroupKey(fields)); @@ -35,50 +32,53 @@ export function InventorySummary({fields, items}) { const sortDir = (key) => sort.key === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : undefined; if (!items || items.length === 0) { - return

No items to summarize yet.

; + return No items to summarize yet.; } - const fieldOptions = (fs) => fs.map(f => ({key: f.key, value: f.key, text: f.label})); + const fieldOptions = (fs) => fs.map(f => ({value: f.key, label: f.label})); return <>
Summary
-
- + + Group by:{' '} - setGroupKey(value)}/> {summableFields.length > 0 && Sum:{' '} - setSumKey(value || undefined)}/> } -
- - - - toggleSort('name')} - style={{cursor: 'pointer'}}> + +
+ + + toggleSort('name')}> {groupFields.find(f => f.key === groupKey)?.label || 'Group'} - - toggleSort('count')} - style={{cursor: 'pointer'}}>Items - {sumKey && toggleSort('total')} - style={{cursor: 'pointer'}}>Total} - {caloriesKey && toggleSort('calories')} - style={{cursor: 'pointer'}}>Calories} - - - - {rows.map(row => - {row.name} - {row.count} - {sumKey && {row.total}} + + toggleSort('count')}> + Items + + {sumKey && toggleSort('total')}> + Total + } + {caloriesKey && toggleSort('calories')}> + Calories + } + + + + {rows.map(row => + {row.name} + {row.count} + {sumKey && {row.total}} {caloriesKey && - {row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}} - )} - + {row.calories > 0 ? `${row.calories.toLocaleString()} kcal` : '—'}} + )} +
; } diff --git a/app/src/components/inventory/InventorySummary.test.js b/app/src/components/inventory/InventorySummary.test.js index 162aeeaf..08921c5c 100644 --- a/app/src/components/inventory/InventorySummary.test.js +++ b/app/src/components/inventory/InventorySummary.test.js @@ -1,5 +1,6 @@ import React from "react"; -import {fireEvent, render, screen} from "@testing-library/react"; +import {fireEvent, screen} from "@testing-library/react"; +import {renderWithProviders} from "../../test-utils"; import {InventorySummary} from "./InventorySummary"; // Group by the first text field (category); two groups with different item counts and size totals. @@ -19,12 +20,12 @@ const groupOrder = () => describe('InventorySummary sortable columns', () => { test('defaults to group name ascending', () => { - render(); + renderWithProviders(); expect(groupOrder()).toEqual(['beans', 'grains']); }); test('sorting by Items uses the numeric count', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByText('Items')); // first click -> descending expect(groupOrder()).toEqual(['grains', 'beans']); // grains has 2 items, beans 1 fireEvent.click(screen.getByText('Items')); // toggle -> ascending @@ -32,7 +33,7 @@ describe('InventorySummary sortable columns', () => { }); test('sorting by Total uses the summed magnitude, not the formatted string', () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByText('Total')); // descending expect(groupOrder()).toEqual(['beans', 'grains']); // beans 100 lb > grains 30 lb }); diff --git a/app/src/components/ui/Button.tsx b/app/src/components/ui/Button.tsx index b62b9629..fa46588b 100644 --- a/app/src/components/ui/Button.tsx +++ b/app/src/components/ui/Button.tsx @@ -39,6 +39,30 @@ const renderIcon = (icon?: string | React.ComponentType) => { return typeof icon === 'string' ? : ; } +/* + * Semantic's size names, mapped onto Mantine's scale. + * + * Unmigrated call sites still pass `size='tiny'`, and Mantine silently ignores a + * size it does not know — the button renders at the default size and nobody sees + * a warning. Translating here keeps those call sites looking right until they + * migrate, and costs nothing once they have. + */ +const semanticSizes: Record = { + mini: 'xs', + tiny: 'xs', + small: 'sm', + medium: 'md', + large: 'lg', + big: 'lg', + huge: 'xl', + massive: 'xl', +}; + +// Generic so it passes each component's own size type straight through; only a +// string that Semantic knew about is rewritten. +export const resolveSize = (size: T): T => + typeof size === 'string' ? ((semanticSizes[size] ?? size) as T) : size; + export const Button = forwardRef(( {role, icon, iconAfter, className, ...props}, ref ) => { @@ -52,6 +76,7 @@ export const Button = forwardRef(( leftSection={renderIcon(icon)} rightSection={renderIcon(iconAfter)} {...props} + size={resolveSize(props.size)} /> }); Button.displayName = 'Button'; @@ -78,6 +103,7 @@ export const IconButton = forwardRef(( variant={props.variant ?? fromRole?.variant ?? 'default'} className={[fromRole?.className, className].filter(Boolean).join(' ') || undefined} {...props} + size={resolveSize(props.size)} > {renderIcon(icon)} diff --git a/app/src/components/ui/Icon.tsx b/app/src/components/ui/Icon.tsx index c505a43a..44fce012 100644 --- a/app/src/components/ui/Icon.tsx +++ b/app/src/components/ui/Icon.tsx @@ -25,6 +25,7 @@ const sizes: Record = {small: 16, medium: 20, large: 24}; */ const semanticNames: Record = { 'add': 'IconPlus', + 'apple': 'IconBrandApple', 'archive': 'IconArchive', 'arrow alternate circle up': 'IconCircleArrowUp', 'arrow down': 'IconArrowDown', @@ -35,6 +36,7 @@ const semanticNames: Record = { 'bug': 'IconBug', 'calculator': 'IconCalculator', 'calendar': 'IconCalendar', + 'certificate': 'IconCertificate', 'charset': 'IconLanguage', 'check': 'IconCheck', 'check circle': 'IconCircleCheck', @@ -46,11 +48,13 @@ const semanticNames: Record = { 'circle notch': 'IconLoader2', 'circle notched': 'IconLoader2', 'close': 'IconX', + 'closed captioning': 'IconBadgeCc', 'columns': 'IconColumns', 'copy': 'IconCopy', 'cube': 'IconCube', 'directory': 'IconFolder', 'disk': 'IconDatabase', + 'dot circle': 'IconCircleDot', 'download': 'IconDownload', 'dropdown': 'IconChevronDown', 'edit': 'IconEdit', @@ -63,10 +67,16 @@ const semanticNames: Record = { 'file': 'IconFile', 'file alternate': 'IconFileDescription', 'file alternate outline': 'IconFileDescription', + 'file archive': 'IconFileZip', + 'file audio': 'IconFileMusic', 'file code': 'IconFileCode', + 'file excel': 'IconFileSpreadsheet', + 'file pdf': 'IconFileTypePdf', 'file pdf outline': 'IconFileTypePdf', + 'file powerpoint': 'IconPresentation', 'file text': 'IconFileText', 'file video': 'IconFileSmile', + 'file word': 'IconFileText', 'film': 'IconMovie', 'filter': 'IconFilter', 'firefox': 'IconBrandFirefox', @@ -74,6 +84,7 @@ const semanticNames: Record = { 'folder open': 'IconFolderOpen', 'folder open outline': 'IconFolderOpen', 'folder outline': 'IconFolder', + 'font': 'IconTypography', 'globe': 'IconWorld', 'hand point right': 'IconHandFinger', 'hdd': 'IconDeviceSdCard', @@ -84,14 +95,16 @@ const semanticNames: Record = { 'lightbulb': 'IconBulb', 'lightbulb outline': 'IconBulb', 'lightning': 'IconBolt', - 'moon': 'IconMoon', - 'moon outline': 'IconMoon', 'linkify': 'IconLink', + 'linux': 'IconBrandUbuntu', 'list': 'IconList', 'lock': 'IconLock', + 'mail': 'IconMail', 'map outline': 'IconMap', 'microchip': 'IconCpu', 'minus': 'IconMinus', + 'moon': 'IconMoon', + 'moon outline': 'IconMoon', 'paypal': 'IconBrandPaypal', 'play': 'IconPlayerPlay', 'plug': 'IconPlug', @@ -131,6 +144,7 @@ const semanticNames: Record = { 'warning circle': 'IconAlertCircle', 'warning sign': 'IconAlertTriangle', 'wifi': 'IconWifi', + 'windows': 'IconBrandWindows', 'wrench': 'IconTool', 'x': 'IconX', }; diff --git a/app/src/components/ui/Surfaces.tsx b/app/src/components/ui/Surfaces.tsx index d56b518c..a5fd2d28 100644 --- a/app/src/components/ui/Surfaces.tsx +++ b/app/src/components/ui/Surfaces.tsx @@ -68,10 +68,12 @@ export interface StatisticProps { value: React.ReactNode; label: React.ReactNode; color?: string; + className?: string; + style?: React.CSSProperties; } -export function Statistic({value, label, color}: StatisticProps) { - return
+export function Statistic({value, label, color, className, style}: StatisticProps) { + return
{value}
@@ -89,6 +91,7 @@ export function StatisticGroup({children}: {children: React.ReactNode}) { }}> {items.map((child, index) =>
{ expect(unmapped.map(c => `${c.text}: ${c.icon}`)).toEqual([]); }); + it('maps every name the mimetype and file-suffix helpers can return', () => { + // These two build icon names from arbitrary files in the user's library, so an + // unmapped one is not hypothetical — it is a console error and a fallback glyph + // the moment somebody opens a folder containing that file type. + const common = fs.readFileSync(path.join(__dirname, '..', 'Common.js'), 'utf8'); + const section = common.slice( + common.indexOf('export function mimetypeIconName'), + common.indexOf('export function FileIcon')); + const returned = [...new Set([...section.matchAll(/return '([^']+)'/g)].map(m => m[1]))]; + + expect(returned.length).toBeGreaterThan(10); + expect(returned.filter(name => !resolveIconName(name))).toEqual([]); + }); + + it('points every mapped name at a Tabler component that exists', () => { + // A typo in the map resolves to undefined and renders the fallback glyph, which + // looks like a design choice rather than a bug. + const source = fs.readFileSync(path.join(__dirname, 'Icon.tsx'), 'utf8'); + const names = [...source.matchAll(/^ {4}'([^']+)': '([^']+)',$/gm)].map(m => m[1]); + + expect(names.length).toBeGreaterThan(100); + expect(names.filter(name => !resolveIconName(name))).toEqual([]); + }); + it('reports an unknown name instead of failing silently', () => { const error = jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -116,6 +142,16 @@ describe('Button', () => { expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); }); + it('translates Semantic size names, which Mantine would otherwise ignore', () => { + // Unmigrated call sites still pass size='tiny'. Mantine drops a size it does not + // recognise without warning, so the button quietly renders at the default size. + renderUI(<>); + + expect(screen.getByRole('button', {name: 'Old'})).toHaveAttribute('data-size', 'xs'); + // A Mantine size still passes through untouched. + expect(screen.getByRole('button', {name: 'New'})).toHaveAttribute('data-size', 'sm'); + }); + it('gives icon-only buttons an accessible name', () => { renderUI(); diff --git a/app/src/hooks/customHooks.test.js b/app/src/hooks/customHooks.test.js index c762081b..5d925cc3 100644 --- a/app/src/hooks/customHooks.test.js +++ b/app/src/hooks/customHooks.test.js @@ -1,6 +1,6 @@ import React from 'react'; import {act, renderHook} from '@testing-library/react'; -import {render, screen} from '@testing-library/react'; +import {render, screen} from '../test-utils'; import {usePages, useDriveTemperature, useDriveHealth} from './customHooks'; import {QueryContext, StatusContext} from '../contexts/contexts'; import {Paginator} from '../components/Common'; diff --git a/app/src/hooks/useForm.test.js b/app/src/hooks/useForm.test.js index 8433ed1a..601f4bee 100644 --- a/app/src/hooks/useForm.test.js +++ b/app/src/hooks/useForm.test.js @@ -1044,7 +1044,7 @@ describe('Form Components', () => { /> ); - expect(screen.getByRole('checkbox')).toBeInTheDocument(); + expect(screen.getByRole('switch')).toBeInTheDocument(); }); }); diff --git a/app/src/hooks/useKeyboardShortcuts.test.js b/app/src/hooks/useKeyboardShortcuts.test.js index 51c1c263..d358e0ec 100644 --- a/app/src/hooks/useKeyboardShortcuts.test.js +++ b/app/src/hooks/useKeyboardShortcuts.test.js @@ -1,6 +1,5 @@ import React from 'react'; -import {render, screen, fireEvent, act} from '@testing-library/react'; -import {MemoryRouter} from 'react-router'; +import {render, screen, fireEvent, act} from '../test-utils'; import {KeyboardShortcutsContext} from '../contexts/KeyboardShortcutsContext'; import {KeyboardShortcutsProvider, SHORTCUTS} from '../components/KeyboardShortcutsProvider'; import {ThemeContext} from '../contexts/contexts'; @@ -43,17 +42,15 @@ function TestWrapper({children}) { }; return ( - - - - - - {children} - - - - - + + + + + {children} + + + + ); } diff --git a/app/src/semantic-baseline.json b/app/src/semantic-baseline.json index 5c858fca..fb5ae591 100644 --- a/app/src/semantic-baseline.json +++ b/app/src/semantic-baseline.json @@ -4,14 +4,9 @@ "src/Events.js", "src/Tags.js", "src/api.js", - "src/components/AddToPlaylist.js", - "src/components/Apps.js", "src/components/Archive.js", - "src/components/ArchivePreview.js", "src/components/BulkTagModal.js", - "src/components/CbzViewer.js", "src/components/Channels.js", - "src/components/Common.js", "src/components/DatesSelector.js", "src/components/Docs.js", "src/components/Download.js", @@ -24,10 +19,8 @@ "src/components/MapSearchView.js", "src/components/MapViewer.js", "src/components/Nav.js", - "src/components/Placeholder.js", "src/components/Playlists.js", "src/components/Search.js", - "src/components/TaggedDeleteConfirmModal.js", "src/components/Theme.tsx", "src/components/Upload.js", "src/components/VideoPlayer.js", @@ -46,19 +39,13 @@ "src/components/calculators/RationCalculator.js", "src/components/calculators/WaterCalculator.js", "src/components/collections/BatchReorganizeModal.js", - "src/components/collections/CollectionEditForm.js", "src/components/collections/CollectionReorganizeModal.js", "src/components/collections/CollectionTable.js", - "src/components/collections/CollectionTagModal.js", "src/components/collections/ConflictResolutionModal.js", - "src/components/inventory/CatalogEditor.js", "src/components/inventory/FieldCell.js", "src/components/inventory/FieldSchemaEditor.js", - "src/components/inventory/InventoryExportPanel.js", "src/components/inventory/InventoryImportModal.js", - "src/components/inventory/InventoryItemsMobile.js", "src/components/inventory/InventoryRoute.js", - "src/components/inventory/InventorySummary.js", "src/components/inventory/InventoryTable.js", "src/hooks/customHooks.js", "src/hooks/useForm.js" diff --git a/app/src/semantic-baseline.test.js b/app/src/semantic-baseline.test.js index 5d9fd5cc..7d21c93a 100644 --- a/app/src/semantic-baseline.test.js +++ b/app/src/semantic-baseline.test.js @@ -48,6 +48,23 @@ describe('Semantic UI removal', () => { expect(stale).toEqual([]); }); + it('declares every migrated file that still leans on Semantic CSS classes', () => { + /* + * A file can stop importing semantic-ui-react and still depend on its stylesheet + * by writing `className="ui stackable grid"`. The import ratchet above cannot see + * that, so when Phase 4 deletes semantic.min.css the layout would silently + * collapse. Anything on this list is a deliberate bridge that must be revisited + * then; anything NOT on it is an accident. + */ + const allowed = ['src/components/collections/CollectionEditForm.js']; + + const bridges = sourceFiles() + .filter(file => !baseline.includes(file)) + .filter(file => /className=["']ui /.test(fs.readFileSync(path.join(SRC, '..', file), 'utf8'))); + + expect(bridges.sort()).toEqual(allowed.sort()); + }); + it('reports how much of the migration is left', () => { // Not an assertion so much as a progress readout in the test output. const remaining = importers().length; diff --git a/app/src/setupTests.js b/app/src/setupTests.js index 24a37e2a..5f2ae4c6 100644 --- a/app/src/setupTests.js +++ b/app/src/setupTests.js @@ -52,6 +52,21 @@ global.IntersectionObserver = class IntersectionObserver { } }; +// Mock ResizeObserver (used by Mantine's ScrollArea, e.g. inside Select/Combobox dropdowns). +global.ResizeObserver = class ResizeObserver { + constructor() { + } + + disconnect() { + } + + observe() { + } + + unobserve() { + } +}; + // Suppress console errors during tests (optional - uncomment if needed) // const originalError = console.error; // beforeAll(() => {