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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 1 addition & 3 deletions packages/propel/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,9 @@ const withTheme: Decorator = (Story, context) => {
const theme: Theme = THEMES.includes(candidate as Theme) ? (candidate as Theme) : TEST_THEME;
useLayoutEffect(() => {
const el = document.documentElement;
const previous = el.dataset.theme;
el.dataset.theme = theme;
return () => {
if (previous == null) delete el.dataset.theme;
else el.dataset.theme = previous;
delete el.dataset.theme;
};
Comment thread
lifeiscontent marked this conversation as resolved.
}, [theme]);
return <Story />;
Expand Down
11 changes: 11 additions & 0 deletions packages/propel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ vp test # run tests
folder; static wildcard `exports` (`./components/*`, `./hooks/*`) expose them
as `@plane/propel/components/<name>` / `hooks/<name>` automatically — no
`exports` edits and no barrel to maintain when you add a folder.
- **Component folders have a public boundary.** `index.tsx` only re-exports the
public API for that component folder. Public components and public child
components live in sibling kebab-case files (`button.tsx`,
`accordion-trigger.tsx`, `table-cell.tsx`). Do not use `Object.assign` or
namespace-style APIs such as `Foo.Bar`; export `FooBar` as its own component
instead.
- **Keep shared implementation private and accurately named.** Shared class
maps, CVA variants, and helpers should live in private sibling files such as
`*-styles.ts`, `*-shared.tsx`, or a real `*-context.tsx` when React context is
involved. Do not create public child files that only re-export from a
monolithic parent file.
- `vp pack` needs at least one component or hook to build (a component library
with zero entries has nothing to compile).
- Compose classes with [`clsx`](https://github.com/lukeed/clsx) only — **do not
Expand Down
8 changes: 8 additions & 0 deletions packages/propel/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@
"**/*.css"
],
"exports": {
"./base/*": {
"types": "./dist/base/*/index.d.ts",
"import": "./dist/base/*/index.js"
},
"./ui/*": {
"types": "./dist/ui/*/index.d.ts",
"import": "./dist/ui/*/index.js"
},
"./components/*": {
"types": "./dist/components/*/index.d.ts",
"import": "./dist/components/*/index.js"
Expand Down
1 change: 1 addition & 0 deletions packages/propel/src/base/text-area/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BaseTextArea, type BaseTextAreaProps } from "./text-area";
32 changes: 32 additions & 0 deletions packages/propel/src/base/text-area/text-area.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Field as BaseField } from "@base-ui/react/field";
import * as React from "react";

type BaseFieldControlProps = React.ComponentProps<typeof BaseField.Control>;

type NativeTextAreaProps = Omit<
React.ComponentProps<"textarea">,
keyof BaseFieldControlProps | "children" | "className" | "style"
>;

export type BaseTextAreaProps = BaseFieldControlProps &
NativeTextAreaProps & {
/** The default value of the textarea. Use when uncontrolled. */
defaultValue?: React.ComponentProps<"textarea">["defaultValue"];
/** The value of the textarea. Use when controlled. */
value?: React.ComponentProps<"textarea">["value"];
};

/**
* A native textarea element that automatically works with Field. Renders a `<textarea>` element.
*
* Adapted from Base UI Input.
*/
export const BaseTextArea = React.forwardRef<HTMLElement, BaseTextAreaProps>(
function BaseTextArea(props, forwardedRef) {
return <BaseField.Control ref={forwardedRef} render={<textarea />} {...props} />;
},
);

if (process.env.NODE_ENV !== "production") {
BaseTextArea.displayName = "BaseTextArea";
}
18 changes: 18 additions & 0 deletions packages/propel/src/components/accordion/accordion-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
AccordionPanel as AccordionPanelRoot,
type AccordionPanelProps as AccordionPanelRootProps,
} from "../../ui/accordion";

export type AccordionPanelProps = AccordionPanelRootProps;

/**
* The ready-made accordion panel: composes the atomic `AccordionPanel` and adds the inner padding
* wrapper so content is inset from the trigger's edges.
*/
export function AccordionPanel({ children, ...props }: AccordionPanelProps) {
return (
<AccordionPanelRoot {...props}>
<div className="px-3 pb-3">{children}</div>
</AccordionPanelRoot>
);
}
37 changes: 37 additions & 0 deletions packages/propel/src/components/accordion/accordion-trigger.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { ChevronDown } from "lucide-react";
import type * as React from "react";

import { NodeSlot } from "../../internal/node-slot";
import {
AccordionTrigger as AccordionTriggerRoot,
type AccordionTriggerProps as AccordionTriggerRootProps,
} from "../../ui/accordion";

