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:
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@moduletrace/twui-dev",
|
"name": "@moduletrace/twui-dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moduletrace/bunext": "^1.3.4",
|
"@moduletrace/bunext": "^1.3.5",
|
||||||
"lucide-react": "^1.59.0",
|
"lucide-react": "^1.59.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
|
|
||||||
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
"@jridgewell/trace-mapping": ["@jridgewell/[email protected]", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
"@moduletrace/bunext": ["@moduletrace/[email protected].4", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.4/bunext-1.3.4.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-+WERTs024+7c2RiynwxngPhShPOexnA2qriVe38JTDAEzl2bqHq97WBecJhD6z7R+VTMibfx+ODikHBXcWEj8Q=="],
|
"@moduletrace/bunext": ["@moduletrace/[email protected].5", "https://git.tben.me/api/packages/Moduletrace/npm/%40moduletrace%2Fbunext/-/1.3.5/bunext-1.3.5.tgz", { "dependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/bun": "1.3.14", "@types/node": "^24.10.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.18.1", "ora": "^9.0.0", "postcss": "^8.5.30", "react": "^19.3.0", "react-dom": "^19.3.0", "tailwindcss": "^4.3.3", "typescript": "^5.0.0", "uuid": "^14.0.2" }, "bin": { "bunext": "dist/commands/index.js" } }, "sha512-Es59Dvq3/eF726+SOHPiKtlJ87UFmyJZI4gpeciW/fA/3ZwOWws2Mm3oD6eZnOj9BP/zlBKOWA9DPTl35IP3lg=="],
|
||||||
|
|
||||||
"@oven/bun-darwin-aarch64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u2wAM9RzqyF4baO5oFVhcFUVWO1CCMLG6yC5/aPWCYGZPfL5ULMbeHt+0/3I0h3yaPx1m+PKSANO/61jPtDZcQ=="],
|
"@oven/bun-darwin-aarch64": ["@oven/[email protected]", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u2wAM9RzqyF4baO5oFVhcFUVWO1CCMLG6yC5/aPWCYGZPfL5ULMbeHt+0/3I0h3yaPx1m+PKSANO/61jPtDZcQ=="],
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@moduletrace/bunext": "^1.3.4",
|
"@moduletrace/bunext": "^1.3.5",
|
||||||
"lucide-react": "^1.59.0"
|
"lucide-react": "^1.59.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -195,6 +195,14 @@ export default function ElementsPage() {
|
|||||||
<Span size="small" variant="faded">
|
<Span size="small" variant="faded">
|
||||||
Page {page} of {Math.ceil(42 / 5)}
|
Page {page} of {Math.ceil(42 / 5)}
|
||||||
</Span>
|
</Span>
|
||||||
|
<Border className="w-full max-w-xs">
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
setPage={setPage}
|
||||||
|
count={400}
|
||||||
|
limit={5}
|
||||||
|
/>
|
||||||
|
</Border>
|
||||||
</Stack>
|
</Stack>
|
||||||
</DemoSection>
|
</DemoSection>
|
||||||
|
|
||||||
|
|||||||
+26
-1
@@ -180,6 +180,31 @@ export default function FormsPage() {
|
|||||||
{ value: "team", title: "Team" },
|
{ value: "team", title: "Team" },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Radios
|
||||||
|
name="Long labels"
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
value: "clamped",
|
||||||
|
title: "This radio label is far too long to fit on a single line, so it is clamped to one line with an ellipsis - hover it to see the full text.",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{ value: "short", title: "Short" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Radios
|
||||||
|
name="Wrapped labels"
|
||||||
|
wrap
|
||||||
|
values={[
|
||||||
|
{
|
||||||
|
value: "wrapped",
|
||||||
|
title: "This radio label is far too long to fit on a single line, but the wrap prop lets it flow onto as many lines as it needs.",
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
{ value: "short", title: "Short" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DemoSection>
|
</DemoSection>
|
||||||
|
|
||||||
@@ -241,7 +266,7 @@ export default function FormsPage() {
|
|||||||
? JSON.stringify(submitted, null, 2)
|
? JSON.stringify(submitted, null, 2)
|
||||||
: "Submit the form to see the collected data."}
|
: "Submit the form to see the collected data."}
|
||||||
</pre>
|
</pre>
|
||||||
<Row className="items-start gap-2">
|
<Row className="items-start gap-2 flex-nowrap">
|
||||||
<Info size={16} className="mt-0.5 shrink-0" />
|
<Info size={16} className="mt-0.5 shrink-0" />
|
||||||
<Span size="small" variant="faded">
|
<Span size="small" variant="faded">
|
||||||
Checkbox and Toggle are div-based and are not
|
Checkbox and Toggle are div-based and are not
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@moduletrace/twui",
|
"name": "@moduletrace/twui",
|
||||||
"version": "0.1.9",
|
"version": "0.1.10",
|
||||||
"description": "A modular skeletal React component framework for Tailwind CSS v4",
|
"description": "A modular skeletal React component framework for Tailwind CSS v4",
|
||||||
"author": "Benjamin Toby",
|
"author": "Benjamin Toby",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ type Props = ComponentProps<typeof Row> & {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* # Pagination Component
|
* # Pagination Component
|
||||||
* @param param0
|
* Stays within its parent's width: the page buttons scroll horizontally
|
||||||
* @returns
|
* when there are too many to fit.
|
||||||
|
* @className twui-pagination
|
||||||
*/
|
*/
|
||||||
export default function Pagination({
|
export default function Pagination({
|
||||||
count,
|
count,
|
||||||
@@ -44,7 +45,7 @@ export default function Pagination({
|
|||||||
}}
|
}}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
className="p-1"
|
className="p-1 shrink-0"
|
||||||
disabled={isLimit}
|
disabled={isLimit}
|
||||||
>
|
>
|
||||||
<ChevronRight size={20} />
|
<ChevronRight size={20} />
|
||||||
@@ -55,7 +56,7 @@ export default function Pagination({
|
|||||||
<Row
|
<Row
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"w-full justify-between md:flex-nowrap",
|
"w-full min-w-0 max-w-full justify-between md:flex-nowrap twui-pagination",
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -68,7 +69,7 @@ export default function Pagination({
|
|||||||
}}
|
}}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
size="small"
|
size="small"
|
||||||
className="p-1"
|
className="p-1 shrink-0"
|
||||||
disabled={page == 1}
|
disabled={page == 1}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={20} />
|
<ChevronLeft size={20} />
|
||||||
@@ -79,20 +80,20 @@ export default function Pagination({
|
|||||||
|
|
||||||
<Row
|
<Row
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"gap-6 w-full md:flex-nowrap justify-center",
|
"gap-6 w-full min-w-0 flex-1 md:flex-nowrap justify-center",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Span
|
<Span
|
||||||
size="small"
|
size="small"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
className="whitespace-nowrap"
|
className="whitespace-nowrap shrink-0"
|
||||||
>
|
>
|
||||||
Page {page} / {pages}
|
Page {page} / {pages}
|
||||||
</Span>
|
</Span>
|
||||||
{pages > 1 && (
|
{pages > 1 && (
|
||||||
<Row
|
<Row
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex-nowrap overflow-x-auto p-1 max-w-[90%]",
|
"flex-nowrap overflow-x-auto p-1 min-w-0 max-w-[90%]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{Array(pages)
|
{Array(pages)
|
||||||
|
|||||||
+58
-4
@@ -28,11 +28,23 @@ export type TWUI_FORM_RADIO_PROPS = {
|
|||||||
>;
|
>;
|
||||||
wrapperProps?: ComponentProps<typeof Row>;
|
wrapperProps?: ComponentProps<typeof Row>;
|
||||||
changeHandler?: (value: string) => void;
|
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
|
* # 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({
|
export default function Radios({
|
||||||
values,
|
values,
|
||||||
@@ -41,6 +53,7 @@ export default function Radios({
|
|||||||
labelProps,
|
labelProps,
|
||||||
wrapperProps,
|
wrapperProps,
|
||||||
changeHandler,
|
changeHandler,
|
||||||
|
wrap,
|
||||||
}: TWUI_FORM_RADIO_PROPS) {
|
}: TWUI_FORM_RADIO_PROPS) {
|
||||||
const finalName = twuiSlugify(name);
|
const finalName = twuiSlugify(name);
|
||||||
const finalTitle = twuiSlugToNormalText(finalName);
|
const finalTitle = twuiSlugToNormalText(finalName);
|
||||||
@@ -49,14 +62,28 @@ export default function Radios({
|
|||||||
<Row
|
<Row
|
||||||
title={finalTitle}
|
title={finalTitle}
|
||||||
{...wrapperProps}
|
{...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) => {
|
{values.map((v, i) => {
|
||||||
const valueName = twuiSlugify(`${finalName}-${v.value}`);
|
const valueName = twuiSlugify(`${finalName}-${v.value}`);
|
||||||
const valueTitle = v.title || twuiSlugToNormalText(v.value);
|
const valueTitle = v.title || twuiSlugToNormalText(v.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row key={i} className="gap-1.5">
|
<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
|
<input
|
||||||
id={valueName}
|
id={valueName}
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -67,8 +94,35 @@ export default function Radios({
|
|||||||
changeHandler?.(targetValue);
|
changeHandler?.(targetValue);
|
||||||
}}
|
}}
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
|
className={twMerge(
|
||||||
|
"peer sr-only",
|
||||||
|
inputProps?.className,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={valueName} {...labelProps}>
|
<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}
|
{valueTitle}
|
||||||
</label>
|
</label>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
Reference in New Issue
Block a user