mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-14 03:43:50 +00:00
refactor: migrate UI components and remove chakra
migrate pin page migrate copy tag migrate pin input migrate dropdown menus migrate radio buttons migrate switch migrate select migrate textarea migrate colour picker migrate select migrate context menu migrate viewparams remove chakra
This commit is contained in:
committed by
Carlos Valente
parent
4d359445a7
commit
e1e8410ba4
@@ -1,8 +0,0 @@
|
||||
.contextMenuButton {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.contextMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
// logic (with some modifications) culled from:
|
||||
// https://github.com/lukasbach/chakra-ui-contextmenu/blob/main/src/ContextMenu.tsx
|
||||
|
||||
import { ReactElement } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Menu, MenuButton, MenuGroup, MenuList } from '@chakra-ui/react';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { ContextMenuOption } from './ContextMenuOption';
|
||||
|
||||
import style from './ContextMenu.module.scss';
|
||||
|
||||
type ContextMenuCoords = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type OptionWithoutGroup = {
|
||||
label: string;
|
||||
isDisabled?: boolean;
|
||||
icon: IconType;
|
||||
onClick: () => void;
|
||||
withDivider?: boolean;
|
||||
};
|
||||
|
||||
type OptionWithGroup = {
|
||||
label: string;
|
||||
group: Omit<OptionWithoutGroup, 'isGroup'>[];
|
||||
};
|
||||
|
||||
export type Option = OptionWithoutGroup | OptionWithGroup;
|
||||
|
||||
const isOptionWithGroup = (option: Option): option is OptionWithGroup => 'group' in option;
|
||||
|
||||
type ContextMenuStore = {
|
||||
coords: ContextMenuCoords;
|
||||
options: Option[];
|
||||
isOpen: boolean;
|
||||
setContextMenu: (coords: ContextMenuCoords, options: Option[]) => void;
|
||||
setIsOpen: (newIsOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const useContextMenuStore = create<ContextMenuStore>((set) => ({
|
||||
coords: { x: 0, y: 0 },
|
||||
options: [],
|
||||
isOpen: false,
|
||||
setContextMenu: (coords, options) => set(() => ({ coords, options, isOpen: true })),
|
||||
setIsOpen: (newIsOpen) => set(() => ({ isOpen: newIsOpen })),
|
||||
}));
|
||||
|
||||
interface ContextMenuProps {
|
||||
// ReactElement type required due to early `return` (line 51) returning {children}
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export const ContextMenu = ({ children }: ContextMenuProps) => {
|
||||
const { coords, options, isOpen, setIsOpen } = useContextMenuStore();
|
||||
|
||||
const onClose = () => {
|
||||
return setIsOpen(false);
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return children;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<div className={style.contextMenuBackdrop} />
|
||||
<Menu isOpen size='sm' gutter={0} onClose={onClose} isLazy lazyBehavior='unmount' variant='ontime-on-dark'>
|
||||
<MenuButton
|
||||
className={style.contextMenuButton}
|
||||
aria-hidden
|
||||
w={1}
|
||||
h={1}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: coords.x,
|
||||
top: coords.y,
|
||||
}}
|
||||
/>
|
||||
<MenuList>
|
||||
{options.map((option) =>
|
||||
isOptionWithGroup(option) ? (
|
||||
<MenuGroup key={option.label} title={option.label}>
|
||||
{option.group.map((groupOption) => (
|
||||
<ContextMenuOption key={groupOption.label} {...groupOption} />
|
||||
))}
|
||||
</MenuGroup>
|
||||
) : (
|
||||
<ContextMenuOption key={option.label} {...option} />
|
||||
),
|
||||
)}
|
||||
</MenuList>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import { MenuDivider, MenuItem } from '@chakra-ui/react';
|
||||
|
||||
import { OptionWithoutGroup } from './ContextMenu';
|
||||
|
||||
export const ContextMenuOption = ({ label, onClick, isDisabled, icon: Icon, withDivider }: OptionWithoutGroup) => (
|
||||
<>
|
||||
{withDivider && <MenuDivider />}
|
||||
<MenuItem icon={<Icon style={{ fontSize: '1rem' }} />} onClick={onClick} isDisabled={isDisabled}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,40 @@
|
||||
.copytag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
border-radius: 3px 0 0 3px;
|
||||
border: 1px solid $gray-1200;
|
||||
background-color: $white-3;
|
||||
padding-inline: 0.5rem 1rem;
|
||||
line-height: 1em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 1.5rem;
|
||||
font-size: calc(1rem - 3px);
|
||||
}
|
||||
|
||||
.medium {
|
||||
height: 2rem;
|
||||
font-size: calc(1rem - 2px);
|
||||
}
|
||||
|
||||
.large {
|
||||
height: 2.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.copy {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
@@ -1,22 +1,28 @@
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IoCopy } from 'react-icons/io5';
|
||||
import { Button, ButtonGroup, IconButton, Tooltip } from '@chakra-ui/react';
|
||||
|
||||
import { tooltipDelayFast } from '../../../ontimeConfig';
|
||||
import { Size } from '../../models/Util.type';
|
||||
import copyToClipboard from '../../utils/copyToClipboard';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import Button from '../buttons/Button';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
|
||||
import style from './CopyTag.module.scss';
|
||||
|
||||
interface CopyTagProps {
|
||||
copyValue: string;
|
||||
label: string;
|
||||
size?: Size;
|
||||
disabled?: boolean;
|
||||
onClick?: () => void;
|
||||
size?: 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
const { copyValue, label, size = 'xs', disabled, children, onClick } = props;
|
||||
export default function CopyTag({
|
||||
copyValue,
|
||||
disabled,
|
||||
size = 'medium',
|
||||
children,
|
||||
onClick,
|
||||
}: PropsWithChildren<CopyTagProps>) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleClick = () => {
|
||||
@@ -28,20 +34,24 @@ export default function CopyTag(props: PropsWithChildren<CopyTagProps>) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip label={label} openDelay={tooltipDelayFast}>
|
||||
<ButtonGroup size={size} isAttached>
|
||||
<Button variant='ontime-subtle' tabIndex={-1} onClick={onClick} isDisabled={disabled}>
|
||||
<div className={style.copytag}>
|
||||
{onClick !== undefined ? (
|
||||
<Button className={style.action} size={size} tabIndex={-1} onClick={onClick} disabled={disabled}>
|
||||
{children}
|
||||
</Button>
|
||||
<IconButton
|
||||
aria-label={label}
|
||||
icon={copied ? <IoCheckmark /> : <IoCopy />}
|
||||
variant='ontime-filled'
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</ButtonGroup>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className={cx([style.label, style[size]])}>{children}</div>
|
||||
)}
|
||||
<IconButton
|
||||
className={style.copy}
|
||||
variant='primary'
|
||||
size={size}
|
||||
tabIndex={-1}
|
||||
onClick={handleClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{copied ? <IoCheckmark /> : <IoCopy />}
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
|
||||
z-index: $zindex-dialog;
|
||||
transform: translateX(-50%);
|
||||
|
||||
padding-inline: 1rem;
|
||||
@@ -19,7 +18,6 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $zindex-backdrop;
|
||||
background-color: $backdrop-color;
|
||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PropsWithChildren, ReactNode } from 'react';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IconType } from 'react-icons';
|
||||
import { Menu as BaseMenu } from '@base-ui-components/react/menu';
|
||||
|
||||
import style from './DropdownMenu.module.scss';
|
||||
@@ -7,13 +8,15 @@ type DropdownMenuItemDivider = { type: 'divider' };
|
||||
type DropdownMenuItem = {
|
||||
type: 'item';
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
icon?: IconType;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export type DropdownMenuOption = DropdownMenuItemDivider | DropdownMenuItem;
|
||||
|
||||
interface DropdownMenuProps extends BaseMenu.Trigger.Props {
|
||||
items: Array<DropdownMenuItemDivider | DropdownMenuItem>;
|
||||
items: DropdownMenuOption[];
|
||||
}
|
||||
|
||||
export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChildren<DropdownMenuProps>) {
|
||||
@@ -29,7 +32,8 @@ export function DropdownMenu({ items, children, ...triggerProps }: PropsWithChil
|
||||
}
|
||||
return (
|
||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||
{item.icon} {item.label}
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
})}
|
||||
@@ -55,12 +59,9 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<BaseMenu.Trigger
|
||||
style={{ position: 'absolute', left: position.x, top: position.y, pointerEvents: 'none' }}
|
||||
aria-hidden
|
||||
/>
|
||||
<BaseMenu.Trigger style={{ position: 'fixed', left: position.x, top: position.y }} aria-hidden />
|
||||
<BaseMenu.Portal>
|
||||
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8}>
|
||||
<BaseMenu.Positioner className={style.positioner} align='start' sideOffset={8} alignOffset={8}>
|
||||
<BaseMenu.Popup className={style.popup}>
|
||||
{items.map((item, index) => {
|
||||
if (item.type === 'divider') {
|
||||
@@ -68,7 +69,8 @@ export function PositionedDropdownMenu({ items, isOpen, position, onClose }: Pos
|
||||
}
|
||||
return (
|
||||
<BaseMenu.Item key={index} className={style.item} onClick={item.onClick} disabled={item.disabled}>
|
||||
{item.icon} {item.label}
|
||||
{item.icon && <item.icon />}
|
||||
{item.label}
|
||||
</BaseMenu.Item>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -15,7 +15,6 @@
|
||||
display: grid;
|
||||
place-content: center;
|
||||
text-align: center;
|
||||
z-index: $zindex-modal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
import { Textarea, TextareaProps } from '@chakra-ui/react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
export const AutoTextArea = (props: TextareaProps & { inputref: RefObject<unknown> }) => {
|
||||
const { value, inputref } = props;
|
||||
|
||||
useEffect(() => {
|
||||
const node = inputref.current;
|
||||
autosize(inputref.current);
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, [inputref, value]);
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
overflow='hidden'
|
||||
w='100%'
|
||||
ref={inputref}
|
||||
resize='none'
|
||||
transition='height none'
|
||||
variant='ontime-transparent'
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { RefObject, useEffect } from 'react';
|
||||
// @ts-expect-error no types from library
|
||||
import autosize from 'autosize/dist/autosize';
|
||||
|
||||
import Textarea, { type TextareaProps } from '../textarea/Textarea';
|
||||
|
||||
interface AutoTextAreaProps extends TextareaProps {
|
||||
inputref: RefObject<HTMLTextAreaElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A textarea that automatically resizes based on its content
|
||||
*/
|
||||
export function AutoTextarea({ value, inputref, ...textAreaProps }: AutoTextAreaProps) {
|
||||
// when the value changes, we use the ref to reapply autosize
|
||||
useEffect(() => {
|
||||
const node = inputref.current;
|
||||
autosize(inputref.current);
|
||||
|
||||
return () => {
|
||||
autosize.destroy(node);
|
||||
};
|
||||
}, [inputref, value]);
|
||||
|
||||
return <Textarea ref={inputref} value={value} {...textAreaProps} />;
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useController, UseControllerProps } from 'react-hook-form';
|
||||
import { IoEyedrop } from 'react-icons/io5';
|
||||
import { useDebouncedCallback } from '@mantine/hooks';
|
||||
import { ViewSettings } from 'ontime-types';
|
||||
|
||||
import { debounce } from '../../../utils/debounce';
|
||||
import { cx, getAccessibleColour } from '../../../utils/styleUtils';
|
||||
import PopoverPicker from '../popover-picker/PopoverPicker';
|
||||
|
||||
@@ -19,12 +18,9 @@ interface SwatchPickerProps {
|
||||
export default function SwatchPicker(props: SwatchPickerProps) {
|
||||
const { color, onChange, isSelected, alwaysDisplayColor } = props;
|
||||
|
||||
const debouncedOnChange = useCallback(
|
||||
debounce((newValue: string) => {
|
||||
onChange(newValue);
|
||||
}, 500),
|
||||
[onChange],
|
||||
);
|
||||
const debouncedOnChange = useDebouncedCallback((newValue: string) => {
|
||||
onChange(newValue);
|
||||
}, 100);
|
||||
|
||||
const displayColor = alwaysDisplayColor || isSelected ? color : '';
|
||||
const { color: iconColor } = getAccessibleColour(displayColor);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.radioGroup {
|
||||
color: $gray-900;
|
||||
font-size: calc(1rem - 3px);
|
||||
color: $label-gray;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
line-height: 1.2em;
|
||||
|
||||
&:has([data-checked]) {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
outline: 0;
|
||||
border: none;
|
||||
|
||||
&[data-unchecked] {
|
||||
background-color: $gray-1200;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $gray-1200;
|
||||
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&[data-unchecked] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
border-radius: 100%;
|
||||
width: 0.5em;
|
||||
height: 0.5em;
|
||||
background-color: $blue-500;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Radio } from '@base-ui-components/react/radio';
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
||||
|
||||
import style from './BlockRadio.module.scss';
|
||||
|
||||
interface BlockRadioProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
|
||||
items: {
|
||||
value: T;
|
||||
label: string;
|
||||
}[];
|
||||
onValueChange?: (value: T) => void;
|
||||
}
|
||||
|
||||
export default function BlockRadio<T extends string | number | boolean>({
|
||||
items,
|
||||
onValueChange,
|
||||
...elementProps
|
||||
}: BlockRadioProps<T>) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
onValueChange={(value) => onValueChange?.(value as T)}
|
||||
className={style.radioGroup}
|
||||
{...elementProps}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<label className={style.item} key={item.value.toString()}>
|
||||
<Radio.Root value={item.value.toString()} className={style.radio}>
|
||||
<Radio.Indicator className={style.indicator} />
|
||||
</Radio.Root>
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</BaseRadioGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,12 @@
|
||||
$input-font-size: 15px;
|
||||
|
||||
.delayInput {
|
||||
display: flex;
|
||||
gap: $element-spacing;
|
||||
align-items: center;
|
||||
font-size: $text-body-size;
|
||||
|
||||
|
||||
.inputField {
|
||||
font-size: $input-font-size;
|
||||
letter-spacing: 0.5px;
|
||||
max-width: 7em;
|
||||
padding-left: 16px;
|
||||
color: $ontime-delay-text
|
||||
}
|
||||
}
|
||||
|
||||
.delayOptions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.inputField {
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
max-width: 7em;
|
||||
color: $ontime-delay-text
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { KeyboardEvent, useEffect, useRef, useState } from 'react';
|
||||
import { Input, Radio, RadioGroup } from '@chakra-ui/react';
|
||||
import { millisToString, parseUserTime } from 'ontime-utils';
|
||||
|
||||
import { useEntryActions } from '../../../hooks/useEntryAction';
|
||||
import Input from '../input/Input';
|
||||
|
||||
import BlockRadio from './BlockRadio';
|
||||
|
||||
import style from './DelayInput.module.scss';
|
||||
|
||||
@@ -105,13 +107,10 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
return (
|
||||
<div className={style.delayInput}>
|
||||
<Input
|
||||
size='sm'
|
||||
ref={inputRef}
|
||||
data-testid='delay-input'
|
||||
className={style.inputField}
|
||||
type='text'
|
||||
placeholder='-'
|
||||
variant='ontime-filled'
|
||||
onFocus={handleFocus}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={(event) => validateAndSubmit(event.target.value)}
|
||||
@@ -119,16 +118,14 @@ export default function DelayInput(props: DelayInputProps) {
|
||||
value={value}
|
||||
maxLength={9}
|
||||
/>
|
||||
<RadioGroup
|
||||
className={style.delayOptions}
|
||||
onChange={handleSlipChange}
|
||||
<BlockRadio
|
||||
onValueChange={handleSlipChange}
|
||||
value={checkedOption}
|
||||
variant='ontime-block'
|
||||
size='sm'
|
||||
>
|
||||
<Radio value='add'>Add time</Radio>
|
||||
<Radio value='subtract'>Subtract time</Radio>
|
||||
</RadioGroup>
|
||||
items={[
|
||||
{ value: 'add', label: 'Add time' },
|
||||
{ value: 'subtract', label: 'Subtract time' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
// styles from Input.module.scss
|
||||
.input {
|
||||
color: $gray-200;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background-color: $gray-1200;
|
||||
padding: 0 0.5rem;
|
||||
width: 100%;
|
||||
margin-top: 0.25rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
font-size: 1rem;
|
||||
font-weight: 400;
|
||||
color: $gray-200;
|
||||
border-radius: $component-border-radius-md;
|
||||
background-color: $gray-1200;
|
||||
border: 1px solid transparent;
|
||||
|
||||
height: 2rem;
|
||||
padding-inline: 0.5em;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
&:hover:not(:disabled) {
|
||||
background-color: $gray-1100;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
&:focus:not(:read-only) {
|
||||
background-color: $gray-1000;
|
||||
color: $gray-50;
|
||||
border: 1px solid $blue-500;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: $gray-500;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { HexAlphaColorPicker, HexColorInput } from 'react-colorful';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@chakra-ui/react';
|
||||
import { Popover } from '@base-ui-components/react/popover';
|
||||
|
||||
import PopoverContents from '../../popover/Popover';
|
||||
|
||||
import style from './PopoverPicker.module.scss';
|
||||
|
||||
@@ -9,15 +11,14 @@ interface PopoverPickerProps {
|
||||
onChange: (color: string) => void;
|
||||
}
|
||||
|
||||
export default function PopoverPicker(props: PropsWithChildren<PopoverPickerProps>) {
|
||||
const { color, onChange, children } = props;
|
||||
export default function PopoverPicker({ color, onChange, children }: PropsWithChildren<PopoverPickerProps>) {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>{children}</PopoverTrigger>
|
||||
<PopoverContent className={style.small} style={{ borderRadius: '9px', width: 'auto' }}>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>{children}</Popover.Trigger>
|
||||
<PopoverContents>
|
||||
<HexAlphaColorPicker color={color} onChange={onChange} />
|
||||
<HexColorInput color={color} onChange={onChange} className={style.input} prefixed />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</PopoverContents>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface UseReactiveTextInputReturn {
|
||||
export default function useReactiveTextInput(
|
||||
initialText: string,
|
||||
submitCallback: (newValue: string) => void,
|
||||
ref: RefObject<HTMLInputElement>,
|
||||
ref: RefObject<HTMLInputElement | HTMLTextAreaElement>,
|
||||
options?: {
|
||||
submitOnEnter?: boolean;
|
||||
submitOnCtrlEnter?: boolean;
|
||||
@@ -101,25 +101,31 @@ export default function useReactiveTextInput(
|
||||
];
|
||||
|
||||
if (options?.submitOnEnter) {
|
||||
hotKeys.push(['Enter', () => {
|
||||
isKeyboardSubmitting.current = true;
|
||||
handleSubmit(text);
|
||||
// clear flag after blur has been processed
|
||||
setTimeout(() => {
|
||||
isKeyboardSubmitting.current = false;
|
||||
}, 0);
|
||||
}]);
|
||||
hotKeys.push([
|
||||
'Enter',
|
||||
() => {
|
||||
isKeyboardSubmitting.current = true;
|
||||
handleSubmit(text);
|
||||
// clear flag after blur has been processed
|
||||
setTimeout(() => {
|
||||
isKeyboardSubmitting.current = false;
|
||||
}, 0);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
if (options?.submitOnCtrlEnter) {
|
||||
hotKeys.push(['mod + Enter', () => {
|
||||
isKeyboardSubmitting.current = true;
|
||||
handleSubmit(text);
|
||||
// clear flag after blur has been processed
|
||||
setTimeout(() => {
|
||||
isKeyboardSubmitting.current = false;
|
||||
}, 0);
|
||||
}]);
|
||||
hotKeys.push([
|
||||
'mod + Enter',
|
||||
() => {
|
||||
isKeyboardSubmitting.current = true;
|
||||
handleSubmit(text);
|
||||
// clear flag after blur has been processed
|
||||
setTimeout(() => {
|
||||
isKeyboardSubmitting.current = false;
|
||||
}, 0);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
const hotKeyHandler = getHotkeyHandler(hotKeys);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
@use '../../../../theme/viewerDefs' as *;
|
||||
|
||||
.textarea {
|
||||
box-sizing: border-box;
|
||||
min-height: 2rem;
|
||||
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $zindex-backdrop;
|
||||
background-color: $backdrop-color;
|
||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
|
||||
@@ -21,7 +20,6 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: $zindex-dialog;
|
||||
|
||||
width: 22rem;
|
||||
height: 100vh;
|
||||
|
||||
+1
-6
@@ -30,12 +30,7 @@ export default function OtherAddresses({ currentLocation }: OtherAddressesProps)
|
||||
const address = linkToOtherHost(nif.address, currentLocation);
|
||||
|
||||
return (
|
||||
<CopyTag
|
||||
key={nif.name}
|
||||
copyValue={address}
|
||||
onClick={() => openLink(address)}
|
||||
label='Copy IP or navigate to address'
|
||||
>
|
||||
<CopyTag key={nif.name} copyValue={address} onClick={() => openLink(address)}>
|
||||
{nif.address} <IoArrowUp className={style.goIcon} />
|
||||
</CopyTag>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-size: 3rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pin {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
input {
|
||||
font-size: 4rem;
|
||||
height: 4rem;
|
||||
width: 4em;
|
||||
text-align: center;
|
||||
letter-spacing: 0.25em;
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
font-size: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pinFailed {
|
||||
input {
|
||||
animation: redFlash 1.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes redFlash {
|
||||
from {
|
||||
background: $red-500;
|
||||
}
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,55 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PropsWithChildren, useState } from 'react';
|
||||
import { IoCheckmark } from 'react-icons/io5';
|
||||
import { IconButton, PinInput, PinInputField } from '@chakra-ui/react';
|
||||
|
||||
import style from './ProtectRoute.module.scss';
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
import IconButton from '../buttons/IconButton';
|
||||
import Input from '../input/input/Input';
|
||||
|
||||
import style from './PinPage.module.scss';
|
||||
|
||||
interface PinPageProps {
|
||||
permission: 'editor' | 'operator';
|
||||
handleValidation: (pin: string) => boolean;
|
||||
}
|
||||
|
||||
export default function PinPage(props: PinPageProps) {
|
||||
const { permission, handleValidation } = props;
|
||||
export default function PinPage({ permission, handleValidation }: PropsWithChildren<PinPageProps>) {
|
||||
const [pin, setPin] = useState('');
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const validate = () => {
|
||||
const isValid = handleValidation(pin);
|
||||
if (!isValid) {
|
||||
setFailed(true);
|
||||
setPin('');
|
||||
}
|
||||
}, [handleValidation, pin]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.repeat) return;
|
||||
if (event.key === 'Enter') {
|
||||
validate();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyPress);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyPress);
|
||||
};
|
||||
}, [validate]);
|
||||
const handleInputChange = (value: string) => {
|
||||
setPin(value);
|
||||
if (failed) setFailed(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={style.container}>
|
||||
{`Ontime ${permission}`}
|
||||
<div className={failed ? style.pin__failed : style.pin}>
|
||||
<PinInput
|
||||
type='alphanumeric'
|
||||
size='lg'
|
||||
mask
|
||||
autoFocus
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
validate();
|
||||
}}
|
||||
className={cx([style.pin, failed && style.pinFailed])}
|
||||
>
|
||||
<Input
|
||||
type='password'
|
||||
maxLength={4}
|
||||
height='large'
|
||||
value={pin}
|
||||
onChange={(value) => {
|
||||
setFailed(false);
|
||||
setPin(value);
|
||||
}}
|
||||
>
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
<PinInputField />
|
||||
</PinInput>
|
||||
<IconButton
|
||||
variant='ontime-filled'
|
||||
aria-label='Enter'
|
||||
size='lg'
|
||||
isRound
|
||||
icon={<IoCheckmark />}
|
||||
onClick={validate}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<IconButton type='submit' variant='primary' aria-label='Enter'>
|
||||
<IoCheckmark />
|
||||
</IconButton>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-top: 25vh;
|
||||
|
||||
background: $ui-black;
|
||||
color: $ontime-color;
|
||||
font-weight: 200;
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.pin,
|
||||
.pin__failed {
|
||||
display: flex;
|
||||
gap: 0.125em;
|
||||
padding-block: 0.5em;
|
||||
|
||||
input {
|
||||
border-radius: 99px;
|
||||
border-color: $gray-500;
|
||||
|
||||
&:hover {
|
||||
border-color: $blue-500;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.pin__failed {
|
||||
input {
|
||||
animation: colourFade 1.5s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes colourFade {
|
||||
from {
|
||||
background: $red-500;
|
||||
}
|
||||
to {
|
||||
background: rgba($red-500, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
.radioGroup {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
color: $gray-900;
|
||||
font-size: calc(1rem - 2px);
|
||||
color: $ui-white;
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vertical {
|
||||
align-items: start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
&:has([data-checked]) {
|
||||
color: $ui-white;
|
||||
}
|
||||
}
|
||||
|
||||
.radio {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 100%;
|
||||
outline: 0;
|
||||
border: none;
|
||||
|
||||
&[data-unchecked] {
|
||||
border: 1px solid $gray-1200;
|
||||
background-color: $gray-1200;
|
||||
}
|
||||
|
||||
&[data-checked] {
|
||||
background-color: $ui-white;
|
||||
&:hover {
|
||||
border-color: $gray-1000;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.indicator {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
&[data-unchecked] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
border-radius: 100%;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
background-color: $gray-1200;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Radio } from '@base-ui-components/react/radio';
|
||||
import { RadioGroup as BaseRadioGroup } from '@base-ui-components/react/radio-group';
|
||||
|
||||
import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import style from './RadioGroup.module.scss';
|
||||
|
||||
interface RadioGroupProps<T extends string | number | boolean> extends Omit<BaseRadioGroup.Props, 'onValueChange'> {
|
||||
items: {
|
||||
value: T;
|
||||
label: string;
|
||||
}[];
|
||||
onValueChange?: (value: T) => void;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export default function RadioGroup<T extends string | number | boolean>({
|
||||
items,
|
||||
className,
|
||||
orientation = 'vertical',
|
||||
onValueChange,
|
||||
...elementProps
|
||||
}: RadioGroupProps<T>) {
|
||||
return (
|
||||
<BaseRadioGroup
|
||||
onValueChange={(value) => onValueChange?.(value as T)}
|
||||
className={cx([style.radioGroup, style[orientation], className])}
|
||||
{...elementProps}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<label className={style.item} key={item.value.toString()}>
|
||||
<Radio.Root value={item.value.toString()} className={style.radio}>
|
||||
<Radio.Indicator className={style.indicator} />
|
||||
</Radio.Root>
|
||||
{item.label}
|
||||
</label>
|
||||
))}
|
||||
</BaseRadioGroup>
|
||||
);
|
||||
}
|
||||
@@ -38,10 +38,18 @@
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
&.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
.medium {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.large {
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.fluid {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selectIcon {
|
||||
|
||||
@@ -6,20 +6,22 @@ import { cx } from '../../utils/styleUtils';
|
||||
|
||||
import styles from './Select.module.scss';
|
||||
|
||||
export type SelectOption<T = string> = {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
interface SelectProps<T> extends Omit<BaseSelect.Root.Props<T>, 'items'> {
|
||||
// overload items to not allow undefined values
|
||||
options: {
|
||||
value: T;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}[];
|
||||
options: SelectOption<T>[];
|
||||
fluid?: boolean;
|
||||
size?: 'medium' | 'large';
|
||||
}
|
||||
|
||||
export default function Select<T>({ options, fluid, ...selectRootProps }: SelectProps<T>) {
|
||||
export default function Select<T>({ options, fluid, size = 'medium', ...selectRootProps }: SelectProps<T>) {
|
||||
return (
|
||||
<BaseSelect.Root items={options} {...selectRootProps}>
|
||||
<BaseSelect.Trigger className={cx([styles.select, fluid && styles.fluid])}>
|
||||
<BaseSelect.Trigger className={cx([styles.select, styles[size], fluid && styles.fluid])}>
|
||||
<BaseSelect.Value />
|
||||
<BaseSelect.Icon className={styles.selectIcon}>
|
||||
<LuChevronsUpDown />
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: $blue-500;
|
||||
outline: 2px solid $blue-500;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
.inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
// styles from subtle button
|
||||
.toggleSelect {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding-inline: 0.5rem;
|
||||
height: 2.5rem;
|
||||
background: $gray-1050;
|
||||
color: $ui-white;
|
||||
line-height: 1em;
|
||||
border-radius: $component-border-radius-md;
|
||||
|
||||
&:hover:not(:disabled):not(:active) {
|
||||
background: $gray-1000;
|
||||
color: $blue-500;
|
||||
}
|
||||
|
||||
&:active:not(:disabled) {
|
||||
background: $gray-1100;
|
||||
border-color: $gray-1250;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
margin-left: 0.25rem;
|
||||
width: 0.75em;
|
||||
height: 0.75em;
|
||||
background: var(--user-bg);
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: $ui-white;
|
||||
}
|
||||
@@ -1,25 +1,17 @@
|
||||
import { useState } from 'react';
|
||||
import { IoChevronDown } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftElement,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuItemOption,
|
||||
MenuList,
|
||||
MenuOptionGroup,
|
||||
Select,
|
||||
Switch,
|
||||
} from '@chakra-ui/react';
|
||||
|
||||
import { isStringBoolean } from '../../../features/viewers/common/viewUtils';
|
||||
import Checkbox from '../checkbox/Checkbox';
|
||||
import Input from '../input/input/Input';
|
||||
import Select from '../select/Select';
|
||||
import Switch from '../switch/Switch';
|
||||
|
||||
import InlineColourPicker from './InlineColourPicker';
|
||||
import { ParamField } from './viewParams.types';
|
||||
|
||||
import style from './ParamInput.module.scss';
|
||||
|
||||
interface ParamInputProps {
|
||||
paramField: ParamField;
|
||||
}
|
||||
@@ -39,20 +31,11 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
const optionFromParams = searchParams.get(id);
|
||||
const defaultOptionValue = optionFromParams || defaultValue;
|
||||
|
||||
return (
|
||||
<Select
|
||||
placeholder={defaultValue ? undefined : 'Select an option'}
|
||||
variant='ontime'
|
||||
name={id}
|
||||
defaultValue={defaultOptionValue}
|
||||
>
|
||||
{Object.entries(paramField.values).map(([key, value]) => (
|
||||
<option key={key} value={key}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
if (paramField.values.length === 0) {
|
||||
return <span className={style.empty}>No options available</span>;
|
||||
}
|
||||
|
||||
return <Select size='large' name={id} defaultValue={defaultOptionValue} options={paramField.values} />;
|
||||
}
|
||||
|
||||
if (type === 'multi-option') {
|
||||
@@ -61,27 +44,22 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
|
||||
if (type === 'boolean') {
|
||||
const defaultCheckedValue = isStringBoolean(searchParams.get(id)) || defaultValue;
|
||||
|
||||
// checked value should be 'true', so it can be captured by the form event
|
||||
return <Switch variant='ontime' name={id} defaultChecked={defaultCheckedValue} value='true' />;
|
||||
return <Switch size='large' name={id} defaultChecked={defaultCheckedValue} />;
|
||||
}
|
||||
|
||||
if (type === 'number') {
|
||||
const { prefix, placeholder } = paramField;
|
||||
const { placeholder } = paramField;
|
||||
const defaultNumberValue = searchParams.get(id) ?? defaultValue;
|
||||
|
||||
return (
|
||||
<InputGroup variant='ontime-filled'>
|
||||
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
||||
<Input
|
||||
type='number'
|
||||
step='any'
|
||||
variant='ontime-filled'
|
||||
name={id}
|
||||
defaultValue={defaultNumberValue}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</InputGroup>
|
||||
<Input
|
||||
height='large'
|
||||
type='number'
|
||||
step='any'
|
||||
name={id}
|
||||
defaultValue={defaultNumberValue}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,52 +70,56 @@ export default function ParamInput({ paramField }: ParamInputProps) {
|
||||
}
|
||||
|
||||
const defaultStringValue = searchParams.get(id) ?? defaultValue;
|
||||
const { prefix, placeholder } = paramField;
|
||||
const { placeholder } = paramField;
|
||||
|
||||
return (
|
||||
<InputGroup variant='ontime-filled'>
|
||||
{prefix && <InputLeftElement pointerEvents='none'>{prefix}</InputLeftElement>}
|
||||
<Input name={id} defaultValue={defaultStringValue} placeholder={placeholder} />
|
||||
</InputGroup>
|
||||
);
|
||||
return <Input height='large' name={id} defaultValue={defaultStringValue} placeholder={placeholder} />;
|
||||
}
|
||||
|
||||
interface EditFormMultiOptionProps {
|
||||
paramField: ParamField & { type: 'multi-option' };
|
||||
}
|
||||
|
||||
function MultiOption(props: EditFormMultiOptionProps) {
|
||||
function MultiOption({ paramField }: EditFormMultiOptionProps) {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { paramField } = props;
|
||||
const { id, defaultValue } = paramField;
|
||||
const { id, values, defaultValue = [''] } = paramField;
|
||||
|
||||
const optionFromParams = searchParams.getAll(id);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue || ['']);
|
||||
const [paramState, setParamState] = useState<string[]>(optionFromParams || defaultValue);
|
||||
|
||||
const toggleValue = (value: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
setParamState((prev) => [...prev, value]);
|
||||
} else {
|
||||
setParamState((prev) => prev.filter((v) => v !== value));
|
||||
}
|
||||
};
|
||||
|
||||
if (values.length === 0) {
|
||||
return <span className={style.empty}>No options available</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input name={id} hidden readOnly value={paramState} />
|
||||
<Menu isLazy closeOnSelect={false} variant='ontime-on-dark'>
|
||||
<MenuButton as={Button} variant='ontime-subtle-white' position='relative' width='fit-content' fontWeight={400}>
|
||||
{paramField.title} <IoChevronDown style={{ display: 'inline' }} />
|
||||
</MenuButton>
|
||||
<MenuList overflow='auto' maxHeight='200px'>
|
||||
<MenuOptionGroup
|
||||
type='checkbox'
|
||||
value={paramState}
|
||||
onChange={(value) => setParamState(Array.isArray(value) ? value : [value])}
|
||||
>
|
||||
{Object.values(paramField.values).map((option) => {
|
||||
const { value, label, colour } = option;
|
||||
return (
|
||||
<MenuItemOption value={value} key={value} style={{ borderRight: `8px solid ${colour}` }}>
|
||||
{label}
|
||||
</MenuItemOption>
|
||||
);
|
||||
})}
|
||||
</MenuOptionGroup>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
<input name={id} hidden readOnly value={paramState.join(',')} />
|
||||
<div className={style.inline}>
|
||||
{values.map((option) => {
|
||||
return (
|
||||
<label
|
||||
key={option.value}
|
||||
className={style.toggleSelect}
|
||||
style={{
|
||||
'--user-bg': option.colour,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={paramState.includes(option.value)}
|
||||
onCheckedChange={(checked) => toggleValue(option.value, checked as boolean)}
|
||||
/>
|
||||
{option.label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: $zindex-backdrop;
|
||||
background-color: $backdrop-color;
|
||||
transition: opacity 300ms cubic-bezier(0.45, 1.005, 0, 1.005);
|
||||
|
||||
@@ -27,14 +26,13 @@
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: $zindex-dialog;
|
||||
|
||||
width: 40rem;
|
||||
height: 100vh;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem 1.5rem;
|
||||
padding-block: 1rem 1.5rem;
|
||||
|
||||
background-color: $gray-1250;
|
||||
color: $ui-white;
|
||||
@@ -58,6 +56,7 @@
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-inline: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -69,12 +68,14 @@
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding-inline: 1rem;
|
||||
padding-bottom: 10vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
padding-inline: 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { FormEvent, memo } from 'react';
|
||||
import { IoClose } from 'react-icons/io5';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Dialog } from '@base-ui-components/react/dialog';
|
||||
|
||||
import useViewSettings from '../../hooks-query/useViewSettings';
|
||||
@@ -21,6 +22,7 @@ interface EditFormDrawerProps {
|
||||
export default memo(ViewParamsEditor);
|
||||
|
||||
function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
const { data: viewSettings } = useViewSettings();
|
||||
const { isOpen, close } = useViewParamsEditorStore();
|
||||
|
||||
@@ -29,8 +31,7 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
};
|
||||
|
||||
const resetParams = () => {
|
||||
window.history.pushState(null, '', window.location.pathname);
|
||||
close();
|
||||
setSearchParams();
|
||||
};
|
||||
|
||||
const onParamsFormSubmit = (formEvent: FormEvent<HTMLFormElement>) => {
|
||||
@@ -38,9 +39,9 @@ function ViewParamsEditor({ viewOptions }: EditFormDrawerProps) {
|
||||
|
||||
const newParamsObject = Object.fromEntries(new FormData(formEvent.currentTarget));
|
||||
const newSearchParams = getURLSearchParamsFromObj(newParamsObject, viewOptions);
|
||||
const url = new URL(window.location.href);
|
||||
url.search = newSearchParams.toString();
|
||||
window.history.pushState(null, '', url);
|
||||
|
||||
console.log('New search params:', newParamsObject, newSearchParams.toString());
|
||||
setSearchParams(newSearchParams);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
+57
-22
@@ -5,32 +5,32 @@ import { OptionTitle } from '../constants';
|
||||
import type { ViewOption } from '../viewParams.types';
|
||||
import { getURLSearchParamsFromObj, makeOptionsFromCustomFields } from '../viewParams.utils';
|
||||
|
||||
describe('makeOptionsFromCustomFields', () => {
|
||||
describe('makeOptionsFromCustomFields()', () => {
|
||||
const testCustomFields: CustomFields = {
|
||||
field1: { label: 'Field 1', colour: 'red', type: 'string' },
|
||||
field2: { label: 'Field 2', colour: 'blue', type: 'string' },
|
||||
};
|
||||
|
||||
it('creates a record of keys for the given custom fields', () => {
|
||||
it('creates an array of options to use in a select', () => {
|
||||
const result = makeOptionsFromCustomFields(testCustomFields);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
expect(result).toStrictEqual([
|
||||
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends additional data', () => {
|
||||
const additionalData = {
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
};
|
||||
const additionalData = [
|
||||
{ value: 'test1', label: 'Test 1' },
|
||||
{ value: 'test2', label: 'Test 2' },
|
||||
];
|
||||
const result = makeOptionsFromCustomFields(testCustomFields, additionalData);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
test1: 'test1',
|
||||
test2: 'test2',
|
||||
});
|
||||
expect(result).toStrictEqual([
|
||||
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||
{ value: 'test1', label: 'Test 1' },
|
||||
{ value: 'test2', label: 'Test 2' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('filtersImageTypes', () => {
|
||||
@@ -40,14 +40,14 @@ describe('makeOptionsFromCustomFields', () => {
|
||||
};
|
||||
|
||||
const result = makeOptionsFromCustomFields(customFieldsWIthImage);
|
||||
expect(result).toStrictEqual({
|
||||
'custom-field1': 'Custom: Field 1',
|
||||
'custom-field2': 'Custom: Field 2',
|
||||
});
|
||||
expect(result).toStrictEqual([
|
||||
{ value: 'custom-field1', label: 'Custom: Field 1' },
|
||||
{ value: 'custom-field2', label: 'Custom: Field 2' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getURLSearchParamsFromObj', () => {
|
||||
describe('getURLSearchParamsFromObj()', () => {
|
||||
// Mock view options for testing
|
||||
const mockViewOptions: ViewOption[] = [
|
||||
{
|
||||
@@ -73,7 +73,10 @@ describe('getURLSearchParamsFromObj', () => {
|
||||
title: 'Multi Select',
|
||||
description: 'A multi-select field',
|
||||
type: 'option',
|
||||
values: { value1: 'Value 1', value2: 'Value 2' },
|
||||
values: [
|
||||
{ value: 'value1', label: 'Value 1' },
|
||||
{ value: 'value2', label: 'Value 2' },
|
||||
],
|
||||
defaultValue: '',
|
||||
},
|
||||
],
|
||||
@@ -201,4 +204,36 @@ describe('getURLSearchParamsFromObj', () => {
|
||||
// Should only include unique values while maintaining order
|
||||
expect(result.getAll('sub')).toEqual(['value1', 'value2', 'value3']);
|
||||
});
|
||||
|
||||
it('converts on-off from toggle to boolean', () => {
|
||||
const mockOptionsWithBooleans: ViewOption[] = [
|
||||
{
|
||||
title: OptionTitle.StyleOverride,
|
||||
options: [
|
||||
{
|
||||
id: 'bool1',
|
||||
title: 'bool1',
|
||||
description: 'Bool1',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
id: 'bool2',
|
||||
title: 'bool2',
|
||||
description: 'Bool2',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const params = {
|
||||
bool1: 'off',
|
||||
bool2: 'on',
|
||||
};
|
||||
const result = getURLSearchParamsFromObj(params, mockOptionsWithBooleans);
|
||||
console.log('Result:', result.toString());
|
||||
expect(result.get('bool1')).toBe('false');
|
||||
expect(result.get('bool2')).toBe('true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,20 +8,19 @@ type BaseField = {
|
||||
|
||||
type OptionsField = {
|
||||
type: 'option';
|
||||
values: Record<string, string>;
|
||||
values: { value: string; label: string }[];
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type MultiselectOption = { value: string; label: string; colour: string };
|
||||
export type MultiselectOptions = Record<string, MultiselectOption>;
|
||||
export type MultiselectOption = { value: string; label: string; colour: string };
|
||||
type MultiOptionsField = {
|
||||
type: 'multi-option';
|
||||
values: MultiselectOptions;
|
||||
values: MultiselectOption[];
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
type StringField = { type: 'string'; defaultValue?: string; prefix?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; prefix?: string; placeholder?: string };
|
||||
type StringField = { type: 'string'; defaultValue?: string; placeholder?: string };
|
||||
type NumberField = { type: 'number'; defaultValue?: number; placeholder?: string };
|
||||
type BooleanField = { type: 'boolean'; defaultValue: boolean };
|
||||
type ColourField = { type: 'colour'; defaultValue: string; placeholder?: string };
|
||||
type PersistedField = { type: 'persist'; defaultValue?: string[]; values: string[] };
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { CustomFields } from 'ontime-types';
|
||||
|
||||
import type { MultiselectOptions, ViewOption } from './viewParams.types';
|
||||
import type { SelectOption } from '../select/Select';
|
||||
|
||||
import type { MultiselectOption, ViewOption } from './viewParams.types';
|
||||
|
||||
/**
|
||||
* Creates a list of custom fields for a select
|
||||
@@ -8,32 +10,46 @@ import type { MultiselectOptions, ViewOption } from './viewParams.types';
|
||||
*/
|
||||
export function makeOptionsFromCustomFields(
|
||||
customFields: CustomFields,
|
||||
additionalOptions: Readonly<Record<string, string>> = {},
|
||||
additionalOptions: SelectOption[] = [],
|
||||
filterImageType = true,
|
||||
): Record<string, string> {
|
||||
const options = { ...additionalOptions };
|
||||
): SelectOption[] {
|
||||
const options: SelectOption[] = [];
|
||||
|
||||
// Add custom fields first
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
|
||||
options[`custom-${key}`] = `Custom: ${value.label}`;
|
||||
options.push({
|
||||
value: `custom-${key}`,
|
||||
label: `Custom: ${value.label}`,
|
||||
});
|
||||
}
|
||||
return options;
|
||||
|
||||
return options.concat(additionalOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates data for a multiselect component from custom fields
|
||||
* Filters out image type custom fields
|
||||
*/
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOptions {
|
||||
const options: MultiselectOptions = {};
|
||||
export function makeCustomFieldSelectOptions(customFields: CustomFields, filterImageType = true): MultiselectOption[] {
|
||||
const options: MultiselectOption[] = [];
|
||||
|
||||
// Add custom fields first
|
||||
for (const [key, value] of Object.entries(customFields)) {
|
||||
if (filterImageType && value.type === 'image') {
|
||||
continue;
|
||||
}
|
||||
options[key] = { value: key, label: value.label, colour: value.colour };
|
||||
|
||||
options.push({
|
||||
value: key,
|
||||
label: value.label,
|
||||
colour: value.colour || 'transparent',
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -52,17 +68,22 @@ function sanitiseColour(colour: string) {
|
||||
type FieldMetadata = {
|
||||
defaultValues: Record<string, string>;
|
||||
colorFields: Set<string>;
|
||||
booleanFields: Set<string>;
|
||||
isPersistedField: Set<string>;
|
||||
persistedValues: Record<string, string[]>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility collects metadata about fields from view options
|
||||
* - where are the default values
|
||||
* - which fields are colours
|
||||
* - which fields are persisted
|
||||
*/
|
||||
function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
||||
const metadata: FieldMetadata = {
|
||||
defaultValues: {},
|
||||
colorFields: new Set(),
|
||||
booleanFields: new Set(),
|
||||
isPersistedField: new Set(),
|
||||
persistedValues: {},
|
||||
};
|
||||
@@ -80,6 +101,8 @@ function collectFieldMetadata(paramFields: ViewOption[]): FieldMetadata {
|
||||
|
||||
if (option.type === 'colour') {
|
||||
metadata.colorFields.add(option.id);
|
||||
} else if (option.type === 'boolean') {
|
||||
metadata.booleanFields.add(option.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -132,7 +155,16 @@ export function getURLSearchParamsFromObj(paramsObj: ViewParamsObj, paramFields:
|
||||
|
||||
// Process and add new values
|
||||
value.split(',').forEach((v) => {
|
||||
const processedValue = metadata.colorFields.has(id) ? sanitiseColour(v) : v;
|
||||
// some field types need extra processing
|
||||
const processedValue = (() => {
|
||||
if (metadata.colorFields.has(id)) {
|
||||
return sanitiseColour(v);
|
||||
}
|
||||
if (metadata.booleanFields.has(id)) {
|
||||
return v === 'on' ? 'true' : 'false';
|
||||
}
|
||||
return v;
|
||||
})();
|
||||
if (metadata.isPersistedField.has(id) || metadata.defaultValues[id] !== processedValue) {
|
||||
addUniqueParam(id, processedValue);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { MouseEvent } from 'react';
|
||||
|
||||
import { Option, useContextMenuStore } from '../components/context-menu/ContextMenu';
|
||||
import { useContextMenuStore } from '../../features/rundown/rundown-context-menu/RundownContextMenu';
|
||||
import { DropdownMenuOption } from '../components/dropdown-menu/DropdownMenu';
|
||||
|
||||
export const useContextMenu = <T extends HTMLElement>(options: Option[]) => {
|
||||
const { setContextMenu } = useContextMenuStore();
|
||||
export const useContextMenu = <T extends HTMLElement>(options: DropdownMenuOption[]) => {
|
||||
const setContextMenu = useContextMenuStore((state) => state.setContextMenu);
|
||||
|
||||
const localCreateContextMenu = (contextMenuEvent: MouseEvent<T, globalThis.MouseEvent>) => {
|
||||
// prevent browser default context menu from showing up
|
||||
|
||||
Reference in New Issue
Block a user