Radios: each radio stays on one line, with its label clamped to a single line (full text on hover) like Checkbox. Pass `wrap` to let labels flow onto multiple lines. The native radio is replaced by a custom indicator aligned to the label's first line; the input stays (visually hidden) for forms and keyboard use, so inputProps.className now lands on a hidden input - style .twui-radio-indicator instead. Adds twui-radios, twui-radio, twui-radio-label and twui-radio-indicator classes. Pagination: flex children could not shrink below their content, so many pages pushed the component past its parent. Add min-w-0 so the page buttons scroll inside the parent instead. Adds twui-pagination. Playground: demos for both, and bump bunext to 1.3.5. Co-Authored-By: Claude Opus 5.5 <[email protected]>
134 lines
4.9 KiB
TypeScript
134 lines
4.9 KiB
TypeScript
import type {
|
|
ComponentProps,
|
|
DetailedHTMLProps,
|
|
InputHTMLAttributes,
|
|
LabelHTMLAttributes,
|
|
} from "react";
|
|
import Row from "../layout/Row";
|
|
import twuiSlugify from "../utils/slugify";
|
|
import twuiSlugToNormalText from "../utils/slug-to-normal-text";
|
|
import { twMerge } from "tailwind-merge";
|
|
|
|
type Value = {
|
|
value: string;
|
|
title?: string;
|
|
default?: boolean;
|
|
};
|
|
|
|
export type TWUI_FORM_RADIO_PROPS = {
|
|
values: Value[];
|
|
name: string;
|
|
inputProps?: DetailedHTMLProps<
|
|
InputHTMLAttributes<HTMLInputElement>,
|
|
HTMLInputElement
|
|
>;
|
|
labelProps?: DetailedHTMLProps<
|
|
LabelHTMLAttributes<HTMLLabelElement>,
|
|
HTMLLabelElement
|
|
>;
|
|
wrapperProps?: ComponentProps<typeof Row>;
|
|
changeHandler?: (value: string) => void;
|
|
/**
|
|
* Let each radio's label wrap onto multiple lines. By default every
|
|
* radio stays on one line with its label clamped to a single line with
|
|
* an ellipsis, and the full text is shown as the radio's `title` on
|
|
* hover.
|
|
*/
|
|
wrap?: boolean;
|
|
};
|
|
|
|
/**
|
|
* # Form Radios Component
|
|
* @className twui-radios
|
|
* @className twui-radio
|
|
* @className twui-radio-label
|
|
* @className twui-radio-indicator - the custom circle; the native input is
|
|
* visually hidden right before it, so target the checked state with
|
|
* `.twui-radio input:checked + .twui-radio-indicator`
|
|
*/
|
|
export default function Radios({
|
|
values,
|
|
name,
|
|
inputProps,
|
|
labelProps,
|
|
wrapperProps,
|
|
changeHandler,
|
|
wrap,
|
|
}: TWUI_FORM_RADIO_PROPS) {
|
|
const finalName = twuiSlugify(name);
|
|
const finalTitle = twuiSlugToNormalText(finalName);
|
|
|
|
return (
|
|
<Row
|
|
title={finalTitle}
|
|
{...wrapperProps}
|
|
className={twMerge(
|
|
"gap-4 min-w-0 max-w-full twui-radios",
|
|
wrapperProps?.className,
|
|
)}
|
|
>
|
|
{values.map((v, i) => {
|
|
const valueName = twuiSlugify(`${finalName}-${v.value}`);
|
|
const valueTitle = v.title || twuiSlugToNormalText(v.value);
|
|
|
|
return (
|
|
<Row
|
|
key={i}
|
|
title={valueTitle}
|
|
className={twMerge(
|
|
"gap-1.5 flex-nowrap min-w-0 max-w-full twui-radio",
|
|
wrap ? "items-start" : "items-center",
|
|
)}
|
|
>
|
|
<label
|
|
htmlFor={valueName}
|
|
className="relative flex items-center shrink-0 h-[1lh] cursor-pointer"
|
|
>
|
|
<input
|
|
id={valueName}
|
|
type="radio"
|
|
defaultChecked={v.default}
|
|
name={finalName}
|
|
onChange={(e) => {
|
|
const targetValue = v.value;
|
|
changeHandler?.(targetValue);
|
|
}}
|
|
{...inputProps}
|
|
className={twMerge(
|
|
"peer sr-only",
|
|
inputProps?.className,
|
|
)}
|
|
/>
|
|
<span
|
|
aria-hidden
|
|
className={twMerge(
|
|
"flex items-center justify-center size-[18px] rounded-full",
|
|
"border-2 border-current dark:border-white/50",
|
|
"after:size-1.5 after:rounded-full after:bg-white after:scale-0 after:transition-transform",
|
|
"peer-checked:bg-primary peer-checked:border-primary peer-checked:after:scale-100",
|
|
"peer-focus-visible:outline-2 peer-focus-visible:outline-offset-2",
|
|
"peer-disabled:opacity-50 peer-disabled:cursor-not-allowed",
|
|
"twui-radio-indicator",
|
|
)}
|
|
/>
|
|
</label>
|
|
<label
|
|
htmlFor={valueName}
|
|
{...labelProps}
|
|
className={twMerge(
|
|
"select-none min-w-0 max-w-full cursor-pointer twui-radio-label",
|
|
wrap
|
|
? "whitespace-normal break-words"
|
|
: "line-clamp-1 break-all",
|
|
labelProps?.className,
|
|
)}
|
|
>
|
|
{valueTitle}
|
|
</label>
|
|
</Row>
|
|
);
|
|
})}
|
|
</Row>
|
|
);
|
|
}
|