export type AccordionTriggerProps = AccordionTriggerRootProps & {
/**
* Node rendered before the label (inline-start), matching the Figma header icon. Sized to the
* trigger's `--node-size`. Decorative, kept out of the name.
*/
inlineStartNode?: React.ReactNode;
};

/**
* The ready-made accordion trigger: composes the atomic `AccordionTrigger` and lays out an optional
* `inlineStartNode`, the label, and the chevron that rotates when the panel opens.
*/
export function AccordionTrigger({ inlineStartNode, children, ...props }: AccordionTriggerProps) {
return (
<AccordionTriggerRoot {...props}>
{inlineStartNode ? (
<NodeSlot aria-hidden className="text-icon-secondary">
{inlineStartNode}
</NodeSlot>
) : null}
<span className="flex-1">{children}</span>
<ChevronDown
aria-hidden
className="size-3.5 shrink-0 text-icon-secondary transition-transform duration-200 group-data-panel-open:rotate-180"
/>
</AccordionTriggerRoot>
);
}
40 changes: 29 additions & 11 deletions packages/propel/src/components/accordion/accordion.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { CircleHelp } from "lucide-react";
import { expect } from "storybook/test";

import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "./index";
import {
Accordion,
AccordionHeader,
AccordionItem,
AccordionPanel,
AccordionTrigger,
} from "./index";

