mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
ui: media filtering is a per-theme setting, not a night-mode hardcode
Filtering media was keyed on `data-theme="night"`, which made it a property of
the theme that no user could turn off. Some people want their media tinted to
match the interface; some want to see the actual image.
A theme now declares a filter and whether it starts on, and the user decides:
night night-red on by default. An unfiltered thumbnail undoes dark
adaptation, which is the whole point of the mode.
amber amber-mono off by default. Amber is a look, not a night-vision
aid, so media keeps its real colors until asked for.
light - no filter offered, so no toggle is shown.
dark -
The choice is stored per theme, so wanting amber's tint says nothing about
wanting night's. The toggle lives in the theme picker, which puts it on the
Settings page and in the gallery at once.
Mechanically the rules now key on a `data-media-filter` attribute rather than
`data-theme`: a rule keyed on the theme could not be turned off. index.html
stamps that attribute before first paint alongside data-theme -- without it,
night mode paints a screen of unfiltered thumbnails while the bundle arrives
over a hotspot, costing the user the adaptation the theme exists to protect.
Tests cover both the attribute rule and the fact that the inline script's
duplicated ids and defaults still agree with names.ts.
The amber matrix is the same luminance collapse tinted to #ff9500 (red full,
green 0.584, blue zero) with a gentler gamma than night's: amber carries far
more luminance and clips to a flat wash otherwise.
The gallery gains a Media filtering section showing the WROLPi logo filtered,
beside an unfiltered copy that opts out with `night-unfiltered`. That copy is
behind a click on purpose -- an unfiltered image is a bright patch, and night
mode should not spring one on someone reading in the dark.
ui-design.md gains the media filtering contract it never got in Phase 0,
including the note that a Phase 3 custom theme names an existing filter id and
cannot ship its own matrix, since that would be code rather than data.
This commit is contained in:
parent
973a61af28
commit
efe84d17f1
13 changed files with 546 additions and 39 deletions
|
|
@ -105,9 +105,12 @@ import {
|
|||
isDarkTheme,
|
||||
isThemeName,
|
||||
lightTheme,
|
||||
mediaFilterSessionKey,
|
||||
nightTheme,
|
||||
resolveMediaFilter,
|
||||
systemTheme,
|
||||
themeChoices,
|
||||
themeMediaFilter,
|
||||
themeNames,
|
||||
themeSessionKey,
|
||||
} from "../themes/names";
|
||||
|
|
@ -127,6 +130,7 @@ export {
|
|||
nightTheme,
|
||||
systemTheme,
|
||||
themeChoices,
|
||||
themeMediaFilter,
|
||||
themeNames,
|
||||
themeSessionKey,
|
||||
};
|
||||
|
|
@ -155,6 +159,28 @@ const readSavedTheme = (): SavedThemeName => {
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user's per-theme media filter choices: `{night: false}` means "night, but
|
||||
* do not filter media". A theme absent from the map follows its own default.
|
||||
*/
|
||||
const readMediaFilters = (): Partial<Record<ThemeName, boolean>> => {
|
||||
try {
|
||||
const raw = localStorage.getItem(mediaFilterSessionKey);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
// Only keep known themes with boolean values; the rest is someone else's data.
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return Object.fromEntries(Object.entries(parsed)
|
||||
.filter(([key, value]) => isThemeName(key) && typeof value === 'boolean'));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Blocked storage, or a value an older version wrote.
|
||||
console.error('Unable to read the media filter settings', e);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
interface ThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
|
@ -170,12 +196,28 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
// theme is what is currently applied.
|
||||
const [theme, setThemeName] = useState<ThemeName>(() => resolveTheme(readSavedTheme()));
|
||||
|
||||
// Which themes the user has turned media filtering on or off for.
|
||||
const [mediaFilters, setMediaFilters] = useState<Partial<Record<ThemeName, boolean>>>(
|
||||
readMediaFilters);
|
||||
|
||||
// `data-theme` on <html> is what the token CSS keys off of. index.html stamps it before
|
||||
// first paint from the same localStorage value, so the first render already matches.
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// `data-media-filter` names the filter to apply, or is absent when nothing should be
|
||||
// filtered. index.html stamps this too: a flash of unfiltered thumbnails before the
|
||||
// bundle arrives would cost the user the dark adaptation night mode exists to protect.
|
||||
useEffect(() => {
|
||||
const filter = resolveMediaFilter(theme, mediaFilters);
|
||||
if (filter) {
|
||||
document.documentElement.setAttribute('data-media-filter', filter);
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-media-filter');
|
||||
}
|
||||
}, [theme, mediaFilters]);
|
||||
|
||||
// Follow the OS preference only while the user has not chosen a specific theme.
|
||||
useEffect(() => {
|
||||
if (isThemeName(savedTheme) || typeof window.matchMedia !== 'function') {
|
||||
|
|
@ -210,6 +252,20 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
setThemeName(resolveTheme(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the current theme's media filter on or off. Stored per theme, so a user who
|
||||
* wants amber's tint but not night's — or the reverse — gets both.
|
||||
*/
|
||||
const setMediaFilterEnabled = (enabled: boolean) => {
|
||||
const next = {...mediaFilters, [theme]: enabled};
|
||||
setMediaFilters(next);
|
||||
try {
|
||||
localStorage.setItem(mediaFilterSessionKey, JSON.stringify(next));
|
||||
} catch (e) {
|
||||
console.error('Unable to save the media filter setting', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Retained for callers written against the old two-theme API.
|
||||
const setDarkTheme = (save = false) => {
|
||||
setThemeName(darkTheme);
|
||||
|
|
@ -256,6 +312,10 @@ export function ThemeProvider({children, ...props}: ThemeProviderProps) {
|
|||
theme,
|
||||
savedTheme,
|
||||
isDark: dark,
|
||||
// The filter this theme offers, if any, and whether it is currently applied.
|
||||
mediaFilter: themeMediaFilter(theme),
|
||||
mediaFilterEnabled: !!resolveMediaFilter(theme, mediaFilters),
|
||||
setMediaFilterEnabled,
|
||||
setTheme,
|
||||
setDarkTheme,
|
||||
setLightTheme,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import React, {useContext} from 'react';
|
||||
import {act, render, screen} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
|
@ -13,6 +15,7 @@ import {
|
|||
ThemeProvider,
|
||||
themeSessionKey,
|
||||
} from './Theme';
|
||||
import {mediaFilterSessionKey, resolveMediaFilter, themeMediaFilter} from '../themes/names';
|
||||
|
||||
// Lets a test drive `prefers-color-scheme` and assert on what the provider applied.
|
||||
let prefersDark = false;
|
||||
|
|
@ -38,26 +41,37 @@ const mockMatchMedia = () => {
|
|||
};
|
||||
|
||||
function ThemeProbe() {
|
||||
const {theme, savedTheme, isDark, setTheme, cycleSavedTheme} = useContext(ThemeContext);
|
||||
const {
|
||||
theme, savedTheme, isDark, setTheme, cycleSavedTheme,
|
||||
mediaFilter, mediaFilterEnabled, setMediaFilterEnabled,
|
||||
} = useContext(ThemeContext);
|
||||
return <>
|
||||
<span data-testid='theme'>{theme}</span>
|
||||
<span data-testid='saved'>{String(savedTheme)}</span>
|
||||
<span data-testid='dark'>{String(isDark)}</span>
|
||||
<span data-testid='filter-offered'>{mediaFilter ? mediaFilter.id : 'none'}</span>
|
||||
<span data-testid='filter-on'>{String(mediaFilterEnabled)}</span>
|
||||
<button onClick={() => setTheme(nightTheme)}>night</button>
|
||||
<button onClick={() => setTheme(amberTheme)}>amber</button>
|
||||
<button onClick={() => setTheme(lightTheme)}>light</button>
|
||||
<button onClick={() => setTheme(systemTheme)}>system</button>
|
||||
<button onClick={cycleSavedTheme}>cycle</button>
|
||||
<button onClick={() => setMediaFilterEnabled(false)}>filter off</button>
|
||||
<button onClick={() => setMediaFilterEnabled(true)}>filter on</button>
|
||||
</>
|
||||
}
|
||||
|
||||
const renderProvider = () => render(<ThemeProvider><ThemeProbe/></ThemeProvider>);
|
||||
|
||||
const appliedTheme = () => document.documentElement.getAttribute('data-theme');
|
||||
const appliedFilter = () => document.documentElement.getAttribute('data-media-filter');
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
prefersDark = false;
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.removeAttribute('data-media-filter');
|
||||
mockMatchMedia();
|
||||
});
|
||||
|
||||
|
|
@ -140,6 +154,127 @@ describe('ThemeProvider', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('ThemeProvider media filtering', () => {
|
||||
beforeEach(() => {
|
||||
prefersDark = false;
|
||||
localStorage.clear();
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
document.documentElement.removeAttribute('data-media-filter');
|
||||
mockMatchMedia();
|
||||
});
|
||||
|
||||
it('filters media in night mode without being asked', async () => {
|
||||
// An unfiltered thumbnail undoes dark adaptation, so night starts filtered.
|
||||
renderProvider();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
||||
|
||||
expect(appliedFilter()).toBe('night-red');
|
||||
expect(screen.getByTestId('filter-on')).toHaveTextContent('true');
|
||||
});
|
||||
|
||||
it('leaves amber media alone until the user asks for it', async () => {
|
||||
renderProvider();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
|
||||
|
||||
expect(appliedFilter()).toBeNull();
|
||||
expect(screen.getByTestId('filter-offered')).toHaveTextContent('amber-mono');
|
||||
});
|
||||
|
||||
it('applies no filter, and offers none, in a theme without one', async () => {
|
||||
renderProvider();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'light'}));
|
||||
|
||||
expect(appliedFilter()).toBeNull();
|
||||
expect(screen.getByTestId('filter-offered')).toHaveTextContent('none');
|
||||
});
|
||||
|
||||
it('turns the filter off when the user says so, and persists it', async () => {
|
||||
renderProvider();
|
||||
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
|
||||
|
||||
expect(appliedFilter()).toBeNull();
|
||||
expect(JSON.parse(localStorage.getItem(mediaFilterSessionKey))).toEqual({night: false});
|
||||
});
|
||||
|
||||
it('turns amber filtering on when the user asks', async () => {
|
||||
renderProvider();
|
||||
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
|
||||
|
||||
expect(appliedFilter()).toBe('amber-mono');
|
||||
});
|
||||
|
||||
it('keeps the choice separate for each theme', async () => {
|
||||
// Wanting amber's tint says nothing about wanting night's, and turning night's off
|
||||
// must not disarm a filter the user deliberately enabled elsewhere.
|
||||
renderProvider();
|
||||
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
|
||||
await userEvent.click(screen.getByRole('button', {name: 'filter on'}));
|
||||
await userEvent.click(screen.getByRole('button', {name: 'night'}));
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'filter off'}));
|
||||
expect(appliedFilter()).toBeNull();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', {name: 'amber'}));
|
||||
expect(appliedFilter()).toBe('amber-mono');
|
||||
});
|
||||
|
||||
it('restores the saved choice on the next load', () => {
|
||||
localStorage.setItem(themeSessionKey, nightTheme);
|
||||
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({night: false}));
|
||||
|
||||
renderProvider();
|
||||
|
||||
expect(appliedFilter()).toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to the theme default when the stored settings are unusable', () => {
|
||||
localStorage.setItem(themeSessionKey, nightTheme);
|
||||
localStorage.setItem(mediaFilterSessionKey, 'not json');
|
||||
|
||||
renderProvider();
|
||||
|
||||
// Failing open would leave a user in the dark staring at a white thumbnail.
|
||||
expect(appliedFilter()).toBe('night-red');
|
||||
});
|
||||
|
||||
it('ignores stored entries for themes that no longer exist', () => {
|
||||
localStorage.setItem(themeSessionKey, nightTheme);
|
||||
localStorage.setItem(mediaFilterSessionKey, JSON.stringify({sepia: true, night: false}));
|
||||
|
||||
renderProvider();
|
||||
|
||||
expect(appliedFilter()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveMediaFilter', () => {
|
||||
it('gives every filtering theme a filter defined in MediaFilterDefs', () => {
|
||||
// A theme naming a filter that does not exist would silently filter nothing.
|
||||
const defs = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'themes', 'MediaFilterDefs.tsx'), 'utf8');
|
||||
|
||||
[nightTheme, amberTheme].forEach(theme => {
|
||||
const filter = themeMediaFilter(theme);
|
||||
expect(defs).toContain(`id='wrolpi-${filter.id}'`);
|
||||
});
|
||||
});
|
||||
|
||||
it('honours an override in either direction', () => {
|
||||
expect(resolveMediaFilter(nightTheme, {})).toBe('night-red');
|
||||
expect(resolveMediaFilter(nightTheme, {night: false})).toBe('');
|
||||
expect(resolveMediaFilter(amberTheme, {})).toBe('');
|
||||
expect(resolveMediaFilter(amberTheme, {amber: true})).toBe('amber-mono');
|
||||
expect(resolveMediaFilter(lightTheme, {light: true})).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('theme helpers', () => {
|
||||
beforeEach(() => {
|
||||
prefersDark = false;
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export function ThemeSamplePage() {
|
|||
const [hotspot, setHotspot] = useState(true);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [realColors, setRealColors] = useState(false);
|
||||
|
||||
return <div style={{maxWidth: 1060, margin: '0 auto', padding: '20px 16px 60px', color: 'var(--text)'}}>
|
||||
<h1 style={{fontSize: 21, fontWeight: 600, margin: '8px 0 4px'}}>Component gallery</h1>
|
||||
|
|
@ -80,6 +81,53 @@ export function ThemeSamplePage() {
|
|||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Media filtering'>
|
||||
<Panel>
|
||||
<div style={{display: 'flex', gap: 18, flexWrap: 'wrap', alignItems: 'flex-start'}}>
|
||||
<div>
|
||||
{/*
|
||||
A real <img>, so it goes through whatever filter the current theme
|
||||
applies — this is the only way to see the filter working without
|
||||
leaving the page. The unfiltered copy beside it opts out with
|
||||
`night-unfiltered`, so the two can be compared side by side.
|
||||
*/}
|
||||
<img src='/icon.svg' alt='The WROLPi logo, filtered like any other media'
|
||||
width={104} height={104}/>
|
||||
<p style={{fontSize: 11, color: 'var(--muted)', margin: '4px 0 0'}}>
|
||||
Filtered
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{/*
|
||||
Kept behind a click: an unfiltered image is a bright patch, and
|
||||
leaving one on screen in night mode would cost a user the dark
|
||||
adaptation they came here to protect. They can ask for it.
|
||||
*/}
|
||||
{realColors
|
||||
? <img className='night-unfiltered' src='/icon.svg'
|
||||
alt='The WROLPi logo, unfiltered' width={104} height={104}/>
|
||||
: <Button role='cancel' icon='eye' style={{width: 104, height: 104}}
|
||||
onClick={() => setRealColors(true)}>Show</Button>}
|
||||
<p style={{fontSize: 11, color: 'var(--muted)', margin: '4px 0 0'}}>
|
||||
Real colors
|
||||
</p>
|
||||
</div>
|
||||
<p style={{fontSize: 12, color: 'var(--muted)', margin: 0, maxWidth: 430}}>
|
||||
Images, video, PDFs, embedded pages, and the map canvas cannot read theme
|
||||
tokens, so a monochrome theme remaps them with an SVG color matrix instead.
|
||||
Night filters to red unless you turn it off; amber tints to match only if
|
||||
you ask. Light and dark offer no filter, so both images look the same here.
|
||||
<br/><br/>
|
||||
Use the toggle in the theme picker above to see the difference. An
|
||||
element can opt out with <code>night-unfiltered</code> — that is what
|
||||
the right-hand copy does, which is why it is behind a click: an
|
||||
unfiltered image is a bright patch, and night mode should not spring
|
||||
one on you.
|
||||
</p>
|
||||
</div>
|
||||
</Panel>
|
||||
</Section>
|
||||
|
||||
<Section label='Headers'>
|
||||
<Panel>
|
||||
<Header as='h1'>Page title (h1)</Header>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, {useContext} from 'react';
|
|||
import {ThemeContext} from '../../contexts/contexts';
|
||||
import {systemTheme, themeChoices} from '../../themes/names';
|
||||
import {Icon} from './Icon';
|
||||
import {Toggle} from './Inputs';
|
||||
|
||||
/*
|
||||
* The theme picker.
|
||||
|
|
@ -15,29 +16,52 @@ export interface ThemePickerProps {
|
|||
withDescriptions?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The media filter toggle for the current theme.
|
||||
*
|
||||
* Rendered only by themes that offer a filter, because there is nothing to say
|
||||
* about it otherwise — light and dark have no monochrome media treatment.
|
||||
*/
|
||||
export function MediaFilterToggle() {
|
||||
const {mediaFilter, mediaFilterEnabled, setMediaFilterEnabled} = useContext(ThemeContext);
|
||||
if (!mediaFilter) return null;
|
||||
|
||||
return <div className='wrolpi-media-filter-setting'>
|
||||
<Toggle
|
||||
label={mediaFilter.label}
|
||||
description={mediaFilter.description}
|
||||
checked={mediaFilterEnabled}
|
||||
onChange={event => setMediaFilterEnabled(event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function ThemePicker({withDescriptions = true}: ThemePickerProps) {
|
||||
const {savedTheme, setTheme} = useContext(ThemeContext);
|
||||
// A user who has never chosen is following the system preference.
|
||||
const selected = savedTheme ?? systemTheme;
|
||||
|
||||
return <div role='radiogroup' aria-label='Theme' className='wrolpi-theme-picker'>
|
||||
{themeChoices.map(choice => {
|
||||
const active = choice.value === selected;
|
||||
return <button
|
||||
key={choice.value}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={active}
|
||||
className={`wrolpi-theme-option${active ? ' wrolpi-theme-option-active' : ''}`}
|
||||
onClick={() => setTheme(choice.value)}
|
||||
>
|
||||
<span className='wrolpi-theme-option-name'>
|
||||
<Icon name={choice.icon}/>
|
||||
{choice.text}
|
||||
</span>
|
||||
{withDescriptions &&
|
||||
<span className='wrolpi-theme-option-description'>{choice.description}</span>}
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
return <>
|
||||
<div role='radiogroup' aria-label='Theme' className='wrolpi-theme-picker'>
|
||||
{themeChoices.map(choice => {
|
||||
const active = choice.value === selected;
|
||||
return <button
|
||||
key={choice.value}
|
||||
type='button'
|
||||
role='radio'
|
||||
aria-checked={active}
|
||||
className={`wrolpi-theme-option${active ? ' wrolpi-theme-option-active' : ''}`}
|
||||
onClick={() => setTheme(choice.value)}
|
||||
>
|
||||
<span className='wrolpi-theme-option-name'>
|
||||
<Icon name={choice.icon}/>
|
||||
{choice.text}
|
||||
</span>
|
||||
{withDescriptions &&
|
||||
<span className='wrolpi-theme-option-description'>{choice.description}</span>}
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
<MediaFilterToggle/>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,13 @@ html[data-theme="amber"] .wrolpi-theme-option-active {
|
|||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Separated from the theme grid: it configures the chosen theme, it is not a choice. */
|
||||
.wrolpi-media-filter-setting {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Modals */
|
||||
/*
|
||||
* Actions sit below the body, separated by a hairline rather than a raised bar.
|
||||
|
|
|
|||
|
|
@ -322,7 +322,13 @@ describe('Toggle', () => {
|
|||
describe('ThemePicker', () => {
|
||||
const renderPicker = (themeContext = {}) => {
|
||||
const setTheme = jest.fn();
|
||||
const value = {savedTheme: null, setTheme, ...themeContext};
|
||||
const value = {
|
||||
savedTheme: null,
|
||||
setTheme,
|
||||
mediaFilterEnabled: false,
|
||||
setMediaFilterEnabled: jest.fn(),
|
||||
...themeContext,
|
||||
};
|
||||
render(
|
||||
<MantineProvider theme={mantineTheme} cssVariablesResolver={cssVariablesResolver}>
|
||||
<ThemeContext.Provider value={value}><ThemePicker/></ThemeContext.Provider>
|
||||
|
|
@ -368,6 +374,39 @@ describe('ThemePicker', () => {
|
|||
|
||||
expect(screen.getByText(/night vision/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the media filter toggle in a theme that has one', () => {
|
||||
renderPicker({
|
||||
theme: 'night',
|
||||
mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red',
|
||||
description: 'Videos, images, documents, and maps are remapped to red.'},
|
||||
mediaFilterEnabled: true,
|
||||
});
|
||||
|
||||
expect(screen.getByRole('switch', {name: /filter media to red/i})).toBeChecked();
|
||||
});
|
||||
|
||||
it('offers no toggle in a theme with no filter to offer', () => {
|
||||
// Light and dark have no monochrome media treatment, so there is nothing to say.
|
||||
renderPicker({theme: 'light', mediaFilter: undefined, mediaFilterEnabled: false});
|
||||
|
||||
expect(screen.queryByRole('switch')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns the filter off when the user flips the toggle', async () => {
|
||||
const setMediaFilterEnabled = jest.fn();
|
||||
renderPicker({
|
||||
theme: 'night',
|
||||
mediaFilter: {id: 'night-red', defaultOn: true, label: 'Filter media to red',
|
||||
description: 'Videos, images, documents, and maps are remapped to red.'},
|
||||
mediaFilterEnabled: true,
|
||||
setMediaFilterEnabled,
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('switch', {name: /filter media to red/i}));
|
||||
|
||||
expect(setMediaFilterEnabled).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Confirm', () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue