103
common/resources/client/ui/forms/radio-group/radio-group.tsx
Executable file
103
common/resources/client/ui/forms/radio-group/radio-group.tsx
Executable file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
ReactNode,
|
||||
useId,
|
||||
} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {Orientation} from '../orientation';
|
||||
import {RadioProps} from './radio';
|
||||
import {getInputFieldClassNames} from '../input-field/get-input-field-class-names';
|
||||
|
||||
export interface RadioGroupProps {
|
||||
children: ReactNode;
|
||||
orientation?: Orientation;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
label?: ReactNode;
|
||||
disabled?: boolean;
|
||||
name?: string;
|
||||
errorMessage?: ReactNode;
|
||||
description?: ReactNode;
|
||||
invalid?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
export const RadioGroup = forwardRef<HTMLFieldSetElement, RadioGroupProps>(
|
||||
(props, ref) => {
|
||||
const style = getInputFieldClassNames(props);
|
||||
const {
|
||||
label,
|
||||
children,
|
||||
size,
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
disabled,
|
||||
required,
|
||||
invalid,
|
||||
errorMessage,
|
||||
description,
|
||||
} = props;
|
||||
|
||||
const labelProps = {};
|
||||
const id = useId();
|
||||
const name = props.name || id;
|
||||
|
||||
return (
|
||||
<fieldset
|
||||
aria-describedby={description ? `${id}-description` : undefined}
|
||||
ref={ref}
|
||||
className={clsx('text-left', className)}
|
||||
>
|
||||
{label && (
|
||||
<legend className={style.label} {...labelProps}>
|
||||
{label}
|
||||
</legend>
|
||||
)}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex',
|
||||
label ? 'mt-6' : 'mt-0',
|
||||
orientation === 'vertical' ? 'flex-col gap-10' : 'flex-row gap-16'
|
||||
)}
|
||||
>
|
||||
{Children.map(children, child => {
|
||||
if (isValidElement<RadioProps>(child)) {
|
||||
return cloneElement<RadioProps>(child, {
|
||||
name,
|
||||
size,
|
||||
invalid: child.props.invalid || invalid || undefined,
|
||||
disabled: child.props.disabled || disabled,
|
||||
required: child.props.required || required,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
{description && !errorMessage && (
|
||||
<div className={style.description} id={`${id}-description`}>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{errorMessage && <div className={style.error}>{errorMessage}</div>}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
interface FormRadioGroupProps extends RadioGroupProps {
|
||||
name: string;
|
||||
}
|
||||
export function FormRadioGroup({children, ...props}: FormRadioGroupProps) {
|
||||
const {
|
||||
fieldState: {error},
|
||||
} = useController({
|
||||
name: props.name!,
|
||||
});
|
||||
return (
|
||||
<RadioGroup errorMessage={error?.message} {...props}>
|
||||
{children}
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
85
common/resources/client/ui/forms/radio-group/radio.tsx
Executable file
85
common/resources/client/ui/forms/radio-group/radio.tsx
Executable file
@@ -0,0 +1,85 @@
|
||||
import React, {ComponentPropsWithoutRef, forwardRef} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {mergeProps, useObjectRef} from '@react-aria/utils';
|
||||
import {useController} from 'react-hook-form';
|
||||
import {AutoFocusProps, useAutoFocus} from '../../focus/use-auto-focus';
|
||||
|
||||
type RadioSize = 'xs' | 'sm' | 'md' | 'lg' | undefined;
|
||||
|
||||
export interface RadioProps
|
||||
extends AutoFocusProps,
|
||||
Omit<ComponentPropsWithoutRef<'input'>, 'size'> {
|
||||
size?: RadioSize;
|
||||
value: string;
|
||||
invalid?: boolean;
|
||||
isFirst?: boolean;
|
||||
}
|
||||
export const Radio = forwardRef<HTMLInputElement, RadioProps>((props, ref) => {
|
||||
const {children, autoFocus, size, invalid, isFirst, ...domProps} = props;
|
||||
|
||||
const inputRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const sizeClassNames = getSizeClassNames(size);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={clsx(
|
||||
'inline-flex gap-8 select-none items-center whitespace-nowrap align-middle',
|
||||
sizeClassNames.label,
|
||||
props.disabled && 'text-disabled pointer-events-none',
|
||||
props.invalid && 'text-danger'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={clsx(
|
||||
'focus-visible:ring outline-none',
|
||||
'rounded-full transition-button border-2 appearance-none',
|
||||
'border-text-muted disabled:border-disabled-fg checked:border-primary checked:hover:border-primary-dark',
|
||||
'before:bg-primary disabled:before:bg-disabled-fg before:hover:bg-primary-dark',
|
||||
'before:h-full before:w-full before:block before:rounded-full before:scale-10 before:opacity-0 before:transition before:duration-200',
|
||||
'checked:before:scale-[.65] checked:before:opacity-100',
|
||||
sizeClassNames.circle
|
||||
)}
|
||||
ref={inputRef}
|
||||
{...domProps}
|
||||
/>
|
||||
{children && <span>{children}</span>}
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
export function FormRadio(props: RadioProps) {
|
||||
const {
|
||||
field: {onChange, onBlur, value, ref},
|
||||
fieldState: {invalid},
|
||||
} = useController({
|
||||
name: props.name!,
|
||||
});
|
||||
|
||||
const formProps: Partial<RadioProps> = {
|
||||
onChange,
|
||||
onBlur,
|
||||
checked: props.value === value,
|
||||
invalid: props.invalid || invalid,
|
||||
};
|
||||
|
||||
return <Radio ref={ref} {...mergeProps(formProps, props)} />;
|
||||
}
|
||||
|
||||
function getSizeClassNames(size?: RadioSize): {
|
||||
circle: string;
|
||||
label: string;
|
||||
} {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return {circle: 'h-12 w-12', label: 'text-xs'};
|
||||
case 'sm':
|
||||
return {circle: 'h-16 w-16', label: 'text-sm'};
|
||||
case 'lg':
|
||||
return {circle: 'h-24 w-24', label: 'text-lg'};
|
||||
default:
|
||||
return {circle: 'h-20 w-20', label: 'text-base'};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user