Refactor Checkbox and add Tristate

This commit is contained in:
IRBorisov 2023-09-07 16:30:43 +03:00
parent c58743bdb0
commit 75fd9a855c
17 changed files with 180 additions and 88 deletions

View File

@ -1,5 +1,6 @@
import { useRef } from 'react'; import { useMemo } from 'react';
import { CheckboxChecked } from '../Icons';
import Label from './Label'; import Label from './Label';
export interface CheckboxProps { export interface CheckboxProps {
@ -9,51 +10,52 @@ export interface CheckboxProps {
disabled?: boolean disabled?: boolean
widthClass?: string widthClass?: string
tooltip?: string tooltip?: string
value?: boolean
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void value: boolean
setValue?: (newValue: boolean) => void
} }
function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-fit', value, onChange }: CheckboxProps) { function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-fit', value, setValue }: CheckboxProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const cursor = useMemo(
() => {
const cursor = disabled ? 'cursor-not-allowed' : 'cursor-pointer'; if (disabled) {
return 'cursor-not-allowed';
} else if (setValue) {
return 'cursor-pointer';
} else {
return ''
}
}, [disabled, setValue]);
const bgColor = useMemo(
() => {
return value !== false ? 'clr-primary' : 'clr-app'
}, [value]);
function handleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void { function handleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void {
event.preventDefault(); event.preventDefault();
if (!disabled) { if (disabled || !setValue) {
inputRef.current?.click(); return;
} }
setValue(!value);
} }
return ( return (
<button <button
className={'flex [&:not(:first-child)]:mt-3 clr-outline focus:outline-dotted focus:outline-1 ' + widthClass} id={id}
className={`flex items-center [&:not(:first-child)]:mt-3 clr-outline focus:outline-dotted focus:outline-1 ${widthClass}`}
title={tooltip} title={tooltip}
disabled={disabled} disabled={disabled}
onClick={handleClick} onClick={handleClick}
> >
<input id={id} type='checkbox' ref={inputRef} <div className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none ${bgColor} ${cursor}`} />
className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none clr-checkbox ${cursor}`}
required={required}
disabled={disabled}
checked={value}
onChange={onChange}
tabIndex={-1}
/>
{ label && { label &&
<Label <Label
className={`${cursor} px-2`} className={`${cursor} px-2 text-start`}
text={label} text={label}
required={required} required={required}
htmlFor={id} htmlFor={id}
/>} />}
<svg {value && <CheckboxChecked />}
className='absolute hidden w-3 h-3 mt-1 ml-0.5 text-white pointer-events-none peer-checked:block'
viewBox='0 0 512 512'
fill='currentColor'
>
<path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' />
</svg>
</button> </button>
); );
} }

View File