// Design-review convention — when to add a pseudo-states "States" story:
// * Only components that style interaction via CSS `hover:` / `active:` /
Expand All @@ -19,7 +25,7 @@ import { Accordion, AccordionItem, AccordionPanel, AccordionTrigger } from "./in
const meta = {
title: "Components/Accordion",
component: Accordion,
subcomponents: { AccordionItem, AccordionTrigger, AccordionPanel },
subcomponents: { AccordionItem, AccordionHeader, AccordionTrigger, AccordionPanel },
// Give the centered canvas a sensible width so the full-width accordion has room.
decorators: [
(Story) => (
Expand Down Expand Up @@ -63,23 +69,27 @@ export const Default: Story = {
<Accordion {...args} defaultValue={["what"]}>
{ITEMS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.label}</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger>{item.label}</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
</Accordion>
),
};

/** Each trigger can carry a `leadingIcon`, matching the Figma header icon. */
/** Each trigger can carry a `inlineStartNode`, matching the Figma header icon. */
export const WithIcon: Story = {
render: (args) => (
<Accordion {...args} defaultValue={["what"]}>
{ITEMS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger leadingIcon={<CircleHelp aria-hidden className="size-4" />}>
{item.label}
</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger inlineStartNode={<CircleHelp aria-hidden className="size-4" />}>
{item.label}
</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
Expand Down Expand Up @@ -117,7 +127,9 @@ export const States: Story = {
] as const
).map(([label, id]) => (
<AccordionItem key={label} value={id}>
<AccordionTrigger id={id}>{label} — What is Plane?</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger id={id}>{label} — What is Plane?</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Plane is an open-source project management tool.</AccordionPanel>
</AccordionItem>
))}
Expand All @@ -133,7 +145,9 @@ export const MultipleItems: Story = {
<Accordion {...args} defaultValue={["what", "pricing"]}>
{ITEMS.map((item) => (
<AccordionItem key={item.value} value={item.value}>
<AccordionTrigger>{item.label}</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger>{item.label}</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>{item.body}</AccordionPanel>
</AccordionItem>
))}
Expand All @@ -150,7 +164,9 @@ export const Interaction: Story = {
render: () => (
<Accordion>
<AccordionItem value="a">
<AccordionTrigger>Section A</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger>Section A</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Panel A content</AccordionPanel>
</AccordionItem>
</Accordion>
Expand Down Expand Up @@ -187,7 +203,9 @@ export const KeyboardToggle: Story = {
render: () => (
<Accordion>
<AccordionItem value="a">
<AccordionTrigger>Section A</AccordionTrigger>
<AccordionHeader>
<AccordionTrigger>Section A</AccordionTrigger>
</AccordionHeader>
<AccordionPanel>Panel A content</AccordionPanel>
</AccordionItem>
</Accordion>
Expand Down
116 changes: 11 additions & 105 deletions packages/propel/src/components/accordion/index.tsx
Original file line number Diff line number Diff line change
@@ -1,105 +1,11 @@
import { Accordion as BaseAccordion } from "@base-ui/react/accordion";
import { cx } from "class-variance-authority";
import { ChevronDown } from "lucide-react";
import type * as React from "react";

// Accordion is a structural component: the Figma "Accordion" spec only defines the
// collapsed/hover/expanded states (which Base UI drives as state, not props) — so
// there are no styling axes (variant/tone/magnitude) to expose. The header row uses
// `border/subtle` divider, `spacing/3` padding, `spacing/2` gap, `text/14`
// medium `text/primary` label, a `background/layer/transparent` surface that goes to
// `background/layer/transparent-hover` on hover, and an `icon/placeholder` chevron.

export type AccordionProps = Omit<
React.ComponentProps<typeof BaseAccordion.Root>,
"className" | "render" | "style"
>;

/**
* Groups a set of `AccordionItem`s. Single-open by default; pass `multiple` to allow several panels
* open at once. Use `defaultValue` (uncontrolled) or `value` + `onValueChange` (controlled) to
* drive which items are expanded.
*/
export function Accordion(props: AccordionProps) {
return <BaseAccordion.Root className="flex w-full flex-col" {...props} />;
}

export type AccordionItemProps = Omit<
React.ComponentProps<typeof BaseAccordion.Item>,
"className" | "render" | "style"
>;

/** A single collapsible section: pairs an `AccordionTrigger` with an `AccordionPanel`. */
export function AccordionItem(props: AccordionItemProps) {
return <BaseAccordion.Item className="border-b border-subtle" {...props} />;
}

export type AccordionTriggerProps = Omit<
React.ComponentProps<typeof BaseAccordion.Trigger>,
"className" | "render" | "style"
> & {
/**
* Optional icon shown before the label, matching the Figma header icon. Named `leadingIcon` (not
* `icon`) to match Button/Input, so a `trailingIcon` can be added later without a breaking
* rename.
*/
leadingIcon?: React.ReactNode;
};

/**
* The clickable header that opens and closes its panel. Renders an optional `leadingIcon`, the
* label, and a chevron that rotates when the panel is open. Base UI sets `aria-expanded` and
* `aria-controls` for you.
*/
export function AccordionTrigger({ leadingIcon, children, ...props }: AccordionTriggerProps) {
return (
<BaseAccordion.Header className="flex">
<BaseAccordion.Trigger
className={cx(
"group flex flex-1 items-center gap-2 p-3 text-start",
"text-14 font-medium text-primary",
"bg-layer-transparent hover:bg-layer-transparent-hover",
"cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-accent-strong",
"disabled:cursor-not-allowed disabled:opacity-60",
)}
{...props}
>
{leadingIcon ? (
<span className="flex size-4 shrink-0 items-center justify-center text-icon-secondary">
{leadingIcon}
</span>
) : null}
<span className="flex-1">{children}</span>
<ChevronDown
aria-hidden
className="size-3.5 shrink-0 text-icon-secondary transition-transform duration-200 group-data-panel-open:rotate-180"
/>
</BaseAccordion.Trigger>
</BaseAccordion.Header>
);
}

export type AccordionPanelProps = Omit<
React.ComponentProps<typeof BaseAccordion.Panel>,
"className" | "render" | "style"
>;

/**
* The collapsible content region for an item. Animates open/closed using Base UI's
* `--accordion-panel-height` so the height transitions smoothly.
*/
export function AccordionPanel({ children, ...props }: AccordionPanelProps) {
return (
<BaseAccordion.Panel
className={cx(
"h-(--accordion-panel-height) overflow-hidden",
"text-14 text-secondary",
"transition-[height] duration-200 ease-out",
"data-ending-style:h-0 data-starting-style:h-0",
)}
{...props}
>
<div className="px-3 pb-3">{children}</div>
</BaseAccordion.Panel>
);
}
export { AccordionPanel, type AccordionPanelProps } from "./accordion-panel";
export { AccordionTrigger, type AccordionTriggerProps } from "./accordion-trigger";
// Re-export the atomic structural parts so a full accordion is importable from this convenience.
export {
Accordion,
AccordionHeader,
type AccordionHeaderProps,
AccordionItem,
type AccordionItemProps,
type AccordionProps,
} from "../../ui/accordion";
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
AlertDialogBackdrop,
AlertDialogPopup,
type AlertDialogPopupProps,
AlertDialogPortal,
AlertDialogViewport,
} from "../../ui/alert-dialog";

export type AlertDialogContentProps = AlertDialogPopupProps;

/**
* Convenience that composes the alert dialog overlay boilerplate — portal, backdrop, centering
* viewport, and the centered popup — so a consumer only writes the trigger and the popup body.
*/
export function AlertDialogContent({ children, ...props }: AlertDialogContentProps) {
return (
<AlertDialogPortal>
<AlertDialogBackdrop />
<AlertDialogViewport>
<AlertDialogPopup {...props}>{children}</AlertDialogPopup>
</AlertDialogViewport>
</AlertDialogPortal>
);
}
Loading