6
common/resources/client/ui/forms/toggle/checkbox-filled-icon.tsx
Executable file
6
common/resources/client/ui/forms/toggle/checkbox-filled-icon.tsx
Executable file
@@ -0,0 +1,6 @@
|
||||
import {createSvgIcon} from '../../../icons/create-svg-icon';
|
||||
|
||||
export const CheckboxFilledIcon = createSvgIcon(
|
||||
<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 14-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />,
|
||||
'CheckBox'
|
||||
);
|
||||
98
common/resources/client/ui/forms/toggle/checkbox-group.tsx
Executable file
98
common/resources/client/ui/forms/toggle/checkbox-group.tsx
Executable file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useId,
|
||||
useRef,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { useControlledState } from "@react-stately/utils";
|
||||
import { Orientation } from "../orientation";
|
||||
import { CheckboxProps } from "./checkbox";
|
||||
import { getInputFieldClassNames } from "../input-field/get-input-field-class-names";
|
||||
|
||||
interface CheckboxGroupProps {
|
||||
children: ReactElement<CheckboxProps> | ReactElement<CheckboxProps>[];
|
||||
orientation?: Orientation;
|
||||
className?: string;
|
||||
value?: (string | number)[];
|
||||
defaultValue?: (string | number)[];
|
||||
onChange?: (newValue: (string | number)[]) => void;
|
||||
label?: ReactNode;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
invalid?: boolean;
|
||||
}
|
||||
|
||||
export function CheckboxGroup(props: CheckboxGroupProps) {
|
||||
const {
|
||||
label,
|
||||
children,
|
||||
orientation = "vertical",
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
readOnly,
|
||||
invalid,
|
||||
} = props;
|
||||
const ref = useRef(null);
|
||||
|
||||
const labelId = useId();
|
||||
const [selectedValues, setSelectedValues] = useControlledState(
|
||||
value,
|
||||
defaultValue || [],
|
||||
onChange
|
||||
);
|
||||
|
||||
const style = getInputFieldClassNames(props);
|
||||
|
||||
const handleCheckboxToggle: ChangeEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
(e) => {
|
||||
const c = e.currentTarget.value;
|
||||
const i = selectedValues.indexOf(c);
|
||||
if (i > -1) {
|
||||
selectedValues.splice(i, 1);
|
||||
} else {
|
||||
selectedValues.push(c);
|
||||
}
|
||||
setSelectedValues([...selectedValues]);
|
||||
},
|
||||
[selectedValues, setSelectedValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={className} role="group" aria-labelledby={labelId} ref={ref}>
|
||||
{label && (
|
||||
<span id={labelId} className={style.label}>
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
role="presentation"
|
||||
className={clsx(
|
||||
"flex gap-6",
|
||||
orientation === "vertical" ? "flex-col" : "flow-row"
|
||||
)}
|
||||
>
|
||||
{Children.map(children, (child) => {
|
||||
if (isValidElement(child)) {
|
||||
return cloneElement<CheckboxProps>(child, {
|
||||
disabled: child.props.disabled || disabled,
|
||||
readOnly: child.props.readOnly || readOnly,
|
||||
invalid: child.props.invalid || invalid,
|
||||
checked: selectedValues?.includes(child.props.value as string),
|
||||
onChange: handleCheckboxToggle,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
175
common/resources/client/ui/forms/toggle/checkbox.tsx
Executable file
175
common/resources/client/ui/forms/toggle/checkbox.tsx
Executable file
@@ -0,0 +1,175 @@
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
ComponentPropsWithoutRef,
|
||||
ComponentType,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import {InputSize} from '../input-field/input-size';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {CheckBoxOutlineBlankIcon} from '@common/icons/material/CheckBoxOutlineBlank';
|
||||
import {CheckboxFilledIcon} from './checkbox-filled-icon';
|
||||
import {IndeterminateCheckboxFilledIcon} from './indeterminate-checkbox-filled-icon';
|
||||
import {SvgIconProps} from '@common/icons/svg-icon';
|
||||
import {Orientation} from '../orientation';
|
||||
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
|
||||
|
||||
export interface CheckboxProps
|
||||
extends AutoFocusProps,
|
||||
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
|
||||
size?: InputSize;
|
||||
className?: string;
|
||||
icon?: React.ComponentType;
|
||||
checkedIcon?: React.ComponentType;
|
||||
orientation?: Orientation;
|
||||
errorMessage?: string;
|
||||
isIndeterminate?: boolean;
|
||||
invalid?: boolean;
|
||||
inputTestId?: string;
|
||||
}
|
||||
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
size = 'md',
|
||||
children,
|
||||
className,
|
||||
icon,
|
||||
checkedIcon,
|
||||
disabled,
|
||||
isIndeterminate,
|
||||
errorMessage,
|
||||
invalid,
|
||||
orientation = 'horizontal',
|
||||
onChange,
|
||||
autoFocus,
|
||||
required,
|
||||
value,
|
||||
name,
|
||||
inputTestId,
|
||||
} = props;
|
||||
|
||||
const style = getInputFieldClassNames({...props, label: children});
|
||||
const Icon = icon || CheckBoxOutlineBlankIcon;
|
||||
const CheckedIcon =
|
||||
checkedIcon ||
|
||||
(isIndeterminate ? IndeterminateCheckboxFilledIcon : CheckboxFilledIcon);
|
||||
|
||||
const inputObjRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputObjRef);
|
||||
|
||||
useEffect(() => {
|
||||
// indeterminate is a property, but it can only be set via javascript
|
||||
if (inputObjRef.current) {
|
||||
inputObjRef.current.indeterminate = isIndeterminate || false;
|
||||
}
|
||||
});
|
||||
|
||||
const [isSelected, setSelected] = useControlledState(
|
||||
props.checked,
|
||||
props.defaultChecked || false,
|
||||
);
|
||||
|
||||
const updateChecked: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
onChange?.(e);
|
||||
setSelected(e.target.checked);
|
||||
},
|
||||
[onChange, setSelected],
|
||||
);
|
||||
|
||||
const mergedClassName = clsx(
|
||||
'select-none',
|
||||
className,
|
||||
invalid && 'text-danger',
|
||||
!invalid && disabled && 'text-disabled',
|
||||
);
|
||||
|
||||
let CheckboxIcon: ComponentType<SvgIconProps>;
|
||||
let checkboxColor = invalid ? 'text-danger' : null;
|
||||
if (isIndeterminate) {
|
||||
CheckboxIcon = IndeterminateCheckboxFilledIcon;
|
||||
checkboxColor = checkboxColor || 'text-primary';
|
||||
} else if (isSelected) {
|
||||
CheckboxIcon = CheckedIcon;
|
||||
checkboxColor = checkboxColor || 'text-primary';
|
||||
} else {
|
||||
CheckboxIcon = Icon;
|
||||
checkboxColor = checkboxColor || 'text-muted';
|
||||
}
|
||||
|
||||
// input and icon sizes need to match, as checkbox input is being clicked and not the icon due to pointer-events-none
|
||||
return (
|
||||
<div>
|
||||
<label className={mergedClassName}>
|
||||
<div
|
||||
className={clsx(
|
||||
'relative flex items-center',
|
||||
orientation === 'vertical' && 'flex-col flex-col-reverse',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
className="absolute left-0 top-0 h-24 w-24 appearance-none overflow-hidden rounded outline-none ring-inset transition-shadow focus-visible:ring"
|
||||
type="checkbox"
|
||||
aria-checked={isIndeterminate ? 'mixed' : isSelected}
|
||||
aria-invalid={invalid || undefined}
|
||||
onChange={updateChecked}
|
||||
ref={inputObjRef}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
name={name}
|
||||
data-testid={inputTestId}
|
||||
/>
|
||||
<CheckboxIcon
|
||||
size={size}
|
||||
className={clsx(
|
||||
'pointer-events-none',
|
||||
disabled ? 'text-disabled' : checkboxColor,
|
||||
)}
|
||||
/>
|
||||
{children && (
|
||||
<div
|
||||
className={clsx(
|
||||
'first-letter:capitalize',
|
||||
style.size.font,
|
||||
orientation === 'vertical' ? 'mb-6' : 'ml-6',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
{errorMessage && <div className={style.error}>{errorMessage}</div>}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface FormCheckboxProps extends CheckboxProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormCheckbox(props: FormCheckboxProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = false, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<CheckboxProps> = {
|
||||
onChange,
|
||||
onBlur,
|
||||
checked: value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
name: props.name,
|
||||
};
|
||||
|
||||
return <Checkbox ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {createSvgIcon} from "../../../icons/create-svg-icon";
|
||||
|
||||
export const IndeterminateCheckboxFilledIcon = createSvgIcon(
|
||||
<path d="M19,3H5C3.9,3,3,3.9,3,5v14c0,1.1,0.9,2,2,2h14c1.1,0,2-0.9,2-2V5C21,3.9,20.1,3,19,3z M17,13H7v-2h10V13z" />,
|
||||
'CheckBox'
|
||||
);
|
||||
135
common/resources/client/ui/forms/toggle/switch.tsx
Executable file
135
common/resources/client/ui/forms/toggle/switch.tsx
Executable file
@@ -0,0 +1,135 @@
|
||||
import React, {ComponentPropsWithoutRef, ReactNode, useId} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {InputSize} from '../input-field/input-size';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
|
||||
|
||||
interface SwitchProps
|
||||
extends AutoFocusProps,
|
||||
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
|
||||
size?: InputSize;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
errorMessage?: string;
|
||||
iconRight?: ReactNode;
|
||||
}
|
||||
export const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
size = 'sm',
|
||||
description,
|
||||
className,
|
||||
invalid,
|
||||
autoFocus,
|
||||
errorMessage,
|
||||
iconRight,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
const inputRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const style = getSizeClassName(size);
|
||||
const fieldClassNames = getInputFieldClassNames(props);
|
||||
|
||||
const descriptionId = useId();
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'isolate')}>
|
||||
<label className="flex select-none items-center">
|
||||
<input
|
||||
{...domProps}
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-describedby={description ? descriptionId : undefined}
|
||||
ref={inputRef}
|
||||
aria-checked={domProps.checked}
|
||||
className={clsx(
|
||||
style,
|
||||
!invalid &&
|
||||
'checked:border-primary checked:bg-primary dark:checked:border-primary-dark dark:checked:bg-primary-dark',
|
||||
invalid && 'checked:border-danger checked:bg-danger',
|
||||
'relative flex flex-shrink-0 cursor-pointer appearance-none items-center overflow-hidden rounded-3xl border border-chip bg-chip p-0 outline-none transition-colors checked:border-primary checked:bg-primary',
|
||||
'before:z-10 before:block before:translate-x-2 before:rounded-3xl before:border before:bg-white before:transition-transform',
|
||||
'checked:before:border-white',
|
||||
'focus-visible:ring',
|
||||
props.disabled && 'cursor-not-allowed opacity-80',
|
||||
)}
|
||||
/>
|
||||
{children && (
|
||||
<span
|
||||
className={clsx(
|
||||
fieldClassNames.size.font,
|
||||
'ml-12',
|
||||
invalid && 'text-danger',
|
||||
props.disabled && 'text-disabled',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
{iconRight}
|
||||
</label>
|
||||
{description && !errorMessage && (
|
||||
<div id={descriptionId} className={fieldClassNames.description}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<div id={descriptionId} className={fieldClassNames.error}>
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
interface FormSwitchProps extends SwitchProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormSwitch(props: FormSwitchProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value = false, ref},
|
||||
fieldState: {invalid, error},
|
||||
} = useController({
|
||||
name: props.name,
|
||||
});
|
||||
|
||||
const formProps: Partial<SwitchProps> = {
|
||||
onChange: e => {
|
||||
if (e.target.value && e.target.value !== 'on') {
|
||||
onChange(e.target.checked ? e.target.value : false);
|
||||
} else {
|
||||
onChange(e);
|
||||
}
|
||||
},
|
||||
onBlur,
|
||||
checked: !!value,
|
||||
invalid,
|
||||
errorMessage: error?.message,
|
||||
name: props.name,
|
||||
};
|
||||
|
||||
return <Switch ref={ref} {...mergeProps(props, formProps)} />;
|
||||
}
|
||||
|
||||
function getSizeClassName(size: InputSize): string {
|
||||
switch (size) {
|
||||
case 'xl':
|
||||
return 'w-68 h-36 before:w-28 before:h-28 checked:before:translate-x-36';
|
||||
case 'lg':
|
||||
return 'w-56 h-30 before:w-22 before:h-22 checked:before:translate-x-30';
|
||||
case 'md':
|
||||
return 'w-46 h-24 before:w-18 before:h-18 checked:before:translate-x-24';
|
||||
case 'xs':
|
||||
return 'w-30 h-18 before:w-12 before:h-12 checked:before:translate-x-14';
|
||||
default:
|
||||
return 'w-38 h-20 before:w-14 before:h-14 checked:before:translate-x-20';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user