@ -1,15 +1,15 @@
import Checkbox from './Checkbox'; import Checkbox from './Checkbox';
interface DropdownCheckboxProps { interface DropdownCheckboxProps {
value: boolean
label?: string label?: string
tooltip?: string tooltip?: string
disabled?: boolean disabled?: boolean
value?: boolean setValue?: (newValue: boolean) => void
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
} }
function DropdownCheckbox({ tooltip, onChange, disabled, ...props }: DropdownCheckboxProps) { function DropdownCheckbox({ tooltip, setValue, disabled, ...props }: DropdownCheckboxProps) {
const behavior = (onChange && !disabled ? 'clr-hover' : ''); const behavior = (setValue && !disabled ? 'clr-hover' : '');
return ( return (
<div <div
title={tooltip} title={tooltip}
@ -18,7 +18,7 @@ function DropdownCheckbox({ tooltip, onChange, disabled, ...props }: DropdownChe
<Checkbox <Checkbox
widthClass='w-full' widthClass='w-full'
disabled={disabled} disabled={disabled}
onChange={onChange} setValue={setValue}
{...props} {...props}
/> />
</div> </div>

View File

@ -4,18 +4,22 @@ import { UploadIcon } from '../Icons';
import Button from './Button'; import Button from './Button';
import Label from './Label'; import Label from './Label';
interface FileInputProps { interface FileInputProps
id?: string extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className' | 'title' | 'style' | 'accept' | 'type'> {
required?: boolean
label: string label: string
tooltip?: string
acceptType?: string acceptType?: string
widthClass?: string widthClass?: string
onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void
} }
function FileInput({ id, required, label, acceptType, widthClass = 'w-full', onChange }: FileInputProps) { function FileInput({
label, acceptType, tooltip,
widthClass = 'w-fit', onChange,
...props
}: FileInputProps) {
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
const [labelText, setLabelText] = useState(''); const [fileName, setFileName] = useState('');
const handleUploadClick = () => { const handleUploadClick = () => {
inputRef.current?.click(); inputRef.current?.click();
@ -23,9 +27,9 @@ function FileInput({ id, required, label, acceptType, widthClass = 'w-full', onC
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (event.target.files && event.target.files.length > 0) { if (event.target.files && event.target.files.length > 0) {
setLabelText(event.target.files[0].name) setFileName(event.target.files[0].name)
} else { } else {
setLabelText('') setFileName('')
} }
if (onChange) { if (onChange) {
onChange(event); onChange(event);
@ -33,21 +37,22 @@ function FileInput({ id, required, label, acceptType, widthClass = 'w-full', onC
}; };
return ( return (
<div className={'flex flex-col gap-2 py-2 [&:not(:first-child)]:mt-3 items-start ' + widthClass}> <div className={`flex flex-col gap-2 py-2 mt-3 items-start ${widthClass}`}>
<input id={id} type='file' <input type='file'
ref={inputRef} ref={inputRef}
required={required}
style={{ display: 'none' }} style={{ display: 'none' }}
accept={acceptType} accept={acceptType}
onChange={handleFileChange} onChange={handleFileChange}
{...props}
/> />
<Button <Button
text={label} text={label}
icon={<UploadIcon/>} icon={<UploadIcon/>}
onClick={handleUploadClick} onClick={handleUploadClick}
tooltip={tooltip}
/> />
<Label <Label
text={labelText} text={fileName}
/> />
</div> </div>
); );

View File

@ -14,7 +14,7 @@ function SubmitButton({
return ( return (
<button type='submit' <button type='submit'
title={tooltip} title={tooltip}
className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-bold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${widthClass} ${loading ? ' cursor-progress' : ''}`} className={`px-4 py-2 inline-flex items-center gap-2 align-middle justify-center font-semibold select-none disabled:cursor-not-allowed border rounded clr-btn-primary ${widthClass} ${loading ? ' cursor-progress' : ''}`}
disabled={disabled ?? loading} disabled={disabled ?? loading}
> >
{icon && <span>{icon}</span>} {icon && <span>{icon}</span>}

View File

@ -1,9 +1,7 @@
import { type InputHTMLAttributes } from 'react';
import Label from './Label'; import Label from './Label';
interface TextInputProps interface TextInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, 'className' | 'title'> { extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'className' | 'title'> {
id: string id: string
label: string label: string
tooltip?: string tooltip?: string

View File

@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { CheckboxChecked, CheckboxNull } from '../Icons';
import { CheckboxProps } from './Checkbox';
import Label from './Label';
export interface TristateProps
extends Omit<CheckboxProps, 'value' | 'setValue'> {
value: boolean | null
setValue?: (newValue: boolean | null) => void
}
function Checkbox({ id, required, disabled, tooltip, label, widthClass = 'w-fit', value, setValue }: TristateProps) {
const cursor = useMemo(
() => {
if (disabled) {
return 'cursor-not-allowed';
} else if (setValue) {
return 'cursor-pointer';
} else {
return ''
}
}, [disabled, setValue]);
const bgColor = useMemo(
() => {
return value !== false ? 'clr-primary' : 'clr-app'
}, [value]);
function handleClick(event: React.MouseEvent<HTMLButtonElement, MouseEvent>): void {
event.preventDefault();
if (disabled || !setValue) {
return;
}
if (value === false) {
setValue(null);
} else {
setValue(!value);
}
}
return (
<button
id={id}
className={`flex items-center [&:not(:first-child)]:mt-3 clr-outline focus:outline-dotted focus:outline-1 ${widthClass}`}
title={tooltip}
disabled={disabled}
onClick={handleClick}
>
<div className={`relative peer w-4 h-4 shrink-0 mt-0.5 border rounded-sm appearance-none ${bgColor} ${cursor}`} />
{ label &&
<Label
className={`${cursor} px-2 text-start`}
text={label}
required={required}
htmlFor={id}
/>}
{value && <CheckboxChecked />}
{value === null && <CheckboxNull />}
</button>
);
}
export default Checkbox;

View File

@ -317,3 +317,27 @@ export function InDoor(props: IconProps) {
</IconSVG> </IconSVG>
); );
} }
export function CheckboxChecked() {
return (
<svg
className='absolute w-3 h-3 mt-1 ml-0.5'
viewBox='0 0 512 512'
fill='#ffffff'
>
<path d='M470.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L192 338.7l233.4-233.3c12.5-12.5 32.8-12.5 45.3 0z' />
</svg>
);
}
export function CheckboxNull() {
return (
<svg
className='absolute w-3 h-3 mt-1 ml-0.5'
viewBox='0 0 512 512'
fill='#ffffff'
>
<path d='M2 7.75A.75.75 0 012.75 7h10a.75.75 0 010 1.5h-10A.75.75 0 012 7.75z' />
</svg>
);
}

View File

@ -140,7 +140,6 @@
.clr-footer, .clr-footer,
.clr-modal-backdrop, .clr-modal-backdrop,
.clr-btn-nav, .clr-btn-nav,
.clr-checkbox,
.clr-input:disabled .clr-input:disabled
) { ) {
background-color: var(--cl-bg-100); background-color: var(--cl-bg-100);
@ -168,8 +167,7 @@
:is(.clr-primary, :is(.clr-primary,
.clr-btn-primary:hover, .clr-btn-primary:hover,
.clr-btn-primary:focus, .clr-btn-primary:focus
.clr-checkbox:checked
) { ) {
color: var(--cl-prim-fg-100); color: var(--cl-prim-fg-100);
background-color: var(--cl-prim-bg-100); background-color: var(--cl-prim-bg-100);

View File

@ -93,7 +93,7 @@ function CreateRSFormPage() {
/> />
<Checkbox id='common' label='Общедоступная схема' <Checkbox id='common' label='Общедоступная схема'
value={common} value={common}
onChange={event => setCommon(event.target.checked)} setValue={value => setCommon(value ?? false)}
/> />
<FileInput id='trs' label='Загрузить из Экстеор' <FileInput id='trs' label='Загрузить из Экстеор'
acceptType='.trs' acceptType='.trs'

View File

@ -36,38 +36,38 @@ function PickerStrategy({ value, onChange }: PickerStrategyProps) {
{ pickerMenu.isActive && { pickerMenu.isActive &&
<Dropdown> <Dropdown>
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.MANUAL)} setValue={() => handleChange(LibraryFilterStrategy.MANUAL)}
value={value === LibraryFilterStrategy.MANUAL} value={value === LibraryFilterStrategy.MANUAL}
label='Отображать все' label='Отображать все'
/> />
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.COMMON)} setValue={() => handleChange(LibraryFilterStrategy.COMMON)}
value={value === LibraryFilterStrategy.COMMON} value={value === LibraryFilterStrategy.COMMON}
label='Общедоступные' label='Общедоступные'
tooltip='Отображать только общедоступные схемы' tooltip='Отображать только общедоступные схемы'
/> />
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.CANONICAL)} setValue={() => handleChange(LibraryFilterStrategy.CANONICAL)}
value={value === LibraryFilterStrategy.CANONICAL} value={value === LibraryFilterStrategy.CANONICAL}
label='Неизменные' label='Неизменные'
tooltip='Отображать только стандартные схемы' tooltip='Отображать только стандартные схемы'
/> />
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.PERSONAL)} setValue={() => handleChange(LibraryFilterStrategy.PERSONAL)}
value={value === LibraryFilterStrategy.PERSONAL} value={value === LibraryFilterStrategy.PERSONAL}
label='Личные' label='Личные'
disabled={!user} disabled={!user}
tooltip='Отображать только подписки и владеемые схемы' tooltip='Отображать только подписки и владеемые схемы'
/> />
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.SUBSCRIBE)} setValue={() => handleChange(LibraryFilterStrategy.SUBSCRIBE)}
value={value === LibraryFilterStrategy.SUBSCRIBE} value={value === LibraryFilterStrategy.SUBSCRIBE}
label='Подписки' label='Подписки'
disabled={!user} disabled={!user}
tooltip='Отображать только подписки' tooltip='Отображать только подписки'
/> />
<DropdownCheckbox <DropdownCheckbox
onChange={() => handleChange(LibraryFilterStrategy.OWNED)} setValue={() => handleChange(LibraryFilterStrategy.OWNED)}
value={value === LibraryFilterStrategy.OWNED} value={value === LibraryFilterStrategy.OWNED}
disabled={!user} disabled={!user}
label='Я - Владелец!' label='Я - Владелец!'

View File

@ -79,7 +79,7 @@ function DlgCloneRSForm({ hideWindow }: DlgCloneRSFormProps) {
/> />
<Checkbox id='common' label='Общедоступная схема' <Checkbox id='common' label='Общедоступная схема'
value={common} value={common}
onChange={event => setCommon(event.target.checked)} setValue={value => setCommon(value)}
/> />
</Modal> </Modal>
); );

View File

@ -52,7 +52,7 @@ function DlgDeleteCst({ hideWindow, selected, onDelete }: DlgDeleteCstProps) {
<Checkbox <Checkbox
label='Удалить зависимые конституенты' label='Удалить зависимые конституенты'
value={expandOut} value={expandOut}
onChange={data => setExpandOut(data.target.checked)} setValue={value => setExpandOut(value)}
/> />
</div> </div>
</Modal> </Modal>

View File

@ -81,25 +81,25 @@ function DlgGraphOptions({ hideWindow, initial, onConfirm }:DlgGraphOptionsProps
label='Скрыть текст' label='Скрыть текст'
tooltip='Не отображать термины' tooltip='Не отображать термины'
value={noTerms} value={noTerms}
onChange={ event => setNoTerms(event.target.checked) } setValue={ value => setNoTerms(value) }
/> />
<Checkbox <Checkbox
label='Скрыть несвязанные' label='Скрыть несвязанные'
tooltip='Неиспользуемые конституенты' tooltip='Неиспользуемые конституенты'
value={noHermits} value={noHermits}
onChange={ event => setNoHermits(event.target.checked) } setValue={ value => setNoHermits(value) }
/> />
<Checkbox <Checkbox
label='Скрыть шаблоны' label='Скрыть шаблоны'
tooltip='Терм-функции и предикат-функции с параметризованными аргументами' tooltip='Терм-функции и предикат-функции с параметризованными аргументами'
value={noTemplates} value={noTemplates}
onChange={ event => setNoTemplates(event.target.checked) } setValue={ value => setNoTemplates(value) }
/> />
<Checkbox <Checkbox
label='Транзитивная редукция' label='Транзитивная редукция'
tooltip='Удалить связи, образующие транзитивные пути в графе' tooltip='Удалить связи, образующие транзитивные пути в графе'
value={noTransitive} value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) } setValue={ value => setNoTransitive(value) }
/> />
</div> </div>
<div className='flex flex-col'> <div className='flex flex-col'>
@ -107,42 +107,42 @@ function DlgGraphOptions({ hideWindow, initial, onConfirm }:DlgGraphOptionsProps
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.BASE)} label={getCstTypeLabel(CstType.BASE)}
value={allowBase} value={allowBase}
onChange={ event => setAllowBase(event.target.checked) } setValue={ value => setAllowBase(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.STRUCTURED)} label={getCstTypeLabel(CstType.STRUCTURED)}
value={allowStruct} value={allowStruct}
onChange={ event => setAllowStruct(event.target.checked) } setValue={ value => setAllowStruct(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.TERM)} label={getCstTypeLabel(CstType.TERM)}
value={allowTerm} value={allowTerm}
onChange={ event => setAllowTerm(event.target.checked) } setValue={ value => setAllowTerm(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.AXIOM)} label={getCstTypeLabel(CstType.AXIOM)}
value={allowAxiom} value={allowAxiom}
onChange={ event => setAllowAxiom(event.target.checked) } setValue={ value => setAllowAxiom(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.FUNCTION)} label={getCstTypeLabel(CstType.FUNCTION)}
value={allowFunction} value={allowFunction}
onChange={ event => setAllowFunction(event.target.checked) } setValue={ value => setAllowFunction(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.PREDICATE)} label={getCstTypeLabel(CstType.PREDICATE)}
value={allowPredicate} value={allowPredicate}
onChange={ event => setAllowPredicate(event.target.checked) } setValue={ value => setAllowPredicate(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.CONSTANT)} label={getCstTypeLabel(CstType.CONSTANT)}
value={allowConstant} value={allowConstant}
onChange={ event => setAllowConstant(event.target.checked) } setValue={ value => setAllowConstant(value) }
/> />
<Checkbox <Checkbox
label={getCstTypeLabel(CstType.THEOREM)} label={getCstTypeLabel(CstType.THEOREM)}
value={allowTheorem} value={allowTheorem}
onChange={ event => setAllowTheorem(event.target.checked) } setValue ={ value => setAllowTheorem(value) }
/> />
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ function DlgUploadRSForm({ hideWindow }: DlgUploadRSFormProps) {
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitText='Загрузить' submitText='Загрузить'
> >
<div className='max-w-[20rem]'> <div className='flex flex-col items-center'>
<FileInput <FileInput
label='Выбрать файл' label='Выбрать файл'
acceptType='.trs' acceptType='.trs'
@ -53,7 +53,8 @@ function DlgUploadRSForm({ hideWindow }: DlgUploadRSFormProps) {
<Checkbox <Checkbox
label='Загружать название и комментарий' label='Загружать название и комментарий'
value={loadMetadata} value={loadMetadata}
onChange={event => setLoadMetadata(event.target.checked)} setValue={value => setLoadMetadata(value)}
widthClass='w-fit pb-2'
/> />
</div> </div>
</Modal> </Modal>

View File

@ -136,14 +136,14 @@ function EditorRSForm({ onDestroy, onClaim, onShare, isModified, setIsModified,
value={common} value={common}
widthClass='w-fit mt-3' widthClass='w-fit mt-3'
disabled={!isEditable} disabled={!isEditable}
onChange={event => setCommon(event.target.checked)} setValue={value => setCommon(value)}
/> />
<Checkbox id='canonical' label='Неизменная схема' <Checkbox id='canonical' label='Неизменная схема'
widthClass='w-fit' widthClass='w-fit'
value={canonical} value={canonical}
tooltip='Только администраторы могут присваивать схемам неизменный статус' tooltip='Только администраторы могут присваивать схемам неизменный статус'
disabled={!isEditable || !isForceAdmin} disabled={!isEditable || !isForceAdmin}
onChange={event => setCanonical(event.target.checked)} setValue={value => setCanonical(value)}
/> />
</div> </div>

View File

@ -415,18 +415,18 @@ function EditorTermGraph({ onOpenEdit, onCreateCst, onDeleteCst }: EditorTermGra
<Checkbox <Checkbox
label='Скрыть текст' label='Скрыть текст'
value={noTerms} value={noTerms}
onChange={ event => setNoTerms(event.target.checked) } setValue={ value => setNoTerms(value) }
/> />
<Checkbox <Checkbox
label='Транзитивная редукция' label='Транзитивная редукция'
value={noTransitive} value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) } setValue={ value => setNoTransitive(value) }
/> />
<Checkbox <Checkbox
disabled={!is3D} disabled={!is3D}
label='Анимация вращения' label='Анимация вращения'
value={orbit} value={orbit}
onChange={ event => setOrbit(event.target.checked) } setValue={ value => setOrbit(value) }
/> />
<Divider margins='mt-3 mb-2' /> <Divider margins='mt-3 mb-2' />

View File

@ -146,14 +146,14 @@ function RSTabsMenu({
{(isOwned || user?.is_staff) && {(isOwned || user?.is_staff) &&
<DropdownCheckbox <DropdownCheckbox
value={isReadonly} value={isReadonly}
onChange={toggleReadonly} setValue={toggleReadonly}
label='Я — читатель!' label='Я — читатель!'
tooltip='Режим чтения' tooltip='Режим чтения'
/>} />}
{user?.is_staff && {user?.is_staff &&
<DropdownCheckbox <DropdownCheckbox
value={isForceAdmin} value={isForceAdmin}
onChange={toggleForceAdmin} setValue={toggleForceAdmin}
label='Я — администратор!' label='Я — администратор!'
tooltip='Режим редактирования для администраторов' tooltip='Режим редактирования для администраторов'
/>} />}