diff --git a/.changeset/fruity-seas-grin.md b/.changeset/fruity-seas-grin.md new file mode 100644 index 0000000..0f15555 --- /dev/null +++ b/.changeset/fruity-seas-grin.md @@ -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. diff --git a/packages/tokens/dist/brands/acorn.css b/packages/tokens/dist/brands/acorn.css index 81de7cc..ba4b407 100644 --- a/packages/tokens/dist/brands/acorn.css +++ b/packages/tokens/dist/brands/acorn.css @@ -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); @@ -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); } } diff --git a/packages/tokens/src/sets/semantic/base.json b/packages/tokens/src/sets/semantic/base.json index c4812e9..3934908 100644 --- a/packages/tokens/src/sets/semantic/base.json +++ b/packages/tokens/src/sets/semantic/base.json @@ -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": { @@ -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": { diff --git a/packages/ui/src/brands/tailwind.css b/packages/ui/src/brands/tailwind.css index 1604372..00bf4b3 100644 --- a/packages/ui/src/brands/tailwind.css +++ b/packages/ui/src/brands/tailwind.css @@ -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); diff --git a/packages/ui/src/components/Accordion/Accordion.stories.tsx b/packages/ui/src/components/Accordion/Accordion.stories.tsx new file mode 100644 index 0000000..d655661 --- /dev/null +++ b/packages/ui/src/components/Accordion/Accordion.stories.tsx @@ -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; + +const SingleItem: React.FC<{ defaultOpen?: boolean } & Props> = ({ + body, + defaultOpen, + eyebrow, + title +}) => ( + + + + + {eyebrow} + {title} + + + + +

{body}

+
+
+
+); + +export const Default: Story = (args) => ( +
+ +
+); + +export const OpenByDefault: Story = (args) => ( +
+ +
+); + +export const RichTextBody: Story = () => ( +
+ + + + + Details + {`What's included in the box?`} + + + + +

Each kit ships with:

+ +
+
+
+
+); + +export const MultipleItems: Story = () => ( +
+ + {[ + { + 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 }) => ( + + + + {eyebrow} + {title} + + + + +

{body}

+
+
+ ))} +
+
+); + +export const AllVariants: Story = (args) => ( +
+
+

+ Single, closed +

+ +
+
+

Single, open

+ +
+
+

+ Custom heading level (h2) +

+ + + + + {args.title} + + + + +

{args.body}

+
+
+
+
+
+); diff --git a/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts new file mode 100644 index 0000000..ce30b70 --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts @@ -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' + ] +}); diff --git a/packages/ui/src/components/Accordion/brands/index.ts b/packages/ui/src/components/Accordion/brands/index.ts new file mode 100644 index 0000000..e445275 --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/index.ts @@ -0,0 +1,69 @@ +import { type BrandVariants } from '@/lib/brand-registry'; +import { type VariantProps } from 'cva'; +import { + accordionAcornVariants, + accordionContentAcornVariants, + accordionContentInnerAcornVariants, + accordionEyebrowAcornVariants, + accordionHeadingAcornVariants, + accordionIconAcornVariants, + accordionIconBarAcornVariants, + accordionTitleAcornVariants, + accordionTriggerAcornVariants +} from './Accordion.acorn.brand'; + +export const accordionBrandVariants = { + acorn: accordionAcornVariants + // 'microbird-school': compose(accordionAcornVariants, accordionMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionAcornVariants, accordionMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionTriggerBrandVariants = { + acorn: accordionTriggerAcornVariants + // 'microbird-school': compose(accordionTriggerAcornVariants, accordionTriggerMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionTriggerAcornVariants, accordionTriggerMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionHeadingBrandVariants = { + acorn: accordionHeadingAcornVariants + // 'microbird-school': compose(accordionHeadingAcornVariants, accordionHeadingMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionHeadingAcornVariants, accordionHeadingMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionEyebrowBrandVariants = { + acorn: accordionEyebrowAcornVariants + // 'microbird-school': compose(accordionEyebrowAcornVariants, accordionEyebrowMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionEyebrowAcornVariants, accordionEyebrowMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionTitleBrandVariants = { + acorn: accordionTitleAcornVariants + // 'microbird-school': compose(accordionTitleAcornVariants, accordionTitleMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionTitleAcornVariants, accordionTitleMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionIconBrandVariants = { + acorn: accordionIconAcornVariants + // 'microbird-school': compose(accordionIconAcornVariants, accordionIconMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionIconAcornVariants, accordionIconMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionIconBarBrandVariants = { + acorn: accordionIconBarAcornVariants + // 'microbird-school': compose(accordionIconBarAcornVariants, accordionIconBarMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionIconBarAcornVariants, accordionIconBarMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionContentBrandVariants = { + acorn: accordionContentAcornVariants + // 'microbird-school': compose(accordionContentAcornVariants, accordionContentMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionContentAcornVariants, accordionContentMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export const accordionContentInnerBrandVariants = { + acorn: accordionContentInnerAcornVariants + // 'microbird-school': compose(accordionContentInnerAcornVariants, accordionContentInnerMicroBirdSchoolVariants), + // 'microbird-commercial': compose(accordionContentInnerAcornVariants, accordionContentInnerMicroBirdCommercialVariants), +} as const satisfies BrandVariants; + +export type AccordionVariantProps = VariantProps; diff --git a/packages/ui/src/components/Accordion/index.tsx b/packages/ui/src/components/Accordion/index.tsx new file mode 100644 index 0000000..7891918 --- /dev/null +++ b/packages/ui/src/components/Accordion/index.tsx @@ -0,0 +1,204 @@ +'use client'; + +import { getBrandVariant } from '@/lib/brand-registry'; +import { Accordion as RadixAccordion } from 'radix-ui'; +import type { ComponentProps, PropsWithChildren } from 'react'; +import { + accordionBrandVariants, + accordionContentBrandVariants, + accordionContentInnerBrandVariants, + accordionEyebrowBrandVariants, + accordionHeadingBrandVariants, + accordionIconBarBrandVariants, + accordionIconBrandVariants, + accordionTitleBrandVariants, + accordionTriggerBrandVariants +} from './brands'; + +export type AccordionRootProps = ComponentProps; + +type HeadingLevel = 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +/** + * Wraps a group of Accordion items. Forwards all Radix Accordion.Root props, + * so consumers control single/multiple, controlled/uncontrolled, and default value. + * @example + * + * + * + * + */ +export const AccordionRoot: React.FC> = ({ + children, + ...props +}) => {children}; + +export type AccordionProps = { + value: string; +} & Omit, 'value'>; + +/** + * A single accordion item. Must live inside and have a unique `value`. + * Built on Radix Accordion.Item — keyboard navigation (Up/Down/Home/End) and ARIA wiring are handled natively. + */ +const Accordion: React.FC> = ({ + children, + className, + value, + ...props +}) => { + const variants = getBrandVariant(accordionBrandVariants); + return ( + + {children} + + ); +}; + +export type AccordionTriggerProps = { + headingLevel?: HeadingLevel; +} & ComponentProps; + +/** + * Toggles the accordion item open/closed. Wraps the heading and icon. + * Rendered inside an Accordion.Header. Pass `headingLevel` to align with + * the surrounding document outline (defaults to `h4`). + */ +export const AccordionTrigger: React.FC< + PropsWithChildren +> = ({ children, className, headingLevel = 'h4', ...props }) => { + const variants = getBrandVariant(accordionTriggerBrandVariants); + const Heading = headingLevel; + return ( + + + + {children} + + + + ); +}; + +export type AccordionHeadingProps = ComponentProps<'span'>; + +/** + * Wrapper for the eyebrow + title pair within an AccordionTrigger. + */ +export const AccordionHeading: React.FC< + PropsWithChildren +> = ({ children, className, ...props }) => { + const variants = getBrandVariant(accordionHeadingBrandVariants); + return ( + + {children} + + ); +}; + +export type AccordionEyebrowProps = ComponentProps<'span'>; + +/** + * Small label rendered below the title (visually) but ahead of it in source order for screen readers. + */ +export const AccordionEyebrow: React.FC< + PropsWithChildren +> = ({ children, className, ...props }) => { + const variants = getBrandVariant(accordionEyebrowBrandVariants); + return ( + + {children} + + ); +}; + +export type AccordionTitleProps = ComponentProps<'span'>; + +/** + * Main heading text for the accordion item. + */ +export const AccordionTitle: React.FC< + PropsWithChildren +> = ({ children, className, ...props }) => { + const variants = getBrandVariant(accordionTitleBrandVariants); + return ( + + {children} + + ); +}; + +export type AccordionIconProps = { + children?: React.ReactNode; +} & ComponentProps<'span'>; + +/** + * Plus / minus indicator. Reads `data-state` from the parent Accordion.Trigger + * to morph the vertical bar in/out via CSS. Pass children to override. + */ +export const AccordionIcon: React.FC = ({ + children, + className, + ...props +}) => { + const variants = getBrandVariant(accordionIconBrandVariants); + const barVariants = getBrandVariant(accordionIconBarBrandVariants); + return ( + + {children ?? ( + + + + + )} + + ); +}; + +export type AccordionContentProps = ComponentProps< + typeof RadixAccordion.Content +>; + +/** + * Animated content panel. Height animation is driven by tw-animate-css keyframes + * (`animate-accordion-down` / `animate-accordion-up`) that read + * --radix-accordion-content-height, so it works with Radix's native mount/unmount + * and stays accessible. Inner content fades in for additional polish. + */ +export const AccordionContent: React.FC< + PropsWithChildren +> = ({ children, className, ...props }) => { + const wrapperVariants = getBrandVariant(accordionContentBrandVariants); + const innerVariants = getBrandVariant(accordionContentInnerBrandVariants); + return ( + +
{children}
+
+ ); +}; + +export default Accordion;