Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fruity-seas-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@perimetre/ui': minor
---

Add `Collapse` component — a brand-aware, motion-animated disclosure with `CollapseTrigger`, `CollapseHeading`, `CollapseEyebrow`, `CollapseTitle`, `CollapseIcon`, and `CollapseContent` subcomponents for rich-text bodies.
13 changes: 13 additions & 0 deletions packages/tokens/dist/brands/acorn.css
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@
--pui-color-border-rule-primary: var(--pui-primitive-color-overlay-9);
--pui-color-border-rule-secondary: var(--pui-primitive-color-overlay-5);
--pui-color-border-input: var(--pui-color-border-default);
--pui-color-border-accordion-first: var(--pui-color-border-baseline);
--pui-color-border-accordion-last: var(--pui-color-border-baseline);
--pui-color-border-accordion-between: var(--pui-color-border-baseline);
--pui-color-focus-outline: var(--pui-color-border-focus);
--pui-color-interactive-primary: var(--pui-primitive-color-primary-9);
--pui-color-interactive-on-primary: var(--pui-primitive-color-overlay-12);
Expand Down Expand Up @@ -266,6 +269,16 @@
--pui-typo-navbar-transform: none;
--pui-typo-alertbar-size: var(--pui-typo-small-size);
--pui-typo-alertbar-weight: var(--pui-typo-small-weight);
--pui-typo-accordion-title-size: var(--pui-typo-heading-5-size);
--pui-typo-accordion-title-leading: var(--pui-typo-heading-5-leading);
--pui-typo-accordion-title-weight: var(--pui-typo-heading-5-weight);
--pui-typo-accordion-title-tracking: var(--pui-typo-heading-5-tracking);
--pui-typo-accordion-title-transform: var(--pui-typo-heading-5-transform);
--pui-typo-accordion-content-size: var(--pui-typo-base-size);
--pui-typo-accordion-content-leading: var(--pui-typo-base-leading);
--pui-typo-accordion-content-weight: var(--pui-typo-base-weight);
--pui-typo-accordion-content-tracking: var(--pui-typo-base-tracking);
--pui-typo-accordion-content-transform: var(--pui-typo-base-transform);
--pui-duration-normal: var(--pui-primitive-duration-normal);
}
}
58 changes: 58 additions & 0 deletions packages/tokens/src/sets/semantic/base.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@
},
"input": {
"$value": "{pui.color.border.default}"
},
"accordion-first": {
"$value": "{pui.color.border.baseline}",
"$description": "Top edge of the first Accordion item."
},
"accordion-last": {
"$value": "{pui.color.border.baseline}",
"$description": "Bottom edge of the last Accordion item."
},
"accordion-between": {
"$value": "{pui.color.border.baseline}",
"$description": "Divider between adjacent Accordion items."
}
},
"focus-outline": {
Expand Down Expand Up @@ -751,6 +763,52 @@
"$value": "{pui.typo.small.weight}",
"$type": "fontWeights"
}
},
"accordion-title": {
"$description": "Accordion trigger title typography. Defaults to heading-5 — brands can scale up/down or change family.",
"size": {
"$value": "{pui.typo.heading-5.size}",
"$type": "fontSizes"
},
"leading": {
"$value": "{pui.typo.heading-5.leading}",
"$type": "lineHeights"
},
"weight": {
"$value": "{pui.typo.heading-5.weight}",
"$type": "fontWeights"
},
"tracking": {
"$value": "{pui.typo.heading-5.tracking}",
"$type": "letterSpacing"
},
"transform": {
"$value": "{pui.typo.heading-5.transform}",
"$type": "textCase"
}
},
"accordion-content": {
"$description": "Accordion content/rich-text body typography. Defaults to base.",
"size": {
"$value": "{pui.typo.base.size}",
"$type": "fontSizes"
},
"leading": {
"$value": "{pui.typo.base.leading}",
"$type": "lineHeights"
},
"weight": {
"$value": "{pui.typo.base.weight}",
"$type": "fontWeights"
},
"tracking": {
"$value": "{pui.typo.base.tracking}",
"$type": "letterSpacing"
},
"transform": {
"$value": "{pui.typo.base.transform}",
"$type": "textCase"
}
}
},
"duration": {
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/brands/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,11 @@
--color-pui-border-rule-primary: var(--pui-color-border-rule-primary);
--color-pui-border-rule-secondary: var(--pui-color-border-rule-secondary);
--color-pui-border-input: var(--pui-color-border-input);
--color-pui-border-accordion-first: var(--pui-color-border-accordion-first);
--color-pui-border-accordion-last: var(--pui-color-border-accordion-last);
--color-pui-border-accordion-between: var(
--pui-color-border-accordion-between
);

/* Interactive colors */
--color-pui-interactive-primary: var(--pui-color-interactive-primary);
Expand Down
176 changes: 176 additions & 0 deletions packages/ui/src/components/Accordion/Accordion.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import type { Story, StoryDefault } from '@ladle/react';
import Accordion, {
AccordionContent,
AccordionEyebrow,
AccordionHeading,
AccordionIcon,
AccordionRoot,
AccordionTitle,
AccordionTrigger
} from './index';

type Props = {
body?: string;
eyebrow?: string;
title?: string;
};

export default {
title: 'Components/Accordion',
args: {
eyebrow: 'How it works',
title: 'How does the subscription work?',
body: 'Sign up online, choose your plan, and we ship your first kit within 48 hours. You can pause or cancel any time from your account dashboard.'
},
argTypes: {
eyebrow: { control: { type: 'text' }, defaultValue: 'How it works' },
title: {
control: { type: 'text' },
defaultValue: 'How does the subscription work?'
},
body: {
control: { type: 'text' },
defaultValue:
'Sign up online, choose your plan, and we ship your first kit within 48 hours. You can pause or cancel any time from your account dashboard.'
}
}
} satisfies StoryDefault<Props>;

const SingleItem: React.FC<{ defaultOpen?: boolean } & Props> = ({
body,
defaultOpen,
eyebrow,
title
}) => (
<AccordionRoot
collapsible
defaultValue={defaultOpen ? 'item-1' : undefined}
type="single"
>
<Accordion value="item-1">
<AccordionTrigger>
<AccordionHeading>
<AccordionEyebrow>{eyebrow}</AccordionEyebrow>
<AccordionTitle>{title}</AccordionTitle>
</AccordionHeading>
<AccordionIcon />
</AccordionTrigger>
<AccordionContent>
<p>{body}</p>
</AccordionContent>
</Accordion>
</AccordionRoot>
);

export const Default: Story<Props> = (args) => (
<div className="pui:max-w-2xl">
<SingleItem {...args} />
</div>
);

export const OpenByDefault: Story<Props> = (args) => (
<div className="pui:max-w-2xl">
<SingleItem {...args} defaultOpen />
</div>
);

export const RichTextBody: Story<Props> = () => (
<div className="pui:max-w-2xl">
<AccordionRoot collapsible defaultValue="item-1" type="single">
<Accordion value="item-1">
<AccordionTrigger>
<AccordionHeading>
<AccordionEyebrow>Details</AccordionEyebrow>
<AccordionTitle>{`What's included in the box?`}</AccordionTitle>
</AccordionHeading>
<AccordionIcon />
</AccordionTrigger>
<AccordionContent>
<p className="pui:mb-2">Each kit ships with:</p>
<ul className="pui:list-disc pui:pl-5 pui:flex pui:flex-col pui:gap-1">
<li>One reusable container</li>
<li>A 30-day supply of refills</li>
<li>
<a className="pui:underline" href="https://example.com/guide">
A getting-started guide
</a>
</li>
</ul>
</AccordionContent>
</Accordion>
</AccordionRoot>
</div>
);

export const MultipleItems: Story<Props> = () => (
<div className="pui:max-w-2xl">
<AccordionRoot type="multiple">
{[
{
value: 'item-1',
eyebrow: 'How it works',
title: 'How does the subscription work?',
body: 'Sign up online, choose your plan, and we ship your first kit within 48 hours.'
},
{
value: 'item-2',
eyebrow: 'Shipping',
title: 'When will my order arrive?',
body: 'Most orders ship within 1-2 business days and arrive in 3-5 business days.'
},
{
value: 'item-3',
eyebrow: 'Returns',
title: 'What is your return policy?',
body: 'Unopened items can be returned within 30 days for a full refund.'
}
].map(({ body, eyebrow, title, value }) => (
<Accordion key={value} value={value}>
<AccordionTrigger>
<AccordionHeading>
<AccordionEyebrow>{eyebrow}</AccordionEyebrow>
<AccordionTitle>{title}</AccordionTitle>
</AccordionHeading>
<AccordionIcon />
</AccordionTrigger>
<AccordionContent>
<p>{body}</p>
</AccordionContent>
</Accordion>
))}
</AccordionRoot>
</div>
);

export const AllVariants: Story<Props> = (args) => (
<div className="pui:flex pui:flex-col pui:gap-8 pui:max-w-2xl">
<section>
<p className="pui:mb-2 pui:text-sm pui:text-pui-fg-muted">
Single, closed
</p>
<SingleItem {...args} />
</section>
<section>
<p className="pui:mb-2 pui:text-sm pui:text-pui-fg-muted">Single, open</p>
<SingleItem {...args} defaultOpen />
</section>
<section>
<p className="pui:mb-2 pui:text-sm pui:text-pui-fg-muted">
Custom heading level (h2)
</p>
<AccordionRoot collapsible type="single">
<Accordion value="item-1">
<AccordionTrigger headingLevel="h2">
<AccordionHeading>
<AccordionTitle>{args.title}</AccordionTitle>
</AccordionHeading>
<AccordionIcon />
</AccordionTrigger>
<AccordionContent>
<p>{args.body}</p>
</AccordionContent>
</Accordion>
</AccordionRoot>
</section>
</div>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { cva } from '@/lib/cva';

/**
* Acorn brand Accordion variants (default/base theme).
* Uses semantic tokens for themeable properties:
* - text-pui-fg-*: title/body/eyebrow colors
* - border-pui-border-accordion-*: per-position dividers (first / between / last)
* - --pui-typo-accordion-*: title and content typography (applied inline
* below — these tokens are accordion-specific, so no `@utility` is needed)
*/
export const accordionAcornVariants = cva({
base: [
'pui:flex pui:w-full pui:flex-col',
'pui:border-b pui:border-pui-border-accordion-between',
'pui:first:border-t pui:first:border-t-pui-border-accordion-first',
'pui:last:border-b-pui-border-accordion-last'
]
});

export const accordionTriggerAcornVariants = cva({
base: [
'group/accordion-trigger pui:group/accordion-trigger',
'pui:flex pui:w-full pui:items-center pui:justify-between pui:gap-4',
'pui:py-4 pui:text-left',
'pui:cursor-pointer pui:appearance-none pui:bg-transparent pui:border-0',
'pui:focus-visible:outline-none pui:focus-visible:shadow-pui-input-focus'
]
});

export const accordionHeadingAcornVariants = cva({
base: ['pui:flex pui:flex-col-reverse pui:gap-1']
});

export const accordionEyebrowAcornVariants = cva({
base: ['pui:typo-tagline pui:text-pui-fg-muted']
});

export const accordionTitleAcornVariants = cva({
base: [
'pui:text-pui-fg-default',
'pui:font-(family-name:--pui-typo-accordion-title-family,var(--pui-primitive-font-sans))',
'pui:text-(length:--pui-typo-accordion-title-size)',
'pui:leading-(--pui-typo-accordion-title-leading)',
'pui:font-(--pui-typo-accordion-title-weight)',
'pui:tracking-(--pui-typo-accordion-title-tracking)',
'pui:[text-transform:var(--pui-typo-accordion-title-transform)]'
]
});

export const accordionIconAcornVariants = cva({
base: ['pui:shrink-0 pui:text-pui-interactive-primary']
});

export const accordionIconBarAcornVariants = cva({
base: [
'pui:origin-center pui:transition-[transform,opacity]',
'pui:duration-pui-normal pui:ease-pui-out-quad',
'pui:motion-reduce:transition-none',
'pui:group-data-[state=open]/accordion-trigger:rotate-90',
'pui:group-data-[state=open]/accordion-trigger:opacity-0'
]
});

export const accordionContentAcornVariants = cva({
base: [
'pui:overflow-hidden',
'pui:data-[state=open]:animate-accordion-down',
'pui:data-[state=closed]:animate-accordion-up',
'pui:motion-reduce:animate-none'
]
});

export const accordionContentInnerAcornVariants = cva({
base: [
'pui:text-pui-fg-body pui:pb-4',
'pui:font-(family-name:--pui-typo-accordion-content-family,var(--pui-primitive-font-sans))',
'pui:text-(length:--pui-typo-accordion-content-size)',
'pui:leading-(--pui-typo-accordion-content-leading)',
'pui:font-(--pui-typo-accordion-content-weight)',
'pui:tracking-(--pui-typo-accordion-content-tracking)',
'pui:[text-transform:var(--pui-typo-accordion-content-transform)]',
'pui:animate-in pui:fade-in pui:duration-pui-normal pui:delay-75 pui:fill-mode-both pui:ease-pui-out-quad',
'pui:motion-reduce:animate-none'
]
});
Loading
Loading