Move to Zustand and add Filters to web_test (#1077)

* Moved to zustand and started refactored filters

- Move from redux to zustand
- Refactor Filters typings and add some helper types
- Add Filters button and Picker/Switch filter options
- Fixed Syosetu typings

* Add rule consistent-type-definitions

- Add consistent-type-definitions eslint rule to force consistent use of types instead of interfaces
- Run eslint to auto-change every interface to types

https://dev.to/atheodosiou/types-vs-interfaces-in-typescript-making-the-right-choice-48a4
https://www.youtube.com/watch?v=zM9UPcIyyhQ

* Add other filters and refactor

- Move Dialog to FiltersDialog
- CheckboxFilter, TextFilter, XCheckboxFilter added
- Added TripleCheckbox and TripleSwitch as tri-state checkbox/switch items using Mui stylings

* TriStateCheckbox/Switch refactor

- Change name of TripleSwitch and TripleCheckbox to TriState
- Change slightly TriStateSwitch and StyledSwitch styles
- Add option to change into TriStateSwitches via `localStorage` value `useSwitches`

* Refactor code and fix issues

* fix: prettier

---------

Co-authored-by: nyagami <hoangquan05112002@gmail.com>
This commit is contained in:
Marcin Majewski
2024-08-18 17:31:41 +02:00
committed by GitHub
parent eb5522b066
commit b2caf26da3
73 changed files with 1914 additions and 916 deletions
+26 -26
View File
@@ -18,42 +18,29 @@ import { Search, Translate } from '@mui/icons-material';
import { searchPlugins } from '@provider/plugins';
import { Plugin } from '@typings/plugin';
import { FixedSizeList, ListChildComponentProps } from 'react-window';
import { useDispatch } from 'react-redux';
import { selectPlugin } from '@redux/pluginSlice';
import usePlugin from '@hooks/usePlugin';
import { useAppStore } from '@store';
const resovleIcon = (iconPath: string) => {
return '/static/' + iconPath;
};
export default function () {
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
const [keyword, setKeyword] = useState('');
const [listVisible, setListVisible] = useState(false);
const plugin = usePlugin();
const dispatch = useDispatch();
useEffect(() => {
const plugins = searchPlugins(keyword);
setSearchPlugins(plugins);
}, [keyword]);
function renderPlugin(props: ListChildComponentProps<Plugin.PluginBase[]>) {
function renderPlugin(setListVisible: (v: boolean) => void) {
return function (props: ListChildComponentProps<Plugin.PluginBase[]>) {
const { index, style, data } = props;
const plugin = data[index];
const selectPlugin = useAppStore(state => state.selectPlugin);
return (
<ListItem style={style} key={index} component="div" disablePadding>
<ListItemButton
onClick={() => {
dispatch(
selectPlugin({
id: plugin.id,
name: plugin.name,
version: plugin.version,
icon: plugin.icon,
site: plugin.site,
}),
);
selectPlugin({
id: plugin.id,
name: plugin.name,
version: plugin.version,
icon: plugin.icon,
site: plugin.site,
});
setListVisible(false);
}}
>
@@ -64,7 +51,20 @@ export default function () {
</ListItemButton>
</ListItem>
);
}
};
}
export default function Appbar() {
const [searchedPlugins, setSearchPlugins] = useState<Plugin.PluginBase[]>([]);
const [keyword, setKeyword] = useState('');
const [listVisible, setListVisible] = useState(false);
const plugin = useAppStore(state => state.plugin);
useEffect(() => {
const plugins = searchPlugins(keyword);
setSearchPlugins(plugins);
}, [keyword]);
return (
<Box sx={{ display: 'flex' }}>
@@ -111,7 +111,7 @@ export default function () {
backgroundColor: 'white',
}}
>
{renderPlugin}
{renderPlugin(setListVisible)}
</FixedSizeList>
) : null}
</FormControl>
+2 -2
View File
@@ -3,10 +3,10 @@ import { createTheme, ThemeProvider } from '@mui/material/styles';
import React from 'react';
import CopiableItem from './CopiableItem';
interface Validation {
type Validation = {
type?: 'error' | 'warning';
message?: string;
}
};
const validatePath = (path: string): Validation => {
if (path.includes('http'))
+14
View File
@@ -0,0 +1,14 @@
import { styled, Switch } from '@mui/material';
export const StyledSwitch = styled(Switch)(() => ({
padding: 8,
'& .MuiSwitch-track': {
borderRadius: 22 / 2,
},
'& .MuiSwitch-thumb': {
boxShadow: 'none',
width: 16,
height: 16,
margin: 2,
},
}));
@@ -1,10 +1,10 @@
import { Box, Button, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import usePlugin from '@hooks/usePlugin';
import Textarea from '@components/Textarea';
import { useAppStore } from '@store';
export default function ParseChapter() {
const plugin = usePlugin();
const plugin = useAppStore(state => state.plugin);
const [loading, setLoading] = useState(false);
const [chapterPath, setchapterPath] = useState('');
const [chapterText, setChapterText] = useState('');
@@ -2,12 +2,12 @@ import { Box, Button, Pagination, Stack, TextField } from '@mui/material';
import React, { useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import usePlugin from '@hooks/usePlugin';
import SourceNovelCard from './SourceNovelCard';
import ChapterList from './ChaptertList';
import { useAppStore } from '@store';
export default function ParseNovel() {
const plugin = usePlugin();
const plugin = useAppStore(state => state.plugin);
const [sourceNovel, setSouceNovel] = useState<
Plugin.SourceNovel | undefined
>();
@@ -0,0 +1,11 @@
#filtersContent {
display: flex;
flex-direction: column;
align-content: center;
}
.switch_filter {
display: flex;
flex-direction: column;
align-items: center;
}
@@ -8,40 +8,42 @@ import {
FormControlLabel,
FormGroup,
} from '@mui/material';
import React, { useEffect, useState, ChangeEvent } from 'react';
import React, { useEffect, useState, ChangeEvent, useRef } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import NovelItemCard from '../components/NovelItemCard';
import { Filters } from '@libs/filterInputs';
import usePlugin from '@hooks/usePlugin';
import { Filters, FilterToValues } from '@libs/filterInputs';
import { useAppStore } from '@store';
import './index.css';
import { DialogRef, FiltersDialog } from '../components/filters/FiltersDialog';
export default function PopularNovels() {
const plugin = usePlugin();
const plugin = useAppStore(state => state.plugin);
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [filterValues, setFilterValues] = useState<Filters | undefined>();
const [filterValues, setFilterValues] = useState<
FilterToValues<Filters> | undefined
>();
const [loading, setLoading] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const [maxIndex, setMaxIndex] = useState(0);
const [isLatest, setIsLatest] = useState(true);
const dialogRef = useRef<DialogRef>(null);
const fetchNovelsByIndex = (index: number) => {
const fetchNovelsByIndex = async (index: number) => {
if (plugin && index) {
setLoading(true);
plugin
.popularNovels(index, {
filters: filterValues,
showLatestNovels: isLatest,
})
.then(res => {
if (res.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(res);
}
})
.finally(() => setLoading(false));
const novels = await plugin.popularNovels(index, {
filters: filterValues || {},
showLatestNovels: isLatest,
});
if (novels.length !== 0) {
setCurrentIndex(index);
if (index > maxIndex) {
setMaxIndex(index);
}
setNovels(novels);
}
setLoading(false);
}
};
@@ -59,14 +61,15 @@ export default function PopularNovels() {
useEffect(() => {
// Reset when changing plugins.
// TODO: Fix crashing on plugin change!
setCurrentIndex(0);
setMaxIndex(0);
setNovels([]);
if (plugin?.filters) {
const filters = {};
const filters: FilterToValues<typeof plugin.filters> = {};
for (const fKey in plugin.filters) {
filters[fKey] = {
filters[fKey as keyof typeof filters] = {
type: plugin.filters[fKey].type,
value: plugin.filters[fKey].value,
};
@@ -74,6 +77,7 @@ export default function PopularNovels() {
setFilterValues(filters);
}
}, [plugin]);
return (
<AccordionContainer title="Popular Novels" loading={loading}>
<Stack direction={'row'} spacing={2} alignItems={'center'}>
@@ -103,10 +107,25 @@ export default function PopularNovels() {
>
Next Page
</Button>
<Button
disabled={!plugin || isLatest || !plugin.filters}
onClick={() => {
dialogRef.current?.show();
}}
>
Filters
</Button>
<FiltersDialog
ref={dialogRef}
values={filterValues}
filters={plugin?.filters}
setValues={setFilterValues}
refetch={() => fetchNovelsByIndex(1)}
/>
<ToggleButtonGroup
value={currentIndex}
exclusive
onChange={(event, value) => fetchNovelsByIndex(value)}
onChange={(_event, value) => fetchNovelsByIndex(value)}
>
{Array.from({ length: maxIndex }, (_, index) => index + 1).map(
number => (
@@ -3,35 +3,34 @@ import React, { useEffect, useState } from 'react';
import AccordionContainer from '../components/AccordionContainer';
import { Plugin } from '@typings/plugin';
import NovelItemCard from '../components/NovelItemCard';
import { useSelector } from 'react-redux';
import { AppState } from '@redux/store';
import { getPlugin } from '@provider/plugins';
import { useAppStore } from '@store';
export default function SearchNovels() {
const selectedPluginItem = useSelector(
(state: AppState) => state.plugin.selected,
);
const plugin = useAppStore(state => state.plugin);
const [searchTerm, setSearchTerm] = useState('');
const [page] = useState(1);
const [plugin, setPlugin] = useState<Plugin.PluginBase | undefined>();
const [novels, setNovels] = useState<Plugin.NovelItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (selectedPluginItem) {
setPlugin(getPlugin(selectedPluginItem.id));
}
}, [selectedPluginItem]);
const [fetchError, setFetchError] = useState('');
const fetchNovels = () => {
if (plugin) {
setLoading(true);
plugin
.searchNovels(searchTerm, page)
.then(res => setNovels(res))
.finally(() => setLoading(false));
.finally(() => setLoading(false))
.catch(e => {
setFetchError(e.message);
});
}
};
return (
<AccordionContainer title="Search Novels" loading={loading}>
{fetchError && <span style={{ color: 'red' }}>{fetchError}</span>}
<Stack direction={'row'} spacing={2}>
<TextField
value={searchTerm}
@@ -0,0 +1,64 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import {
Checkbox,
FormControl,
FormControlLabel,
FormLabel,
Grid,
} from '@mui/material';
type FilterType = FilterTypes.CheckboxGroup;
export const CheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid
container
columns={{ xs: 3 }}
id={`checkbox_filter_${key}_${label}`}
>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
id={`checkbox_filter_option_${key}_${option.value.replace(/\s+/gi, '-')}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
control={
<Checkbox
onChange={() => {
set(
value.includes(option.value)
? value.filter(q => q !== option.value)
: [...value, option.value],
);
}}
checked={value.includes(option.value)}
/>
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};
@@ -0,0 +1,211 @@
import React, {
forwardRef,
ReactNode,
useEffect,
useImperativeHandle,
} from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
} from '@mui/material';
import { useState } from 'react';
import {
AnyFilterValue,
Filters,
FilterToValues,
FilterTypes,
} from '@libs/filterInputs';
import { PickerFilter } from './PickerFilter';
import { SwitchFilter } from './SwitchFilter';
import { TextFilter } from './TextFilter';
import { CheckboxFilter } from './CheckboxFilter';
import { XCheckboxFilter } from './XCheckboxFilter';
const renderFilters = (
filters: Filters | undefined,
values: FilterToValues<Filters> | undefined,
set: (key: string, v: AnyFilterValue) => void,
): React.ReactNode => {
const isValueCorrectType = <T extends AnyFilterValue>(
o: AnyFilterValue,
f: T,
): o is T => {
const checkIfIsCorrectObjectType = (o: AnyFilterValue, f: T): o is T => {
const areArrays = Array.isArray(o) && Array.isArray(f);
const areObjects = typeof o === 'object' && typeof f === 'object';
return areArrays || areObjects;
};
return typeof o === typeof f || checkIfIsCorrectObjectType(o, f);
};
if (!filters || !values) return false;
return (
<>
{Object.entries(filters).map(([key, filter]) => {
if (!(key in values)) {
console.error(`No filter value for ${key} in filter values!`);
return null;
}
switch (filter.type) {
case FilterTypes.Picker: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<PickerFilter
key={`picker_filter_${key}`}
filter={{ key, filter }}
value={value}
set={newValue => set(key, newValue)}
/>
);
}
case FilterTypes.Switch: {
// Check if filterValues have correct type
// this needs to be inside of every case in this switch to get correct value type
const value = values[key].value; // here value has every possible value type
// We could just do `as typeof filter.value` but just to be sure I made a typeguard
if (!isValueCorrectType<typeof filter.value>(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
// value; // here value has only Picker's value
return (
<SwitchFilter
filter={{ key, filter }}
key={`switch_filter_${key}`}
value={value}
set={newValue => set(key, newValue)}
/>
);
}
case FilterTypes.TextInput: {
const value = values[key].value;
if (!isValueCorrectType(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
return (
<TextFilter
filter={{ key, filter }}
set={newValue => set(key, newValue)}
value={value}
key={`text_filter_${key}`}
/>
);
}
case FilterTypes.CheckboxGroup: {
const value = values[key].value;
if (!isValueCorrectType(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
return (
<CheckboxFilter
filter={{ key, filter }}
set={newValue => set(key, newValue)}
value={value}
key={`checkbox_filter_${key}`}
/>
);
}
case FilterTypes.ExcludableCheckboxGroup: {
const value = values[key].value;
if (!isValueCorrectType(value, filter.value)) {
console.error(
`FilterValue for filter [${key}] has a wrong type!`,
);
return null;
}
return (
<XCheckboxFilter
filter={{ key, filter }}
set={newValue => set(key, newValue)}
value={value}
key={`checkbox_filter_${key}`}
/>
);
}
}
})}
</>
);
};
export type DialogRef = { show(): void; hide(): void };
export const FiltersDialog = forwardRef<
DialogRef,
{
values: FilterToValues<Filters> | undefined;
filters: Filters | undefined;
setValues: React.Dispatch<
React.SetStateAction<FilterToValues<Filters> | undefined>
>;
refetch(): void;
}
>(function FiltersDialog({ values, setValues, filters, refetch }, ref) {
const [dialogOpen, setDialogOpen] = useState(false);
const [filterElements, setFilterElements] = useState<ReactNode>(false);
const setFilterWithKey = (key: string, newValue: AnyFilterValue) =>
setValues(fValues =>
!fValues
? fValues
: {
...fValues,
[key]: {
...fValues[key],
value: newValue,
},
},
);
useImperativeHandle(ref, () => ({
show() {
setDialogOpen(true);
},
hide() {
setDialogOpen(false);
},
}));
useEffect(() => {
setFilterElements(renderFilters(filters, values, setFilterWithKey));
}, [values]);
return (
<Dialog
scroll="paper"
open={dialogOpen}
onClose={() => setDialogOpen(false)}
>
<DialogTitle>Filters</DialogTitle>
<DialogContent dividers id="filtersContent">
{filterElements}
</DialogContent>
<DialogActions>
<Button onClick={() => refetch()}>Refetch</Button>
<Button style={{ color: 'red' }} onClick={() => setDialogOpen(false)}>
Close
</Button>
</DialogActions>
</Dialog>
);
});
@@ -0,0 +1,61 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, InputLabel, MenuItem, Select } from '@mui/material';
type FilterType = FilterTypes.Picker;
export const PickerFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: ValueOfFilter<FilterType>) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
variant="filled"
fullWidth
margin="dense"
style={{ alignSelf: 'center' }}
>
<InputLabel id={`picker_filter_label_${label}_${key}`}>
{filter.label}
</InputLabel>
<Select
size="medium"
labelId={`picker_filter_label_${label}_${key}`}
id={`picker_filter_${label}_${key}`}
value={value || key}
onChange={({ target: { value: newValue } }) => {
if (newValue === key) newValue = '';
set(newValue);
}}
MenuProps={{ slotProps: { paper: { sx: { maxHeight: 36 * 4 } } } }}
label={filter.label}
>
{filter.options.map(option => {
return (
<MenuItem
style={{ minHeight: 36 }}
key={`${key}_${option.value}`}
value={
/** Because Mui treats empty string as `empty value`
* and doesn't show the MenuItem, if the value is empty, just use the key...
* We could do some other weird stuff, but I doubt there will be a filter
* that has the same name as the value being used inside of it.
* We can warn people in the documentation about this behavior */
option.value || key
}
>
{option.label}
</MenuItem>
);
})}
</Select>
</FormControl>
);
};
@@ -0,0 +1,33 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel } from '@mui/material';
import { StyledSwitch } from '../../../StyledSwitch';
type FilterType = FilterTypes.Switch;
export const SwitchFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
return (
<FormControl
key={key}
className="switch_filter"
fullWidth
variant="outlined"
>
<FormControlLabel
style={{ userSelect: 'none' }}
label={filter.label}
id={`switch_filter_${label}_${key}`}
control={<StyledSwitch onChange={() => set(!value)} />}
/>
</FormControl>
);
};
@@ -0,0 +1,38 @@
import React, { useRef } from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, TextField } from '@mui/material';
type FilterType = FilterTypes.TextInput;
export const TextFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const label = filter.label.replace(/s+/gi, '-');
const textRef = useRef<HTMLInputElement>(null);
return (
<FormControl key={key} className="switch_filter">
<TextField
color="primary"
id={`text_filter_${label}_${key}`}
inputRef={textRef}
label={filter.label}
value={value}
type="text"
variant="filled"
fullWidth
margin="dense"
onInput={() => {
if (textRef.current) {
set(textRef.current.value ?? '');
}
}}
/>
</FormControl>
);
};
@@ -0,0 +1,140 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateCheckboxClasses {
root = 'TriStateCheckboxRoot';
base = 'TriStateCheckboxBase';
input = 'TriStateCheckboxInput';
svg = 'TriStateCheckboxSVG';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 16,
checked: 16 * 2,
unchecked: 0,
};
constructor() {
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateCheckboxClasses();
const TriStateCheckboxRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(({ theme }) => ({
[`&.${classes.checked}`]: {
color: theme.palette.primary.main,
},
[`&.${classes.unchecked}`]: {
color: 'red',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxSizing: 'border-box',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
borderRadius: '50%',
padding: '9px',
}));
const TriStateCheckboxInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
width: '100%',
top: 0,
left: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateCheckboxSVG = styled(`svg`, { name: classes.svg, slot: 'image' })(
() => ({
userSelect: 'none',
width: '1em',
height: '1em',
display: 'inline-block',
fill: 'currentcolor',
flexShrink: 0,
fontSize: '1.5rem',
transition: 'fill 200ms cubic-bezier(0.4,0,0.2,1) 0ms',
}),
);
const emptyPath = (
<path d="M19 5v14H5V5h14m0-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"></path>
);
const checkmarkPath = (
<path d="M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zm-9 14l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"></path>
);
const uncheckedPath = (
<path
d={`M19 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.11 0 2-.9 2-2V5c0-1.1-.89-2-2-2zM8 6l4 4l4-4l2 2l-4 4l4,4l-2 2l-4-4l-4 4l-2-2l4-4l-4-4z`}
></path>
);
export type TriStateCheckboxState = 'checked' | 'unchecked' | 'neutral';
export const TriStateCheckbox = function ({
value,
onChange,
}: {
value?: TriStateCheckboxState;
onChange?: (newValue: TriStateCheckboxState) => void;
}) {
const [state, sSt] = useState<TriStateCheckboxState>(value ?? 'neutral');
const setState = (v: TriStateCheckboxState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
const curVal = value ?? state;
return (
<>
<TriStateCheckboxRoot
className={classes[curVal]}
data-state={curVal}
ref={root}
onClick={() => {
switch (curVal) {
case 'checked':
return setState('unchecked');
case 'neutral':
return setState('checked');
case 'unchecked':
return setState('neutral');
}
}}
>
<TriStateCheckboxInput />
<TriStateCheckboxSVG>
{curVal === 'checked'
? checkmarkPath
: curVal === 'unchecked'
? uncheckedPath
: emptyPath}
</TriStateCheckboxSVG>
</TriStateCheckboxRoot>
</>
);
};
@@ -0,0 +1,191 @@
import { styled } from '@mui/system';
import React, { useRef, useState } from 'react';
class TriStateSwitchClasses {
root = 'TriStateSwitchRoot';
base = 'TriStateSwitchBase';
track = 'TriStateSwitchTrack';
thumb = 'TriStateSwitchThumb';
input = 'TriStateSwitchInput';
checked: string;
unchecked: string;
neutral: string;
offsets = {
neutral: 18,
checked: 18 * 2,
unchecked: 0,
};
width = 16 * 4 + 10;
constructor() {
this.base;
this.checked = `${this.base}-checked`;
this.neutral = `${this.base}-neutral`;
this.unchecked = `${this.base}-unchecked`;
}
}
const classes = new TriStateSwitchClasses();
const TriStateSwitchRoot = styled(`span`, {
name: classes.root,
slot: 'root',
})(() => ({
display: 'inline-flex',
width: `${classes.width}px`,
height: '38px',
overflow: 'hidden',
boxSizing: 'border-box',
position: 'relative',
flexShrink: 0,
zIndex: 0,
verticalAlign: 'middle',
padding: '8px',
}));
const TriStateSwitchTrack = styled(`span`, {
name: classes.track,
slot: 'track',
})(() => ({
// Android12 visuals:
borderRadius: 22 / 2,
// base Track
width: '100%',
height: '100%',
zIndex: -1,
content: `''`,
transition:
'opacity 150ms cubic-bezier(0.4,0,0.2,1) 0ms, background-color 150ms cubic-bezier(0.4,0,0.2,1) 0ms',
backgroundColor: '#000',
opacity: 0.38,
}));
const TriStateSwitchBase = styled(`span`, {
name: classes.base,
slot: 'base',
})(({ theme }) => ({
[`&.${classes.unchecked}`]: {
transform: `translateX(${classes.offsets.unchecked}px)`,
color: '#f33',
},
[`&.${classes.checked}`]: {
transform: `translateX(${classes.offsets.checked}px)`,
color: theme.palette.primary.main,
},
[`&.${classes.neutral}`]: {
transform: `translateX(${classes.offsets.neutral}px)`,
color: 'gray',
},
[`&.${classes.checked} + span`]: {
opacity: 0.5,
backgroundColor: theme.palette.primary.main,
},
[`&.${classes.unchecked} + span`]: {
opacity: 0.5,
backgroundColor: '#f33',
},
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'transparent',
outline: 0,
border: 0,
margin: 0,
cursor: 'pointer',
userSelect: 'none',
textDecoration: 'none',
padding: '9px',
borderRadius: '50%',
position: 'absolute',
top: 0,
left: 0,
zIndex: 1,
color: '#fff',
transition:
'left 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;',
}));
const TriStateSwitchInput = styled(`input`, {
name: classes.input,
slot: 'input',
})(() => ({
left: '-100%',
width: '300%',
cursor: 'inherit',
position: 'absolute',
opacity: 0,
height: '100%',
top: 0,
margin: 0,
padding: 0,
zIndex: 1,
}));
const TriStateSwitchThumb = styled(`span`, {
name: classes.thumb,
slot: 'thumb',
})(() => ({
boxShadow: 'none',
width: '16px',
height: '16px',
margin: '2px',
backgroundColor: 'currentColor',
borderRadius: '50%',
}));
type TriStateSwitchState = 'checked' | 'unchecked' | 'neutral';
export const TriStateSwitch = function ({
value,
onChange,
}: {
value?: TriStateSwitchState;
onChange?: (newValue: TriStateSwitchState) => void;
}) {
const [state, sSt] = useState<TriStateSwitchState>(value ?? 'neutral');
const setState = (v: TriStateSwitchState) => {
onChange?.(v);
sSt(value ?? v);
};
const root = useRef<HTMLSpanElement>(null);
return (
<>
<TriStateSwitchRoot
data-state={state}
ref={root}
onClick={e => {
const offsetX =
e.clientX - (root.current?.getBoundingClientRect().x ?? 0);
const inputWidth = parseFloat(
getComputedStyle(e.currentTarget).width,
);
const clickPercentage = offsetX / inputWidth;
const sidePressed =
clickPercentage < 0.33
? 'left'
: clickPercentage > 0.66
? 'right'
: 'center';
console.log(inputWidth, offsetX);
switch (sidePressed) {
case 'center':
return setState('neutral');
case 'left':
return setState('unchecked');
case 'right':
return setState('checked');
}
}}
>
<TriStateSwitchBase className={classes[value ?? state]}>
<TriStateSwitchInput />
<TriStateSwitchThumb />
</TriStateSwitchBase>
<TriStateSwitchTrack />
</TriStateSwitchRoot>
</>
);
};
@@ -0,0 +1,88 @@
import React from 'react';
import { Filter, FilterTypes, ValueOfFilter } from '@libs/filterInputs';
import { FormControl, FormControlLabel, FormLabel, Grid } from '@mui/material';
import { useAppStore } from '@store';
import { TriStateCheckbox, TriStateCheckboxState } from './TriStateCheckbox';
import { TriStateSwitch } from './TriStateSwitch';
type FilterType = FilterTypes.ExcludableCheckboxGroup;
export const XCheckboxFilter = ({
filter: { key, filter },
value,
set,
}: {
filter: { key: string; filter: Filter<FilterType> };
value: ValueOfFilter<FilterType>;
set: (v: typeof value) => void;
}) => {
const useSwitches = useAppStore(state => state.useSwitches);
const getCheckboxState = (v: string): TriStateCheckboxState => {
if (value.include?.includes(v)) return 'checked';
if (value.exclude?.includes(v)) return 'unchecked';
return 'neutral';
};
const handleChange = (key: string, v: TriStateCheckboxState) => {
console.log(`setting ${key} to`, v);
if (v === 'unchecked')
return set({
exclude: value.exclude
? Array.from(new Set([...value.exclude, key]))
: [key],
include: value.include?.filter(f => f !== key),
});
if (v === 'checked')
return set({
include: value.include
? Array.from(new Set([...value.include, key]))
: [key],
exclude: value.exclude?.filter(f => f !== key),
});
return set({
include: value.include?.filter(f => f !== key),
exclude: value.exclude?.filter(f => f !== key),
});
};
return (
<FormControl key={key} variant="outlined">
<FormLabel>{filter.label}</FormLabel>
<Grid container columns={{ xs: 3 }}>
{filter.options.map(option => {
return (
<Grid
key={`${key}_${option.label}`}
item
xs={1}
justifyContent={'center'}
display={'flex'}
>
<FormControl>
<FormControlLabel
labelPlacement={useSwitches ? 'top' : 'end'}
style={{ userSelect: 'none' }}
control={
useSwitches ? (
<TriStateSwitch
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
) : (
<TriStateCheckbox
value={getCheckboxState(option.value)}
onChange={c => handleChange(option.value, c)}
/>
)
}
label={option.label}
/>
</FormControl>
</Grid>
);
})}
</Grid>
</FormControl>
);
};