Add bunext playground for local component development

Runs at http://localhost:7010 via bun run dev. The playground imports the library from src through a committed symlink and tsconfig path alias, so component edits hot-reload without a build step. It has its own install root and is excluded from the published package.
This commit is contained in:
2026-10-19 04:23:07 +01:00
parent 46049c5e15
commit 73a25f54c2
18 changed files with 1348 additions and 5 deletions
+376
View File
@@ -0,0 +1,376 @@
import { useState } from "react";
import {
ArrowUpRight,
Bell,
Box,
CheckCircle2,
ChevronDown,
Inbox,
} from "lucide-react";
import Border from "@moduletrace/twui/elements/Border";
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
import Card from "@moduletrace/twui/elements/Card";
import CheckBulletPoints from "@moduletrace/twui/elements/CheckBulletPoints";
import CodeBlock from "@moduletrace/twui/elements/CodeBlock";
import Dropdown from "@moduletrace/twui/elements/Dropdown";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import Loading from "@moduletrace/twui/elements/Loading";
import Modal from "@moduletrace/twui/elements/Modal";
import Pagination from "@moduletrace/twui/elements/Pagination";
import Paper from "@moduletrace/twui/elements/Paper";
import Popover from "@moduletrace/twui/elements/Popover";
import StarRating from "@moduletrace/twui/elements/StarRating";
import Table from "@moduletrace/twui/elements/Table";
import Tabs from "@moduletrace/twui/elements/Tabs";
import Tag from "@moduletrace/twui/elements/Tag";
import Toast from "@moduletrace/twui/elements/Toast";
import Button from "@moduletrace/twui/layout/Button";
import Divider from "@moduletrace/twui/layout/Divider";
import H1 from "@moduletrace/twui/layout/H1";
import P from "@moduletrace/twui/layout/P";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { TWUIButtonColors } from "@moduletrace/twui/layout/Button";
import DemoSection from "../(components)/DemoSection";
const TABLE_DATA = [
{ component: "Button", folder: "layout", status: "stable" },
{ component: "Input", folder: "form", status: "stable" },
{ component: "Tabs", folder: "elements", status: "stable" },
{ component: "AceEditor", folder: "editors", status: "optional peer" },
];
const TAG_COLORS = TWUIButtonColors.filter((color) =>
["primary", "secondary", "success", "error", "warning", "gray"].includes(
color,
),
);
export default function ElementsPage() {
const [page, setPage] = useState(1);
const [toastOpen, setToastOpen] = useState(false);
const [rating, setRating] = useState(3);
return (
<Stack className="w-full gap-10">
<Stack className="gap-2">
<H1 className="!mb-0">Elements</H1>
<P className="!pt-0 opacity-70">
Composite components. Overlays render into portals appended
to <code>document.body</code>, so they escape any stacking
context in the page.
</P>
</Stack>
<DemoSection
title="Cards, tags and surfaces"
description="Card is a link when href is passed. Tag reuses the button color palette."
>
<Stack className="w-full gap-4">
<Row className="items-stretch gap-4">
<Card className="w-full max-w-sm flex-col items-start gap-2">
<Span className="font-semibold">Plain card</Span>
<Span size="small" variant="faded">
A bordered surface with no link behaviour.
</Span>
</Card>
<Card
href="#"
className="w-full max-w-sm flex-col items-start gap-2"
>
<Span className="font-semibold">
Linked card
<ArrowUpRight
size={14}
className="ml-1 inline-block"
/>
</Span>
<Span size="small" variant="faded">
The whole card is clickable.
</Span>
</Card>
<Paper className="w-full max-w-sm">
<Span className="font-semibold">Paper</Span>
<Span size="small" variant="faded">
Used for grouped content and forms.
</Span>
</Paper>
</Row>
<Row className="gap-3">
{TAG_COLORS.map((color) => (
<Tag key={color} color={color}>
{color}
</Tag>
))}
{TAG_COLORS.map((color) => (
<Tag
key={`${color}-outlined`}
color={color}
variant="outlined"
>
{color}
</Tag>
))}
</Row>
<Border className="w-full">
<Row className="w-full justify-between">
<Span size="small">
Border with default spacing
</Span>
<Tag color="gray" variant="ghost">
variant matrix
</Tag>
</Row>
</Border>
</Stack>
</DemoSection>
<DemoSection
title="Tabs"
description="Tab content stays mounted; switching is client-side state."
>
<Tabs
className="w-full"
tabsContentArray={[
{
title: "Overview",
content: (
<P className="!pt-2">
The first tab is active by default. Tab
values are slugified from the title unless
you provide one.
</P>
),
},
{
title: "Details",
content: (
<CheckBulletPoints
className="pt-2"
bulletPoints={[
{
title: "Accepts an array of tabs",
},
{
title: "Supports a default value",
},
{
title: "Can sync to the URL hash",
},
]}
/>
),
},
{
title: "Disabled-looking",
content: (
<P className="!pt-2 opacity-60">
This is just a third tab; there is no
disabled state in the current API.
</P>
),
},
]}
/>
</DemoSection>
<DemoSection
title="Table and pagination"
description="Table derives columns from the keys of the first row."
>
<Stack className="w-full gap-4">
<Border className="w-full overflow-hidden !p-0">
<Table data={TABLE_DATA} />
</Border>
<Pagination
page={page}
setPage={setPage}
count={42}
limit={5}
className="justify-center"
/>
<Span size="small" variant="faded">
Page {page} of {Math.ceil(42 / 5)}
</Span>
</Stack>
</DemoSection>
<DemoSection
title="Overlays"
description="Modal and Popover share the same implementation; Popover renders in place while Modal is a full dialog."
>
<Row className="items-start gap-4">
<OverlayDemo />
</Row>
</DemoSection>
<DemoSection
title="Dropdown"
description="A lightweight positioned panel with click/hover targets."
>
<Dropdown
target={
<Button
title="Open dropdown"
color="gray"
variant="outlined"
afterIcon={<ChevronDown size={16} />}
>
Actions
</Button>
}
contentWrapperProps={{ className: "min-w-48" }}
>
<Stack className="gap-1">
<Span size="small">Edit item</Span>
<Span size="small">Duplicate item</Span>
<Divider />
<Span size="small" className="text-error">
Delete item
</Span>
</Stack>
</Dropdown>
</DemoSection>
<DemoSection
title="Toasts"
description="Toast portals into a body-level root and auto-closes after four seconds."
>
<Row className="gap-3">
<Button
title="Show success toast"
color="success"
onClick={() => setToastOpen(true)}
beforeIcon={<Bell size={16} />}
>
Show toast
</Button>
<Span size="small" variant="faded">
Hovering the toast pauses the timer.
</Span>
</Row>
<Toast
open={toastOpen}
setOpen={setToastOpen}
color="success"
>
Build finished successfully.
</Toast>
</DemoSection>
<DemoSection
title="Status and feedback"
description="Loading, empty states, code blocks and ratings."
>
<div className="grid w-full gap-6 md:grid-cols-2">
<Stack className="w-full gap-3">
<Span className="font-semibold">Loading</Span>
<Row className="gap-4">
<Loading size="smaller" />
<Loading size="small" />
<Loading size="normal" />
<Loading size="large" />
</Row>
<Span className="font-semibold">Star rating</Span>
<StarRating
value={rating}
allowRating
changeHandler={setRating}
/>
<Span size="small" variant="faded">
Current rating: {rating}
</Span>
</Stack>
<EmptyContent
title="No results — EmptyContent takes an optional link and icon."
icon={<Inbox size={24} />}
url="#"
/>
</div>
<Stack className="mt-6 w-full gap-2">
<Span className="font-semibold">Code block</Span>
<CodeBlock data-title="install">
{`bun add @moduletrace/twui`}
</CodeBlock>
</Stack>
</DemoSection>
<DemoSection
title="Breadcrumbs"
description="Infer crumbs from the current path, or pass pageUrl to render deterministically."
>
<Stack className="w-full gap-4">
<Breadcrumbs pageUrl="/elements/overlays/modal" />
<Row className="gap-2">
<CheckCircle2 size={16} className="text-success" />
<Span size="small" variant="faded">
The divider and back button are configurable.
</Span>
</Row>
</Stack>
</DemoSection>
</Stack>
);
}
function OverlayDemo() {
const [modalOpen, setModalOpen] = useState(false);
return (
<>
<Button
title="Open modal"
onClick={() => setModalOpen(true)}
beforeIcon={<Box size={16} />}
>
Open modal
</Button>
<Modal
open={modalOpen}
setOpen={setModalOpen}
target={null}
no_cancel_button
>
<Stack className="w-full gap-3">
<Span className="text-lg font-semibold">Modal content</Span>
<P className="!pt-0">
The modal is controlled by the <code>open</code> and{" "}
<code>setOpen</code> props. Without a target it renders
nothing until you set it.
</P>
<Row className="justify-end">
<Button
title="Close modal"
color="gray"
variant="outlined"
onClick={() => setModalOpen(false)}
>
Close
</Button>
</Row>
</Stack>
</Modal>
<Popover
trigger="hover"
target={
<Tag color="secondary" variant="outlined">
Hover for a popover
</Tag>
}
>
<Stack className="w-56 gap-1">
<Span className="font-semibold">Popover</Span>
<Span size="small" variant="faded">
Same props as Modal, plus hover trigger support.
</Span>
</Stack>
</Popover>
</>
);
}