Custom Radios indicator with one-line labels; keep Pagination within its parent

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]>
This commit is contained in:
2026-11-08 08:52:11 +01:00
co-authored by Claude Opus 5.5
parent 66c2f6850e
commit 7d7eae5801
7 changed files with 116 additions and 28 deletions
+69 -15
View File
@@ -28,11 +28,23 @@ export type TWUI_FORM_RADIO_PROPS = {
>;
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-textarea
* @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,
@@ -41,6 +53,7 @@ export default function Radios({
labelProps,
wrapperProps,
changeHandler,
wrap,
}: TWUI_FORM_RADIO_PROPS) {
const finalName = twuiSlugify(name);
const finalTitle = twuiSlugToNormalText(finalName);
@@ -49,26 +62,67 @@ export default function Radios({
<Row
title={finalTitle}
{...wrapperProps}
className={twMerge("gap-4", wrapperProps?.className)}
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} className="gap-1.5">
<input
id={valueName}
type="radio"
defaultChecked={v.default}
name={finalName}
onChange={(e) => {
const targetValue = v.value;
changeHandler?.(targetValue);
}}
{...inputProps}
/>
<label htmlFor={valueName} {...labelProps}>
<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>