Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions packages/components/src/components/columns/columns.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import type React from "react";
import { Columns } from "./columns";
import { COL_OPTIONS } from "./constants";
import { type AUTO_MODES, COL_OPTIONS } from "./constants";

const Box = ({ children }: { children?: React.ReactNode }) => (
<div className="flex h-24 items-center justify-center rounded-lg bg-stone-200 text-sm text-stone-500 dark:bg-stone-800">
<div className="flex h-24 items-center justify-center rounded-lg bg-stone-200 font-medium text-sm text-stone-500 dark:bg-stone-800">
{children}
</div>
);
Expand Down Expand Up @@ -33,6 +33,10 @@ const meta: Meta<typeof Columns> = {
control: "select",
options: [...COL_OPTIONS],
},
layout: {
control: "select",
options: ["static", "fill", "fit"],
},
children: {
table: { disable: true },
},
Expand Down Expand Up @@ -60,6 +64,45 @@ export const FourColumns: Story = {
args: { cols: 4 },
};

/**
* Side-by-side comparison showing the difference between `layout`.
*
* With 3 items in a 4-column grid:
* - `static` (default): no dynamic wrapping, 1 column on small screens
* - `fit`: items stretch to fill all available space
* - `fill`: items keep their column width, leaving empty tracks visible
*
* Resize the browser window to see columns wrap responsively in both cases.
*/
export const LayoutModes: StoryObj = {
render: () => (
<div className="@container flex flex-col gap-8">
{Object.entries({
static: "items fall back to default behavior",
fit: "items stretch to fill row",
fill: "empty columns preserve space",
} satisfies Record<keyof typeof AUTO_MODES, string>).map(
([layout, description]) => (
<div key={layout}>
<p className="mb-2 font-medium text-sm text-stone-600 dark:text-stone-400">
<code>layout="{layout}"</code> → {description}
</p>
<Columns
className="[--col-min-w:100px]"
cols={4}
layout={layout as "fit" | "fill"}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type cast incorrectly excludes "static" layout value

Low Severity

The cast layout as "fit" | "fill" omits "static", but the Object.entries loop iterates over all three keys including "static". When layout is "static", this is a type lie — it narrows the type to a union that doesn't include the actual runtime value. The cast needs to be layout as "static" | "fit" | "fill" (or layout as ColumnsProps["layout"]) to match the iterated keys.

Fix in Cursor Fix in Web

>
<Box>1</Box>
<Box>2</Box>
<Box>3</Box>
</Columns>
</div>
)
)}
</div>
),
};

export const WithCustomClassName: Story = {
args: {
cols: 2,
Expand Down
32 changes: 26 additions & 6 deletions packages/components/src/components/columns/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,45 @@ import type React from "react";
import { Classes } from "@/constants/selectors";
import { cn } from "@/utils/cn";
import type { ColCount } from "./constants";
import {
AUTO_MODES,
COL_CLASSES,
DEFAULT_AUTO_MODE,
DEFAULT_COLS,
DEFAULT_MIN_COL_WIDTH,
} from "./constants";

type ColumnsProps = {
children: React.ReactNode;
cols?: ColCount | `${ColCount}`;
layout?: "static" | "fill" | "fit";
className?: string;
};

const Columns = ({ children, className, cols = 2 }: ColumnsProps) => {
const Columns = ({
children,
className,
cols = DEFAULT_COLS,
layout = DEFAULT_AUTO_MODE,
}: ColumnsProps) => {
const numCols = Number(cols) || DEFAULT_COLS;
const autoMode = AUTO_MODES[layout];
const minWidth = `var(--col-min-w, ${DEFAULT_MIN_COL_WIDTH})`;
const autoStyle = autoMode
? {
gridTemplateColumns: `repeat(${autoMode},minmax(max(${minWidth},calc(100%/${numCols} - 1rem)),1fr))`,
}
: undefined;

return (
<div
className={cn(
Classes.Columns,
"prose dark:prose-invert grid gap-4",
Number(cols) === 1 && "sm:grid-cols-1",
Number(cols) === 2 && "sm:grid-cols-2",
Number(cols) === 3 && "sm:grid-cols-3",
Number(cols) === 4 && "sm:grid-cols-4",
"prose dark:prose-invert grid max-w-none gap-4",
!autoMode && (COL_CLASSES[numCols] ?? ""),
className
)}
style={autoStyle}
>
{children}
</div>
Expand Down
27 changes: 26 additions & 1 deletion packages/components/src/components/columns/constants.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
const COL_OPTIONS = [1, 2, 3, 4] as const;
type ColCount = (typeof COL_OPTIONS)[number];

export { COL_OPTIONS };
const DEFAULT_COLS = 2;
const DEFAULT_MIN_COL_WIDTH = "200px";
const DEFAULT_AUTO_MODE = "static";

const COL_CLASSES: Record<number, string> = {
1: "sm:grid-cols-1",
2: "sm:grid-cols-2",
3: "sm:grid-cols-3",
4: "sm:grid-cols-4",
};

const AUTO_MODES = {
static: undefined,
fill: "auto-fill",
fit: "auto-fit",
} as const;

export {
COL_OPTIONS,
COL_CLASSES,
AUTO_MODES,
DEFAULT_COLS,
DEFAULT_MIN_COL_WIDTH,
DEFAULT_AUTO_MODE,
};

export type { ColCount };
Loading