mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
ui: the last Semantic UI file, and the stylesheet with it
ControllerPage was the final call site; Theme.tsx then had nothing left to wrap, so it drops from 861 lines to 338 -- the provider that resolves the theme, stamps `data-theme` and `data-media-filter`, persists both and hands Mantine our tokens. Everything below that was Semantic wrappers. The dark-mode compatibility props went with them. `i`, `s`, `t` and `inverted` existed so unmigrated components could borrow Semantic's dark treatment, and they carried hardcoded greys -- #1B1C1D, #dddddd, #eeeeee -- which is exactly what the token themes replaced. Three call sites in Common.js were still injecting the #eeeeee one into links. Then `semantic-ui-offline/semantic.min.css` came out of App.js. That took more with it than expected, and looking at the running app is the only reason it was caught: Every link lost its colour and gained the browser's underline and visited purple. The dashboard's load averages and section headings were underlined and purple. Links now take `--blue`, which remaps per theme, and underline only on hover -- many of them wrap cards, posters and icons. `box-sizing: border-box` disappeared, which silently changes the size of every padded element in the app. Semantic's reset had been providing it. The navigation bar overflowed a 1000px viewport with `nowrap` and cut "Admin" off the right edge -- the one link a user needs to reach the settings that fix a broken WROLPi. Semantic's menu wrapped; ours does now too. The gzipped CSS bundle is 47.5 kB, and one call site was still leaning on Semantic's grid classes -- now a flex column, since all three of its callers pass library components. The ratchet stays rather than being deleted. At zero it asserts four things: no file imports Semantic UI, none calls its toast library, none leans on its stylesheet, and no file reaches back through Theme.tsx. It can go when the packages are uninstalled. Theme.test.js is deleted, but not before porting the two behaviours it protected: that Button forwards a ref to the real DOM element (a class-component ref once crashed the Control page, because portals call `node.contains`), and that the progress percentage stays readable on every bar colour.
This commit is contained in:
parent
739ceccb9f
commit
96d84dfab8
14 changed files with 238 additions and 846 deletions
|
|
@ -4,7 +4,12 @@
|
|||
.wrolpi-navbar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: nowrap;
|
||||
/*
|
||||
* Wraps rather than clipping. Semantic's menu wrapped, and with this many items the
|
||||
* bar overflows a 1000px viewport -- `nowrap` cut "Admin" off the right edge, which
|
||||
* is the one link a user needs to reach the settings that fix a broken WROLPi.
|
||||
*/
|
||||
flex-wrap: wrap;
|
||||
min-height: 3.4em;
|
||||
padding: 0 0.25em;
|
||||
}
|
||||
|
|
@ -20,7 +25,8 @@
|
|||
.wrolpi-navbar-link-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.1em;
|
||||
/* Tightened from 1.1em: the bar has eleven links plus nine indicators. */
|
||||
padding: 0 0.8em;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
|
|
@ -32,7 +38,8 @@
|
|||
|
||||
.wrolpi-navbar-link:hover,
|
||||
.wrolpi-navbar-link-button:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
/* A translucent black works on every navbar colour, light or dark. */
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.channel-edit {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import {createBrowserRouter, createRoutesFromElements, Link, Outlet, Route, Rout
|
|||
import {VideosTabLayout, VideosPage, VideosSettingsPage, VideosStatistics, VideoWrapper} from "./components/Videos";
|
||||
import AdminRoute from "./components/admin/AdminRoute";
|
||||
import {Box, Header} from "./components/ui";
|
||||
import 'semantic-ui-offline/semantic.min.css';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
// Theme tokens load last so they win over both component libraries.
|
||||
|
|
|
|||
|
|
@ -175,9 +175,6 @@ export function isoDatetimeToString(dt, time = false) {
|
|||
}
|
||||
|
||||
export function CardLink({to, newTab = false, ...props}) {
|
||||
const {t} = useContext(ThemeContext);
|
||||
props = {...props, ...t};
|
||||
|
||||
props = newTab === true ? {...props, target: '_blank', rel: 'noopener noreferrer'} : props;
|
||||
return <Link to={to} className="no-link-underscore card-link" {...props}>
|
||||
{props.children}
|
||||
|
|
@ -185,13 +182,11 @@ export function CardLink({to, newTab = false, ...props}) {
|
|||
}
|
||||
|
||||
export function ExternalCardLink({to, children, ...props}) {
|
||||
const {t} = useContext(ThemeContext);
|
||||
return <a
|
||||
href={to}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='no-link-underscore card-link'
|
||||
{...t}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -199,10 +194,9 @@ export function ExternalCardLink({to, children, ...props}) {
|
|||
}
|
||||
|
||||
export function PreviewLink({file, children, className, ...props}) {
|
||||
const {t} = useContext(ThemeContext);
|
||||
const {setPreviewFile} = React.useContext(FilePreviewContext);
|
||||
className = className ? `clickable ${className}` : `clickable `;
|
||||
return <span className={className} onClick={() => setPreviewFile(file)} {...props} {...t}>
|
||||
return <span className={className} onClick={() => setPreviewFile(file)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,109 +0,0 @@
|
|||
import React from 'react';
|
||||
import {Button, Modal, Progress} from './Theme';
|
||||
import {renderInDarkMode, renderInLightMode} from '../test-utils';
|
||||
|
||||
// All semantic-ui colors.
|
||||
const allColors = ['red', 'orange', 'yellow', 'olive', 'green', 'teal', 'blue', 'violet', 'purple', 'pink', 'brown', 'grey'];
|
||||
|
||||
// Light bar colors that always need dark percent text (light bars in any theme).
|
||||
const lightBarColors = ['yellow', 'olive', 'teal'];
|
||||
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
|
||||
const darkModeLightBarColors = ['grey', 'blue'];
|
||||
|
||||
describe('Button ref forwarding', () => {
|
||||
// Regression: SButton is a class component, so forwarding a ref straight to it resolves to the class instance
|
||||
// (no `.contains`). Semantic UI's Modal/Popup Portal calls `node.contains(...)` on the trigger's ref during
|
||||
// handleDocumentClick, throwing "node.contains is not a function". The forwarded ref must be the DOM <button>.
|
||||
it('resolves a forwarded ref to the underlying DOM element', () => {
|
||||
const ref = React.createRef();
|
||||
renderInLightMode(<Button ref={ref} content='Click'/>);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLElement);
|
||||
expect(typeof ref.current.contains).toBe('function');
|
||||
expect(ref.current.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('does not throw when a Button is used as a controlled Modal trigger and the document is clicked', () => {
|
||||
// Reproduces the Control page crash: an open Modal whose trigger is a themed Button. A document click
|
||||
// invokes the Portal's handleDocumentClick -> doesNodeContainClick(triggerRef.current, e).
|
||||
renderInLightMode(
|
||||
<Modal open trigger={<Button content='Open'/>}>
|
||||
<Modal.Content>content</Modal.Content>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
document.body.dispatchEvent(new MouseEvent('click', {bubbles: true}));
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Progress component theme support', () => {
|
||||
describe('dark mode', () => {
|
||||
it('adds inverted-progress-text class for label readability', () => {
|
||||
const {container} = renderInDarkMode(
|
||||
<Progress percent={39} progress indicating color='violet'>
|
||||
2.1 GB / 5.3 GB
|
||||
</Progress>
|
||||
);
|
||||
|
||||
const progressEl = container.querySelector('.ui.progress');
|
||||
expect(progressEl).toHaveClass('inverted-progress-text');
|
||||
|
||||
const progressLabel = container.querySelector('.bar > .progress');
|
||||
expect(progressLabel).toHaveTextContent('39%');
|
||||
|
||||
const label = container.querySelector('.ui.progress > .label');
|
||||
expect(label).toHaveTextContent('2.1 GB / 5.3 GB');
|
||||
});
|
||||
|
||||
it('does NOT add inverted-progress-text in light mode', () => {
|
||||
const {container} = renderInLightMode(
|
||||
<Progress percent={39} progress indicating color='violet'>
|
||||
2.1 GB / 5.3 GB
|
||||
</Progress>
|
||||
);
|
||||
|
||||
const progressEl = container.querySelector('.ui.progress');
|
||||
expect(progressEl).not.toHaveClass('inverted-progress-text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('percent text contrast per color', () => {
|
||||
allColors.forEach(color => {
|
||||
it(`${color} bar has readable percent text in light mode`, () => {
|
||||
const {container} = renderInLightMode(
|
||||
<Progress percent={50} progress color={color}/>
|
||||
);
|
||||
|
||||
const progressLabel = container.querySelector('.bar > .progress');
|
||||
expect(progressLabel).toBeInTheDocument();
|
||||
expect(progressLabel).toHaveTextContent('50%');
|
||||
|
||||
const progressEl = container.querySelector('.ui.progress');
|
||||
if (lightBarColors.includes(color)) {
|
||||
expect(progressEl).toHaveClass('light-bar-progress-text');
|
||||
} else {
|
||||
expect(progressEl).not.toHaveClass('light-bar-progress-text');
|
||||
}
|
||||
});
|
||||
|
||||
it(`${color} bar has readable percent text in dark mode`, () => {
|
||||
const {container} = renderInDarkMode(
|
||||
<Progress percent={50} progress color={color}/>
|
||||
);
|
||||
|
||||
const progressLabel = container.querySelector('.bar > .progress');
|
||||
expect(progressLabel).toBeInTheDocument();
|
||||
expect(progressLabel).toHaveTextContent('50%');
|
||||
|
||||
const progressEl = container.querySelector('.ui.progress');
|
||||
if (lightBarColors.includes(color) || darkModeLightBarColors.includes(color)) {
|
||||
expect(progressEl).toHaveClass('light-bar-progress-text');
|
||||
} else {
|
||||
expect(progressEl).not.toHaveClass('light-bar-progress-text');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,99 +1,5 @@
|
|||
import React, {useContext, useEffect, useState, forwardRef, HTMLAttributes, CSSProperties} from 'react';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {ThemeContext} from "../contexts/contexts";
|
||||
import {
|
||||
Accordion as SAccordion,
|
||||
AccordionContent as SAccordionContent,
|
||||
AccordionTitle as SAccordionTitle,
|
||||
Breadcrumb as SBreadcrumb,
|
||||
Button as SButton,
|
||||
ButtonGroup as SButtonGroup,
|
||||
Card as SCard,
|
||||
Divider as SDivider,
|
||||
Form as SForm,
|
||||
FormField as SFormField,
|
||||
FormGroup as SFormGroup,
|
||||
FormInput as SFormInput,
|
||||
Header as SHeader,
|
||||
Icon as SIcon,
|
||||
List as SList,
|
||||
ListContent as SListContent,
|
||||
ListDescription as SListDescription,
|
||||
ListHeader as SListHeader,
|
||||
ListIcon as SListIcon,
|
||||
ListItem as SListItem,
|
||||
Loader as SLoader,
|
||||
Menu as SMenu,
|
||||
MenuItem as SMenuItem,
|
||||
MenuHeader as SMenuHeader,
|
||||
MenuMenu as SMenuMenu,
|
||||
Modal as SModal,
|
||||
ModalActions as SModalActions,
|
||||
ModalContent as SModalContent,
|
||||
ModalDescription as SModalDescription,
|
||||
ModalHeader as SModalHeader,
|
||||
Placeholder as SPlaceholder,
|
||||
Popup as SPopup,
|
||||
Progress as SProgress,
|
||||
Ref as SRef,
|
||||
Segment as SSegment,
|
||||
Statistic as SStatistic,
|
||||
StatisticGroup as SStatisticGroup,
|
||||
Tab as STab,
|
||||
Table as STable,
|
||||
TableBody as STableBody,
|
||||
TableCell as STableCell,
|
||||
TableFooter as STableFooter,
|
||||
TableHeader as STableHeader,
|
||||
TableHeaderCell as STableHeaderCell,
|
||||
TableRow as STableRow,
|
||||
TabPane as STabPane,
|
||||
TextArea as STextArea,
|
||||
AccordionContentProps,
|
||||
AccordionProps,
|
||||
AccordionTitleProps,
|
||||
BreadcrumbProps,
|
||||
ButtonProps,
|
||||
ButtonGroupProps,
|
||||
CardProps as SCardProps,
|
||||
DividerProps,
|
||||
FormProps,
|
||||
FormFieldProps,
|
||||
FormGroupProps,
|
||||
FormInputProps,
|
||||
HeaderProps,
|
||||
IconProps,
|
||||
ListProps,
|
||||
ListItemProps,
|
||||
ListIconProps,
|
||||
ListContentProps,
|
||||
ListHeaderProps,
|
||||
ListDescriptionProps,
|
||||
LoaderProps,
|
||||
MenuProps,
|
||||
MenuItemProps,
|
||||
MenuMenuProps,
|
||||
ModalProps,
|
||||
ModalActionsProps,
|
||||
ModalContentProps,
|
||||
ModalDescriptionProps,
|
||||
ModalHeaderProps,
|
||||
PlaceholderProps,
|
||||
PopupProps,
|
||||
ProgressProps,
|
||||
SegmentProps,
|
||||
StatisticProps,
|
||||
StatisticGroupProps,
|
||||
TabProps,
|
||||
TableProps,
|
||||
TableBodyProps,
|
||||
TableCellProps,
|
||||
TableFooterProps,
|
||||
TableHeaderProps,
|
||||
TableHeaderCellProps,
|
||||
TableRowProps,
|
||||
TabPaneProps,
|
||||
TextAreaProps,
|
||||
} from "semantic-ui-react";
|
||||
import {MantineProvider} from "@mantine/core";
|
||||
import {Notifications} from "@mantine/notifications";
|
||||
import {cssVariablesResolver, mantineTheme} from "../themes/mantine";
|
||||
|
|
@ -101,7 +7,6 @@ import {
|
|||
amberTheme,
|
||||
darkTheme,
|
||||
defaultTheme,
|
||||
explicitOnlyThemes,
|
||||
isDarkTheme,
|
||||
isThemeName,
|
||||
lightTheme,
|
||||
|
|
@ -114,13 +19,11 @@ import {
|
|||
themeNames,
|
||||
themeSessionKey,
|
||||
} from "../themes/names";
|
||||
import {ColorToSemanticHexColor} from "./Vars";
|
||||
import _ from "lodash";
|
||||
import {ThemeContextValue, ThemeName, SavedThemeName} from "../types/theme";
|
||||
|
||||
// Theme names and picker choices live in themes/names so the component library can use
|
||||
// them without importing this module (which still pulls in Semantic UI). Re-exported
|
||||
// here because most call sites already import them from Theme.
|
||||
// Theme names and picker choices live in themes/names, and are re-exported here because
|
||||
// most call sites already import them from Theme.
|
||||
export {
|
||||
amberTheme,
|
||||
darkTheme,
|
||||
|
|
@ -289,26 +192,7 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
|
||||
const dark = isDarkTheme(theme);
|
||||
|
||||
// Compatibility layer for components which have not been migrated to tokens yet. Semantic
|
||||
// has no concept of the night/amber themes, so they reuse its dark treatment while pulling
|
||||
// their colors from the tokens. These values disappear with the last Semantic component.
|
||||
const i = dark ? {inverted: true} : {inverted: undefined};
|
||||
const inverted = dark ? 'inverted' : '';
|
||||
let s: {style?: CSSProperties} = {};
|
||||
let t: {style?: CSSProperties} = {};
|
||||
if (theme === darkTheme) {
|
||||
s = {style: {backgroundColor: '#1B1C1D', color: '#dddddd'}};
|
||||
t = {style: {color: '#eeeeee'}};
|
||||
} else if (explicitOnlyThemes.includes(theme)) {
|
||||
s = {style: {backgroundColor: 'var(--panel)', color: 'var(--text)'}};
|
||||
t = {style: {color: 'var(--text)'}};
|
||||
}
|
||||
|
||||
const themeValue: ThemeContextValue = {
|
||||
i, // Used for Semantic elements which support "inverted".
|
||||
s, // Used to invert the style some elements.
|
||||
t, // Used to invert text.
|
||||
inverted, // Used to add "invert" to className.
|
||||
theme,
|
||||
savedTheme,
|
||||
isDark: dark,
|
||||
|
|
@ -336,526 +220,16 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
</MantineProvider>
|
||||
</ThemeContext.Provider>
|
||||
}
|
||||
|
||||
// Simple wrappers for Semantic UI elements to use the current theme.
|
||||
|
||||
const invertedNull = <T extends {inverted?: boolean | null}>(props: T): T => {
|
||||
if (props['inverted'] === true) {
|
||||
return {...props, inverted: null};
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
const defaultGrey = <T extends object>(props: T, inverted: boolean): T => {
|
||||
// Some elements look softer when grey, use grey only if another color is not provided.
|
||||
if (inverted) {
|
||||
return {color: 'grey', ...props} as T;
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Accordion Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type AccordionTitleComponent = React.FC<AccordionTitleProps>;
|
||||
type AccordionContentComponent = React.FC<AccordionContentProps>;
|
||||
|
||||
interface AccordionComponent extends React.FC<AccordionProps> {
|
||||
Title: AccordionTitleComponent;
|
||||
Content: AccordionContentComponent;
|
||||
}
|
||||
|
||||
const AccordionTitle: AccordionTitleComponent = (props) => {
|
||||
return <SAccordionTitle {...props}/>
|
||||
};
|
||||
|
||||
const AccordionContent: AccordionContentComponent = (props) => {
|
||||
return <SAccordionContent {...props}/>
|
||||
};
|
||||
|
||||
const AccordionBase: React.FC<AccordionProps> = (props) => {
|
||||
const {i, inverted} = useContext(ThemeContext);
|
||||
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
|
||||
return <SAccordion {...mergedProps}/>
|
||||
};
|
||||
|
||||
export const Accordion: AccordionComponent = Object.assign(AccordionBase, {
|
||||
Title: AccordionTitle,
|
||||
Content: AccordionContent,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Button Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type ButtonGroupComponent = React.FC<ButtonGroupProps>;
|
||||
|
||||
interface ButtonComponent extends React.ForwardRefExoticComponent<ButtonProps & React.RefAttributes<any>> {
|
||||
Group: ButtonGroupComponent;
|
||||
}
|
||||
|
||||
const ButtonGroup: ButtonGroupComponent = (props) => {
|
||||
return <SButtonGroup {...props}/>
|
||||
};
|
||||
|
||||
const ButtonBase = forwardRef<any, ButtonProps>((props, ref) => {
|
||||
const {i, inverted} = useContext(ThemeContext);
|
||||
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
|
||||
// SButton is a class component, so a forwarded `ref` would resolve to the class instance rather than the DOM
|
||||
// node. Semantic UI's Modal/Popup use the trigger's ref with `node.contains(...)`, which throws on a non-DOM
|
||||
// value. Wrap in Ref (findDOMNode) so the forwarded ref always resolves to the underlying <button> element.
|
||||
if (ref) {
|
||||
return <SRef innerRef={ref}><SButton {...mergedProps}/></SRef>
|
||||
}
|
||||
return <SButton {...mergedProps}/>
|
||||
});
|
||||
ButtonBase.displayName = 'ButtonBase';
|
||||
|
||||
export const Button: ButtonComponent = Object.assign(ButtonBase, {
|
||||
Group: ButtonGroup,
|
||||
});
|
||||
|
||||
export function Divider(props: DividerProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SDivider {...i} {...props}/>
|
||||
}
|
||||
|
||||
export function Header(props: HeaderProps) {
|
||||
const {t} = useContext(ThemeContext);
|
||||
// Merge the caller's style on top of the theme's text style; a bare `{...props}`
|
||||
// would let an incoming `style` prop replace (and drop) the theme's text color.
|
||||
return <SHeader {...t} {...props} style={{...t.style, ...props.style}}/>
|
||||
}
|
||||
|
||||
export function Icon(props: IconProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SIcon {...i} {...props}/>
|
||||
}
|
||||
|
||||
export function Loader(props: LoaderProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SLoader {...i} {...props}/>
|
||||
}
|
||||
|
||||
// Menu is a compound component (Menu.Item / Menu.Header / Menu.Menu) so callers get the same API as Semantic's
|
||||
// Menu while inheriting the theme. The parent menu is `inverted` in dark mode so item text stays readable.
|
||||
const MenuBase: React.FC<MenuProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SMenu {...i} {...props}/>;
|
||||
};
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = (props) => <SMenuItem {...props}/>;
|
||||
const MenuHeader: React.FC<MenuItemProps> = (props) => <SMenuHeader {...props}/>;
|
||||
const MenuMenu: React.FC<MenuMenuProps> = (props) => <SMenuMenu {...props}/>;
|
||||
|
||||
interface MenuComponent extends React.FC<MenuProps> {
|
||||
Item: React.FC<MenuItemProps>;
|
||||
Header: React.FC<MenuItemProps>;
|
||||
Menu: React.FC<MenuMenuProps>;
|
||||
}
|
||||
|
||||
export const Menu: MenuComponent = Object.assign(MenuBase, {
|
||||
Item: MenuItem,
|
||||
Header: MenuHeader,
|
||||
Menu: MenuMenu,
|
||||
});
|
||||
|
||||
export function Placeholder(props: PlaceholderProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SPlaceholder {...i} {...props}/>
|
||||
}
|
||||
|
||||
export function Popup(props: PopupProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SPopup {...i} style={{border: '1px solid grey'}} {...props}/>
|
||||
}
|
||||
|
||||
// Bar colors that always need dark percent text (light bars in any theme).
|
||||
const PROGRESS_BAR_COLORS = ['yellow', 'olive', 'teal'];
|
||||
// Bar colors that need dark percent text only in dark mode (inverted makes bar lighter).
|
||||
const DARK_MODE_PROGRESS_BAR_COLORS = ['grey', 'blue'];
|
||||
|
||||
export function Progress(props: ProgressProps) {
|
||||
const {i, inverted} = useContext(ThemeContext);
|
||||
const classes = [props.className || ''];
|
||||
if (inverted) classes.push('inverted-progress-text');
|
||||
const color = props.color as string;
|
||||
if (PROGRESS_BAR_COLORS.includes(color) || (inverted && DARK_MODE_PROGRESS_BAR_COLORS.includes(color))) {
|
||||
classes.push('light-bar-progress-text');
|
||||
}
|
||||
return <SProgress {...i} {...props} className={classes.join(' ').trim()}/>
|
||||
}
|
||||
|
||||
export function Segment(props: SegmentProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SSegment {...i} {...props}/>
|
||||
}
|
||||
|
||||
// Table is now a compound component - see COMPOUND COMPONENTS section below
|
||||
|
||||
export function TextArea(props: TextAreaProps) {
|
||||
const {i} = useContext(ThemeContext);
|
||||
const mergedProps = invertedNull({...i, ...props});
|
||||
return <STextArea {...mergedProps}/>
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// COMPOUND COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Modal Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type ModalActionsComponent = React.FC<ModalActionsProps>;
|
||||
type ModalContentComponent = React.FC<ModalContentProps>;
|
||||
type ModalDescriptionComponent = React.FC<ModalDescriptionProps>;
|
||||
type ModalHeaderComponent = React.FC<ModalHeaderProps>;
|
||||
|
||||
interface ModalComponent extends React.FC<ModalProps> {
|
||||
Actions: ModalActionsComponent;
|
||||
Content: ModalContentComponent;
|
||||
Description: ModalDescriptionComponent;
|
||||
Header: ModalHeaderComponent;
|
||||
}
|
||||
|
||||
const ModalActions: ModalActionsComponent = (props) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
return <SModalActions {...props} className={`${inverted}`}/>
|
||||
};
|
||||
|
||||
const ModalContent: ModalContentComponent = (props) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
return <SModalContent {...props} className={`${inverted}`}/>
|
||||
};
|
||||
|
||||
const ModalDescription: ModalDescriptionComponent = (props) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
return <SModalDescription {...props} className={`${inverted}`}/>
|
||||
};
|
||||
|
||||
const ModalHeader: ModalHeaderComponent = (props) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
return <SModalHeader {...props} className={`${inverted}`}>
|
||||
{props.children}
|
||||
</SModalHeader>
|
||||
};
|
||||
|
||||
const ModalBase: React.FC<ModalProps> = (props) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
return <SModal {...props} className={`${inverted}`}/>
|
||||
};
|
||||
|
||||
export const Modal: ModalComponent = Object.assign(ModalBase, {
|
||||
Actions: ModalActions,
|
||||
Content: ModalContent,
|
||||
Description: ModalDescription,
|
||||
Header: ModalHeader,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// List Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
// Semantic's List supports `inverted`; the inverted class on the parent handles the item text,
|
||||
// but List.Icon must also receive `inverted` so its color flips in dark mode (the parent's
|
||||
// inverted class alone does not restyle the icon).
|
||||
|
||||
interface ListComponent extends React.FC<ListProps> {
|
||||
Item: React.FC<ListItemProps>;
|
||||
Icon: React.FC<ListIconProps>;
|
||||
Content: React.FC<ListContentProps>;
|
||||
Header: React.FC<ListHeaderProps>;
|
||||
Description: React.FC<ListDescriptionProps>;
|
||||
}
|
||||
|
||||
const ListBase: React.FC<ListProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SList {...i} {...props}/>
|
||||
};
|
||||
|
||||
const ListIcon: React.FC<ListIconProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SListIcon {...i} {...props}/>
|
||||
};
|
||||
|
||||
export const List: ListComponent = Object.assign(ListBase, {
|
||||
Item: SListItem,
|
||||
Icon: ListIcon,
|
||||
Content: SListContent,
|
||||
Header: SListHeader,
|
||||
Description: SListDescription,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Confirm Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
export interface ConfirmProps {
|
||||
open: boolean;
|
||||
header?: string;
|
||||
content?: string | React.ReactNode;
|
||||
confirmButton?: string;
|
||||
cancelButton?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
onClose?: () => void;
|
||||
size?: 'mini' | 'tiny' | 'small' | 'large' | 'fullscreen';
|
||||
}
|
||||
|
||||
export const Confirm: React.FC<ConfirmProps> = ({
|
||||
open,
|
||||
header,
|
||||
content,
|
||||
confirmButton = 'OK',
|
||||
cancelButton = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
onClose,
|
||||
size = 'tiny',
|
||||
}) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} size={size} className="themed-confirm">
|
||||
{header && <Modal.Header>{header}</Modal.Header>}
|
||||
<Modal.Content>
|
||||
{typeof content === 'string' ? <p>{content}</p> : content}
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button onClick={onCancel} {...i}>{cancelButton}</Button>
|
||||
<Button onClick={onConfirm} color="green" {...i}>{confirmButton}</Button>
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Form Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type FormFieldComponent = React.FC<FormFieldProps>;
|
||||
type FormGroupComponent = React.FC<FormGroupProps>;
|
||||
type FormInputComponent = React.FC<FormInputProps>;
|
||||
|
||||
interface FormComponent extends React.FC<FormProps> {
|
||||
Field: FormFieldComponent;
|
||||
Group: FormGroupComponent;
|
||||
Input: FormInputComponent;
|
||||
}
|
||||
|
||||
const FormField: FormFieldComponent = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
const mergedProps = invertedNull({...i, ...props});
|
||||
return <SFormField {...mergedProps}/>
|
||||
};
|
||||
|
||||
const FormGroup: FormGroupComponent = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
const mergedProps = invertedNull({...i, ...props});
|
||||
return <SFormGroup {...mergedProps}/>
|
||||
};
|
||||
|
||||
const FormInput: FormInputComponent = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SFormInput {...i} {...props}/>
|
||||
};
|
||||
|
||||
const FormBase: React.FC<FormProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <SForm {...i} {...props}/>
|
||||
};
|
||||
|
||||
export const Form: FormComponent = Object.assign(FormBase, {
|
||||
Field: FormField,
|
||||
Group: FormGroup,
|
||||
Input: FormInput,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Card Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface CardIconProps extends HTMLAttributes<HTMLDivElement> {
|
||||
onClick?: () => void;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
type CardIconComponent = React.FC<CardIconProps>;
|
||||
|
||||
interface CardProps extends Omit<SCardProps, 'color'> {
|
||||
color?: string;
|
||||
}
|
||||
|
||||
interface CardComponent extends React.FC<CardProps> {
|
||||
Icon: CardIconComponent;
|
||||
}
|
||||
|
||||
const CardIcon: CardIconComponent = ({onClick, children, ...props}) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
const cardIcon = <center className={`card-icon ${inverted}`} {...props}>{children}</center>;
|
||||
if (onClick) {
|
||||
return <div onClick={onClick} className='clickable'>
|
||||
{cardIcon}
|
||||
</div>
|
||||
} else {
|
||||
return cardIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const CardBase: React.FC<CardProps> = ({color, ...props}) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
|
||||
const style: CSSProperties = props.style || {};
|
||||
const emphasisColor = ColorToSemanticHexColor(color);
|
||||
if (emphasisColor) {
|
||||
// Increase drop shadow to emphasize color.
|
||||
const borderColor = inverted ? '#888' : '#ddd';
|
||||
style.boxShadow = `0 0 0 2px ${borderColor}, 0 5px 0 0 ${emphasisColor}, 0 0px 3px 0 #d4d4d5`;
|
||||
}
|
||||
return <SCard {...props} style={style}/>
|
||||
};
|
||||
|
||||
export const Card: CardComponent = Object.assign(CardBase, {
|
||||
Icon: CardIcon,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Tab Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type TabPaneComponent = React.FC<TabPaneProps>;
|
||||
|
||||
interface TabComponent extends React.FC<TabProps> {
|
||||
Pane: TabPaneComponent;
|
||||
}
|
||||
|
||||
const TabPane: TabPaneComponent = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <STabPane {...i} {...props}/>
|
||||
};
|
||||
|
||||
const TabBase: React.FC<TabProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
const mergedProps = invertedNull({...i, ...props});
|
||||
return <STab {...mergedProps}/>
|
||||
};
|
||||
|
||||
export const Tab: TabComponent = Object.assign(TabBase, {
|
||||
Pane: TabPane,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Statistic Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type StatisticGroupComponent = React.FC<StatisticGroupProps>;
|
||||
|
||||
interface StatisticComponent extends React.FC<StatisticProps> {
|
||||
Group: StatisticGroupComponent;
|
||||
}
|
||||
|
||||
const StatisticGroup: StatisticGroupComponent = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
const style: CSSProperties = {...props.style, marginLeft: 0, marginRight: 0};
|
||||
return <SStatisticGroup {...i} {...props} style={style}/>
|
||||
};
|
||||
|
||||
const StatisticBase: React.FC<StatisticProps> = (props) => {
|
||||
const {i, inverted} = useContext(ThemeContext);
|
||||
const mergedProps = defaultGrey({...i, ...props}, !!inverted);
|
||||
return <SStatistic {...mergedProps}/>
|
||||
};
|
||||
|
||||
export const Statistic: StatisticComponent = Object.assign(StatisticBase, {
|
||||
Group: StatisticGroup,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Breadcrumb Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
interface BreadcrumbDividerProps {
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
type BreadcrumbDividerComponent = React.FC<BreadcrumbDividerProps>;
|
||||
|
||||
interface BreadcrumbComponent extends React.FC<BreadcrumbProps> {
|
||||
Divider: BreadcrumbDividerComponent;
|
||||
}
|
||||
|
||||
const BreadcrumbDivider: BreadcrumbDividerComponent = ({icon, ...props}) => {
|
||||
const {inverted} = useContext(ThemeContext);
|
||||
|
||||
// TODO this only handles icons for now.
|
||||
const className = `divider icon ${inverted} ${icon || ''}`;
|
||||
return <i aria-hidden="true" className={className}></i>
|
||||
};
|
||||
|
||||
const BreadcrumbBase: React.FC<BreadcrumbProps> = (props) => {
|
||||
const {t} = useContext(ThemeContext);
|
||||
return <SBreadcrumb {...props} {...t}/>
|
||||
};
|
||||
|
||||
export const Breadcrumb: BreadcrumbComponent = Object.assign(BreadcrumbBase, {
|
||||
Divider: BreadcrumbDivider,
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Table Compound Component
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
type TableBodyComponent = React.FC<TableBodyProps>;
|
||||
type TableCellComponent = React.FC<TableCellProps>;
|
||||
type TableFooterComponent = React.FC<TableFooterProps>;
|
||||
type TableHeaderComponent = React.FC<TableHeaderProps>;
|
||||
type TableHeaderCellComponent = React.FC<TableHeaderCellProps>;
|
||||
type TableRowComponent = React.FC<TableRowProps>;
|
||||
|
||||
interface TableComponent extends React.FC<TableProps> {
|
||||
Body: TableBodyComponent;
|
||||
Cell: TableCellComponent;
|
||||
Footer: TableFooterComponent;
|
||||
Header: TableHeaderComponent;
|
||||
HeaderCell: TableHeaderCellComponent;
|
||||
Row: TableRowComponent;
|
||||
}
|
||||
|
||||
const TableBody: TableBodyComponent = (props) => {
|
||||
return <STableBody {...props}/>
|
||||
};
|
||||
|
||||
const TableCell: TableCellComponent = (props) => {
|
||||
return <STableCell {...props}/>
|
||||
};
|
||||
|
||||
const TableFooter: TableFooterComponent = (props) => {
|
||||
return <STableFooter {...props}/>
|
||||
};
|
||||
|
||||
const TableHeader: TableHeaderComponent = (props) => {
|
||||
return <STableHeader {...props}/>
|
||||
};
|
||||
|
||||
const TableHeaderCell: TableHeaderCellComponent = (props) => {
|
||||
return <STableHeaderCell {...props}/>
|
||||
};
|
||||
|
||||
const TableRow: TableRowComponent = (props) => {
|
||||
return <STableRow {...props}/>
|
||||
};
|
||||
|
||||
const TableBase: React.FC<TableProps> = (props) => {
|
||||
const {i} = useContext(ThemeContext);
|
||||
return <STable {...i} {...props}/>
|
||||
};
|
||||
|
||||
export const Table: TableComponent = Object.assign(TableBase, {
|
||||
Body: TableBody,
|
||||
Cell: TableCell,
|
||||
Footer: TableFooter,
|
||||
Header: TableHeader,
|
||||
HeaderCell: TableHeaderCell,
|
||||
Row: TableRow,
|
||||
});
|
||||
/*
|
||||
* The Semantic UI wrappers that used to live below this line are gone.
|
||||
*
|
||||
* They existed so call sites could get a themed Button, Segment, Modal and so on while
|
||||
* the app still rendered Semantic components; every one of those call sites now imports
|
||||
* from `src/components/ui` instead. The dark-mode compatibility props they consumed --
|
||||
* `i`, `s`, `t`, `inverted` -- went with them: those carried hardcoded greys, which is
|
||||
* precisely what the token themes replaced.
|
||||
*
|
||||
* What remains is the part that was never about Semantic: the provider that resolves the
|
||||
* chosen theme, stamps `data-theme` and `data-media-filter` on <html>, persists both, and
|
||||
* hands Mantine our tokens.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -47,50 +47,39 @@ export function CollectionEditForm({
|
|||
|
||||
<form onSubmit={handleSubmit} autoComplete="off">
|
||||
{/*
|
||||
* `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 <Grid.Row>/<Grid.Column> 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.
|
||||
*/}
|
||||
Every caller now passes library components rather than Semantic Grid rows, so
|
||||
this is a plain flex column instead of the `ui stackable grid` bridge it used
|
||||
to be. That bridge was the last thing in the app depending on Semantic's
|
||||
stylesheet.
|
||||
*/}
|
||||
<div
|
||||
className="ui stackable grid"
|
||||
className='wrolpi-form-rows'
|
||||
style={form.loading ? {opacity: 0.6, pointerEvents: 'none'} : undefined}
|
||||
>
|
||||
{children}
|
||||
|
||||
{appliedTagName && <div className="row">
|
||||
<div className="column">
|
||||
<SingleTag name={appliedTagName}/>
|
||||
</div>
|
||||
</div>}
|
||||
{appliedTagName && <div><SingleTag name={appliedTagName}/></div>}
|
||||
|
||||
<div className="two column row">
|
||||
<div className="column">
|
||||
{actionButtons}
|
||||
{onCancel && <Button
|
||||
type='button'
|
||||
role='cancel'
|
||||
onClick={onCancel}
|
||||
disabled={form.disabled}
|
||||
className="action-button-spacing"
|
||||
>
|
||||
Cancel
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="column">
|
||||
<Button
|
||||
type='submit'
|
||||
role='save'
|
||||
size='lg'
|
||||
style={{float: 'right'}}
|
||||
disabled={form.disabled}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: 10}}>
|
||||
{actionButtons}
|
||||
{onCancel && <Button
|
||||
type='button'
|
||||
role='cancel'
|
||||
onClick={onCancel}
|
||||
disabled={form.disabled}
|
||||
className="action-button-spacing"
|
||||
>
|
||||
Cancel
|
||||
</Button>}
|
||||
<Button
|
||||
type='submit'
|
||||
role='save'
|
||||
size='lg'
|
||||
style={{marginLeft: 'auto'}}
|
||||
disabled={form.disabled}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -342,6 +342,54 @@ html[data-theme="amber"] .wrolpi-searchbox-control:focus-within {
|
|||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* A stack of form rows, replacing Semantic's `ui stackable grid` in edit forms. */
|
||||
.wrolpi-form-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Form fields */
|
||||
/*
|
||||
* `hooks/useForm.js` renders its own <label> and native <input> — it always did, with
|
||||
* Semantic's Form.Input only wrapping them for field styling. These rules supply that
|
||||
* styling from tokens instead, so a native input matches the library's own controls.
|
||||
*/
|
||||
|
||||
.wrolpi-form-field input {
|
||||
width: 100%;
|
||||
padding: 7px 9px;
|
||||
background: var(--panel);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.wrolpi-form-field input::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.wrolpi-form-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--blue);
|
||||
}
|
||||
|
||||
html[data-theme="night"] .wrolpi-form-field input:focus,
|
||||
html[data-theme="amber"] .wrolpi-form-field input:focus {
|
||||
border-color: var(--text);
|
||||
}
|
||||
|
||||
/* An invalid field is dashed, matching every other failure state in the interface. */
|
||||
.wrolpi-form-field[data-error] input {
|
||||
border: 1.5px dashed var(--red);
|
||||
}
|
||||
|
||||
.wrolpi-form-field input:disabled {
|
||||
color: var(--muted);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- Action inputs */
|
||||
/*
|
||||
* Input and button share one outline: the input gives up its right border and
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
IconButton,
|
||||
Label,
|
||||
Message,
|
||||
Modal,
|
||||
Panel,
|
||||
Progress,
|
||||
resolveIconName,
|
||||
|
|
@ -170,6 +171,34 @@ describe('Button', () => {
|
|||
expect(screen.getByRole('link', {name: 'Save file'}).tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('forwards a ref to the underlying DOM element', () => {
|
||||
/*
|
||||
* Ported from the old Theme.test.js, which caught a real crash: Semantic's Button
|
||||
* was a class component, so a forwarded ref resolved to the class instance, and
|
||||
* anything that called `node.contains(...)` on the trigger -- a Modal or Popup
|
||||
* portal checking whether a click landed inside -- threw "contains is not a
|
||||
* function". Our Tooltip and Menu targets rely on this ref too.
|
||||
*/
|
||||
const ref = React.createRef();
|
||||
renderUI(<Button ref={ref}>Click</Button>);
|
||||
|
||||
expect(ref.current).toBeInstanceOf(HTMLElement);
|
||||
expect(typeof ref.current.contains).toBe('function');
|
||||
expect(ref.current.tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('survives a document click while used as a modal trigger', () => {
|
||||
// The other half of that regression: the Control page crashed when the document
|
||||
// was clicked with a modal open whose trigger was a themed Button.
|
||||
renderUI(<>
|
||||
<Button>Open</Button>
|
||||
<Modal open onClose={jest.fn()}><Modal.Content>content</Modal.Content></Modal>
|
||||
</>);
|
||||
|
||||
expect(() => document.body.dispatchEvent(new MouseEvent('click', {bubbles: true})))
|
||||
.not.toThrow();
|
||||
});
|
||||
|
||||
it('is still a button when there is no href', () => {
|
||||
renderUI(<Button onClick={jest.fn()}>Save</Button>);
|
||||
|
||||
|
|
@ -279,6 +308,20 @@ describe('Progress', () => {
|
|||
expect(bar).toHaveTextContent('62%');
|
||||
});
|
||||
|
||||
it('keeps its percent text readable on every bar colour', () => {
|
||||
/*
|
||||
* Ported from Theme.test.js, which asserted Semantic's `inverted-progress-text`
|
||||
* class across all fourteen colours and both modes. That class is gone; the
|
||||
* mechanism now is that the text always takes `--text` while light mode lightens
|
||||
* the fill beneath it, because the text sits across both the filled and unfilled
|
||||
* halves of the bar.
|
||||
*/
|
||||
const css = fs.readFileSync(path.join(__dirname, 'ui.css'), 'utf8');
|
||||
|
||||
expect(css).toMatch(/\.wrolpi-progress-text\s*{[^}]*color:\s*var\(--text\)/);
|
||||
expect(css).toMatch(/html\[data-theme="light"] \.wrolpi-progress-fill\s*{[^}]*brightness/);
|
||||
});
|
||||
|
||||
it('clamps values outside 0-100', () => {
|
||||
// A download reporting 103% must not overflow its container.
|
||||
renderUI(<><Progress percent={150}/><Progress percent={-20}/></>);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import React from "react";
|
||||
import _ from "lodash";
|
||||
import {InfoPopup, RequiredAsterisk, Toggle, validURL, validURLs} from "../components/Common";
|
||||
import {Form, Icon, TextArea} from "../components/Theme";
|
||||
import {Message} from "../components/ui";
|
||||
import {Icon, Message, Textarea} from "../components/ui";
|
||||
|
||||
|
||||
async function asyncNoOp() {
|
||||
|
|
@ -367,13 +366,14 @@ export function InputForm({
|
|||
/>
|
||||
}
|
||||
</label>
|
||||
<Form.Input
|
||||
id={`${name}_input`}
|
||||
placeholder={placeholder}
|
||||
error={inputProps.error}
|
||||
>
|
||||
<input {...extraInputProps} {...inputProps}/>
|
||||
</Form.Input>
|
||||
<div className='wrolpi-form-field' data-error={inputProps.error ? true : undefined}>
|
||||
<input
|
||||
id={`${name}_input`}
|
||||
placeholder={placeholder}
|
||||
{...extraInputProps}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
{messageElm}
|
||||
</>
|
||||
}
|
||||
|
|
@ -463,31 +463,36 @@ export function UrlsTextarea({name = 'urls', required, form}) {
|
|||
};
|
||||
|
||||
return (
|
||||
<Form.Input required error={inputProps.error} label="URLs">
|
||||
<TextArea
|
||||
id="urls_textarea"
|
||||
placeholder="Enter one URL per line"
|
||||
name="urls"
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...inputProps}
|
||||
/>
|
||||
</Form.Input>
|
||||
<Textarea
|
||||
id="urls_textarea"
|
||||
label="URLs"
|
||||
required
|
||||
error={inputProps.error}
|
||||
placeholder="Enter one URL per line"
|
||||
name="urls"
|
||||
onDrop={handleDrop}
|
||||
onKeyDown={handleKeyDown}
|
||||
autosize
|
||||
minRows={3}
|
||||
{...inputProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleForm({form, name, label, path, icon = null, iconSize = 'big', disabled = false}) {
|
||||
const [inputProps, inputAttrs] = form.getCustomProps({name, path})
|
||||
|
||||
const iconElm = icon ? <Icon size={iconSize} name={icon}/> : null;
|
||||
return <Form.Input
|
||||
label={label}>
|
||||
{iconElm}
|
||||
<Toggle
|
||||
disabled={disabled || form.disabled}
|
||||
name={name}
|
||||
checked={inputProps.value}
|
||||
onChange={inputProps.onChange}
|
||||
/>
|
||||
</Form.Input>
|
||||
const iconElm = icon ? <Icon size={iconSize === 'big' ? 'large' : iconSize} name={icon}/> : null;
|
||||
return <div className='wrolpi-form-field'>
|
||||
<label><b>{label}</b></label>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: 8}}>
|
||||
{iconElm}
|
||||
<Toggle
|
||||
disabled={disabled || form.disabled}
|
||||
name={name}
|
||||
checked={inputProps.value}
|
||||
onChange={inputProps.onChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
/* The font comes from the theme's `--font-body` role token; see themes/tokens.css. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1 @@
|
|||
[
|
||||
"src/components/Theme.tsx"
|
||||
]
|
||||
[]
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import baseline from './semantic-baseline.json';
|
|||
* print 75 warnings on every dev rebuild, or fail the build outright, and CI
|
||||
* already unsets CI= for the build step so warnings there guard nothing.
|
||||
*
|
||||
* When the list reaches zero, delete this test, the JSON, and the dependencies.
|
||||
* The list has reached zero. The test stays as a guard rather than being deleted: it now
|
||||
* asserts that nothing imports Semantic UI, nothing calls its toast library, nothing leans
|
||||
* on its stylesheet, and no migrated file reaches back through Theme.tsx. It can go when
|
||||
* the packages themselves are uninstalled.
|
||||
*/
|
||||
|
||||
const SRC = path.join(__dirname);
|
||||
|
|
@ -74,7 +77,9 @@ describe('Semantic UI removal', () => {
|
|||
* 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'];
|
||||
// Empty, and it should stay that way: Semantic's stylesheet is no longer loaded,
|
||||
// so a `ui ...` class name now styles nothing at all.
|
||||
const allowed = [];
|
||||
|
||||
const bridges = sourceFiles()
|
||||
.filter(file => !baseline.includes(file))
|
||||
|
|
|
|||
|
|
@ -158,12 +158,61 @@ html[data-theme="amber"] {
|
|||
/* Shell */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Base element styling.
|
||||
*
|
||||
* semantic.min.css used to supply all of this, and removing it took the lot with it --
|
||||
* links lost their colour and gained the browser's underline and visited purple, and
|
||||
* every padded element silently changed size when `box-sizing: border-box` went away.
|
||||
* None of it belonged to Semantic; it belongs here, expressed in tokens.
|
||||
*/
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
/*
|
||||
* Links take the primary token rather than the browser's blue, and carry no underline
|
||||
* until hovered -- a great many of them wrap cards, posters, statistics and icons, where
|
||||
* a permanent underline is noise. `--blue` remaps per theme, so night gets a dim red
|
||||
* link and amber an amber one without another rule.
|
||||
*/
|
||||
a {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* A link wrapping a card, poster or icon carries no text of its own to underline. */
|
||||
a:has(img, svg, .wrolpi-card-icon):hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
html[data-theme="night"] a:focus-visible,
|
||||
html[data-theme="amber"] a:focus-visible {
|
||||
outline-color: var(--text);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------------- */
|
||||
/* Media filtering */
|
||||
/* ------------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -31,14 +31,6 @@ export interface StyleProps {
|
|||
|
||||
// ThemeContext value type
|
||||
export interface ThemeContextValue {
|
||||
/** Props for Semantic elements that support "inverted" */
|
||||
i: InvertedProps;
|
||||
/** Props to invert styles on elements */
|
||||
s: StyleProps;
|
||||
/** Props to invert text color */
|
||||
t: StyleProps;
|
||||
/** Class name string: 'inverted' or '' */
|
||||
inverted: string;
|
||||
/** Current applied theme */
|
||||
theme: ThemeName;
|
||||
/** User's saved theme preference */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue