41
common/resources/client/ui/forms/segmented-radio-group/active-indicator.tsx
Executable file
41
common/resources/client/ui/forms/segmented-radio-group/active-indicator.tsx
Executable file
@@ -0,0 +1,41 @@
|
||||
import {RefObject, useEffect, useState} from 'react';
|
||||
import {m} from 'framer-motion';
|
||||
|
||||
interface ActiveIndicatorProps {
|
||||
selectedValue?: string;
|
||||
labelsRef: RefObject<Record<string, HTMLLabelElement>>;
|
||||
}
|
||||
export function ActiveIndicator({
|
||||
selectedValue,
|
||||
labelsRef,
|
||||
}: ActiveIndicatorProps) {
|
||||
const [style, setStyle] = useState<{
|
||||
width: number;
|
||||
height: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedValue != null && labelsRef.current) {
|
||||
const el = labelsRef.current[selectedValue];
|
||||
if (!el) return;
|
||||
setStyle({
|
||||
width: el.offsetWidth,
|
||||
height: el.offsetHeight,
|
||||
left: el.offsetLeft,
|
||||
});
|
||||
}
|
||||
}, [setStyle, selectedValue, labelsRef]);
|
||||
|
||||
if (!style) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
animate={style}
|
||||
initial={false}
|
||||
className="bg-paper shadow rounded absolute z-10 pointer-events-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useId,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import {RadioGroupProps} from '../radio-group/radio-group';
|
||||
import {SegmentedRadioProps} from './segmented-radio';
|
||||
import {ActiveIndicator} from './active-indicator';
|
||||
import {useControlledState} from '@react-stately/utils';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface SegmentedRadioGroupProps
|
||||
extends Omit<RadioGroupProps, 'orientation'> {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
defaultValue?: string;
|
||||
width?: string;
|
||||
}
|
||||
export const SegmentedRadioGroup = forwardRef<
|
||||
HTMLFieldSetElement,
|
||||
SegmentedRadioGroupProps
|
||||
>((props, ref) => {
|
||||
const {children, size, className} = props;
|
||||
|
||||
const id = useId();
|
||||
const name = props.name || id;
|
||||
|
||||
const labelsRef = useRef<Record<string, HTMLLabelElement>>({});
|
||||
const [selectedValue, setSelectedValue] = useControlledState(
|
||||
props.value,
|
||||
props.defaultValue,
|
||||
props.onChange,
|
||||
);
|
||||
|
||||
return (
|
||||
<fieldset ref={ref} className={clsx(className, props.width ?? 'w-min')}>
|
||||
<div className="relative isolate flex rounded bg-chip p-4">
|
||||
<ActiveIndicator selectedValue={selectedValue} labelsRef={labelsRef} />
|
||||
{Children.map(children, (child, index) => {
|
||||
if (isValidElement<SegmentedRadioProps>(child)) {
|
||||
return cloneElement<SegmentedRadioProps>(child, {
|
||||
isFirst: index === 0,
|
||||
name,
|
||||
size,
|
||||
onChange: e => {
|
||||
setSelectedValue(e.target.value);
|
||||
child.props.onChange?.(e);
|
||||
},
|
||||
labelRef: el => {
|
||||
if (el) {
|
||||
labelsRef.current[child.props.value] = el;
|
||||
}
|
||||
},
|
||||
isSelected: selectedValue === child.props.value,
|
||||
});
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
});
|
||||
65
common/resources/client/ui/forms/segmented-radio-group/segmented-radio.tsx
Executable file
65
common/resources/client/ui/forms/segmented-radio-group/segmented-radio.tsx
Executable file
@@ -0,0 +1,65 @@
|
||||
import React, {forwardRef, Ref} from 'react';
|
||||
import clsx from 'clsx';
|
||||
import {useObjectRef} from '@react-aria/utils';
|
||||
import {InputSize} from '../input-field/input-size';
|
||||
import {useAutoFocus} from '../../focus/use-auto-focus';
|
||||
import {RadioProps} from '../radio-group/radio';
|
||||
|
||||
export interface SegmentedRadioProps extends RadioProps {
|
||||
labelRef?: Ref<HTMLLabelElement>;
|
||||
isSelected?: boolean;
|
||||
}
|
||||
export const SegmentedRadio = forwardRef<HTMLInputElement, SegmentedRadioProps>(
|
||||
(props, ref) => {
|
||||
const {
|
||||
children,
|
||||
autoFocus,
|
||||
size,
|
||||
invalid,
|
||||
isFirst,
|
||||
labelRef,
|
||||
isSelected,
|
||||
...domProps
|
||||
} = props;
|
||||
|
||||
const inputRef = useObjectRef(ref);
|
||||
useAutoFocus({autoFocus}, inputRef);
|
||||
|
||||
const sizeClassNames = getSizeClassNames(size);
|
||||
|
||||
return (
|
||||
<label
|
||||
ref={labelRef}
|
||||
className={clsx(
|
||||
'relative z-20 inline-flex flex-auto cursor-pointer select-none items-center justify-center gap-8 whitespace-nowrap align-middle font-medium transition-colors hover:text-main',
|
||||
isSelected ? 'text-main' : 'text-muted',
|
||||
!isFirst && '',
|
||||
sizeClassNames,
|
||||
props.disabled && 'pointer-events-none text-disabled',
|
||||
props.invalid && 'text-danger',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="pointer-events-none absolute left-0 top-0 h-full w-full appearance-none rounded focus-visible:outline"
|
||||
ref={inputRef}
|
||||
{...domProps}
|
||||
/>
|
||||
{children && <span>{children}</span>}
|
||||
</label>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function getSizeClassNames(size?: InputSize): string {
|
||||
switch (size) {
|
||||
case 'xs':
|
||||
return 'px-6 py-3 text-xs';
|
||||
case 'sm':
|
||||
return 'px-10 py-5 text-sm';
|
||||
case 'lg':
|
||||
return 'px-16 py-6 text-lg';
|
||||
default:
|
||||
return 'px-16 py-8 text-sm';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user