From 9b13ea4e361c6a1bf859dc59a049524458e52d85 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:21:11 -0400 Subject: [PATCH 1/7] feat(ui): add Collapse component --- .changeset/fruity-seas-grin.md | 5 + .../components/Collapse/Collapse.stories.tsx | 133 ++++++++ .../Collapse/brands/Collapse.acorn.brand.ts | 47 +++ .../src/components/Collapse/brands/index.ts | 46 +++ packages/ui/src/components/Collapse/index.tsx | 296 ++++++++++++++++++ 5 files changed, 527 insertions(+) create mode 100644 .changeset/fruity-seas-grin.md create mode 100644 packages/ui/src/components/Collapse/Collapse.stories.tsx create mode 100644 packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts create mode 100644 packages/ui/src/components/Collapse/brands/index.ts create mode 100644 packages/ui/src/components/Collapse/index.tsx 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/ui/src/components/Collapse/Collapse.stories.tsx b/packages/ui/src/components/Collapse/Collapse.stories.tsx new file mode 100644 index 0000000..f4d34b4 --- /dev/null +++ b/packages/ui/src/components/Collapse/Collapse.stories.tsx @@ -0,0 +1,133 @@ +import type { Story, StoryDefault } from '@ladle/react'; +import Collapse, { + CollapseContent, + CollapseEyebrow, + CollapseHeading, + CollapseIcon, + CollapseTitle, + CollapseTrigger, + type CollapseProps +} from './index'; + +type Props = { + body?: string; + eyebrow?: string; + title?: string; +} & CollapseProps; + +export default { + title: 'Components/Collapse', + argTypes: { + title: { + control: { type: 'text' }, + defaultValue: 'How does the program work?' + }, + eyebrow: { + control: { type: 'text' }, + defaultValue: 'Frequently asked' + }, + 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.' + }, + defaultOpen: { + control: { type: 'boolean' }, + defaultValue: false + } + } +} satisfies StoryDefault; + +const DefaultComp: Story = ({ + body, + defaultOpen, + eyebrow, + title, + ...props +}) => ( +
+ + + + {eyebrow} + {title} + + + + +

{body}

+
+
+
+); + +export const Default = DefaultComp.bind({}); + +export const OpenByDefault = DefaultComp.bind({}); +OpenByDefault.args = { defaultOpen: true }; + +export const StackedList: Story = () => ( +
+ {[ + { + eyebrow: 'Shipping', + title: 'When will my order arrive?', + body: 'Orders ship within 1–2 business days and arrive in 3–5 business days.' + }, + { + eyebrow: 'Returns', + title: 'What is your return policy?', + body: 'Free returns within 30 days of delivery. Items must be unworn with tags attached.' + }, + { + eyebrow: 'Account', + title: 'How do I update my subscription?', + body: 'Sign in to your account and visit the Subscription page to pause, skip, or cancel.' + }, + { + eyebrow: 'Support', + title: 'How do I contact customer service?', + body: 'Reach our team 7 days a week via the chat widget, or email support@example.com — we typically reply within a few hours.' + } + ].map((item) => ( + + + + {item.eyebrow} + {item.title} + + + + +

{item.body}

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

Each kit ships with:

+ +
+
+
+); diff --git a/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts b/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts new file mode 100644 index 0000000..22c8bd8 --- /dev/null +++ b/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts @@ -0,0 +1,47 @@ +import { cva } from '@/lib/cva'; + +/** + * Acorn brand Collapse variants (default/base theme). + * Uses semantic tokens for themeable properties: + * - text-pui-fg-*: title/body/eyebrow colors + * - border-pui-border-*: divider between collapsed items + */ +export const collapseAcornVariants = cva({ + base: [ + 'pui:flex pui:w-full pui:flex-col', + 'pui:border-b pui:border-pui-overlay-12/20' + ] +}); + +export const collapseTriggerAcornVariants = cva({ + base: [ + '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 collapseHeadingAcornVariants = cva({ + base: ['pui:flex pui:flex-col-reverse pui:gap-1'] +}); + +export const collapseEyebrowAcornVariants = cva({ + base: ['pui:typo-tagline pui:text-pui-fg-muted'] +}); + +export const collapseTitleAcornVariants = cva({ + base: ['pui:typo-heading-5 pui:text-pui-fg-default'] +}); + +export const collapseIconAcornVariants = cva({ + base: ['pui:shrink-0 pui:text-pui-interactive-primary'] +}); + +export const collapseContentAcornVariants = cva({ + base: ['pui:overflow-hidden'] +}); + +export const collapseContentInnerAcornVariants = cva({ + base: ['pui:typo-base pui:text-pui-fg-body pui:pb-4'] +}); diff --git a/packages/ui/src/components/Collapse/brands/index.ts b/packages/ui/src/components/Collapse/brands/index.ts new file mode 100644 index 0000000..bdf1a1f --- /dev/null +++ b/packages/ui/src/components/Collapse/brands/index.ts @@ -0,0 +1,46 @@ +import { type BrandVariants } from '@/lib/brand-registry'; +import { type VariantProps } from 'cva'; +import { + collapseAcornVariants, + collapseContentAcornVariants, + collapseContentInnerAcornVariants, + collapseEyebrowAcornVariants, + collapseHeadingAcornVariants, + collapseIconAcornVariants, + collapseTitleAcornVariants, + collapseTriggerAcornVariants +} from './Collapse.acorn.brand'; + +export const collapseBrandVariants = { + acorn: collapseAcornVariants +} as const satisfies BrandVariants; + +export const collapseTriggerBrandVariants = { + acorn: collapseTriggerAcornVariants +} as const satisfies BrandVariants; + +export const collapseHeadingBrandVariants = { + acorn: collapseHeadingAcornVariants +} as const satisfies BrandVariants; + +export const collapseEyebrowBrandVariants = { + acorn: collapseEyebrowAcornVariants +} as const satisfies BrandVariants; + +export const collapseTitleBrandVariants = { + acorn: collapseTitleAcornVariants +} as const satisfies BrandVariants; + +export const collapseIconBrandVariants = { + acorn: collapseIconAcornVariants +} as const satisfies BrandVariants; + +export const collapseContentBrandVariants = { + acorn: collapseContentAcornVariants +} as const satisfies BrandVariants; + +export const collapseContentInnerBrandVariants = { + acorn: collapseContentInnerAcornVariants +} as const satisfies BrandVariants; + +export type CollapseVariantProps = VariantProps; diff --git a/packages/ui/src/components/Collapse/index.tsx b/packages/ui/src/components/Collapse/index.tsx new file mode 100644 index 0000000..2ffe5aa --- /dev/null +++ b/packages/ui/src/components/Collapse/index.tsx @@ -0,0 +1,296 @@ +'use client'; + +import { getBrandVariant } from '@/lib/brand-registry'; +import { AnimatePresence, cubicBezier } from 'motion/react'; +import * as m from 'motion/react-m'; +import { + createContext, + type PropsWithChildren, + useContext, + useId, + useState +} from 'react'; +import { + collapseBrandVariants, + collapseContentBrandVariants, + collapseContentInnerBrandVariants, + collapseEyebrowBrandVariants, + collapseHeadingBrandVariants, + collapseIconBrandVariants, + collapseTitleBrandVariants, + collapseTriggerBrandVariants, + type CollapseVariantProps +} from './brands'; + +type CollapseContextProps = { + contentId: string; + isOpen: boolean; + setIsOpen: React.Dispatch>; + triggerId: string; +}; + +const CollapseContext = createContext(null); + +/** + * Internal hook to access CollapseContext. Throws when used outside of Collapse. + */ +const useCollapseContext = () => { + const ctx = useContext(CollapseContext); + if (!ctx) { + throw new Error('Collapse subcomponents must be used within '); + } + return ctx; +}; + +export type CollapseProps = { + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + open?: boolean; +} & CollapseVariantProps & + React.ComponentProps<'div'>; + +/** + * Collapse — a disclosure panel with an eyebrow, title, and rich text body. + * Animates open/close with motion. Brand-aware via getBrandVariant(). + * @example + * + * + * + * Section + * How does it work? + * + * + * + * + *

Rich text body content goes here.

+ *
+ *
+ */ +const Collapse: React.FC> = ({ + children, + className, + defaultOpen = false, + onOpenChange, + open: controlledOpen, + ...props +}) => { + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + /** + * Setter that supports controlled and uncontrolled modes and forwards to onOpenChange. + */ + const setIsOpen: React.Dispatch> = (value) => { + const next = typeof value === 'function' ? value(isOpen) : value; + if (!isControlled) setUncontrolledOpen(next); + onOpenChange?.(next); + }; + + const id = useId(); + const triggerId = `collapse-trigger-${id}`; + const contentId = `collapse-content-${id}`; + + const variants = getBrandVariant(collapseBrandVariants); + + return ( + +
+ {children} +
+
+ ); +}; + +export type CollapseTriggerProps = React.ComponentProps<'button'>; + +/** + * Toggles the collapse open/closed. Should wrap the heading and icon. + */ +const CollapseTrigger: React.FC> = ({ + children, + className, + onClick, + ...props +}) => { + const { contentId, isOpen, setIsOpen, triggerId } = useCollapseContext(); + const variants = getBrandVariant(collapseTriggerBrandVariants); + + return ( + + ); +}; + +export type CollapseHeadingProps = React.ComponentProps<'span'>; + +/** + * Wrapper for the eyebrow + title pair within a CollapseTrigger. + */ +const CollapseHeading: React.FC> = ({ + children, + className, + ...props +}) => { + const variants = getBrandVariant(collapseHeadingBrandVariants); + return ( + + {children} + + ); +}; + +export type CollapseEyebrowProps = React.ComponentProps<'span'>; + +/** + * Small label rendered above the title. + */ +const CollapseEyebrow: React.FC> = ({ + children, + className, + ...props +}) => { + const variants = getBrandVariant(collapseEyebrowBrandVariants); + return ( + + {children} + + ); +}; + +export type CollapseTitleProps = React.ComponentProps<'span'>; + +/** + * Main heading text for the collapse item. + */ +const CollapseTitle: React.FC> = ({ + children, + className, + ...props +}) => { + const variants = getBrandVariant(collapseTitleBrandVariants); + return ( + + {children} + + ); +}; + +export type CollapseIconProps = { + children?: React.ReactNode; +} & React.ComponentProps<'span'>; + +/** + * Chevron indicator that rotates when open. Provide custom children to override the default chevron. + */ +const CollapseIcon: React.FC = ({ + children, + className, + ...props +}) => { + const { isOpen } = useCollapseContext(); + const variants = getBrandVariant(collapseIconBrandVariants); + return ( + + {children ?? ( + + + + + )} + + ); +}; + +export type CollapseContentProps = { + className?: string; +}; + +/** + * Animated content panel. Renders rich text/children when open. + */ +const CollapseContent: React.FC> = ({ + children, + className +}) => { + const { contentId, isOpen, triggerId } = useCollapseContext(); + const wrapperVariants = getBrandVariant(collapseContentBrandVariants); + const innerVariants = getBrandVariant(collapseContentInnerBrandVariants); + + return ( + + {isOpen && ( + +
{children}
+
+ )} +
+ ); +}; + +export default Collapse; +export { + CollapseContent, + CollapseEyebrow, + CollapseHeading, + CollapseIcon, + CollapseTitle, + CollapseTrigger +}; From f191cc72683bcbfa08edc8e2e8b3f860f9cd57a9 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:27:23 -0400 Subject: [PATCH 2/7] feat(ui): add Collapse component --- .../components/Collapse/Collapse.stories.tsx | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/packages/ui/src/components/Collapse/Collapse.stories.tsx b/packages/ui/src/components/Collapse/Collapse.stories.tsx index f4d34b4..1cd460b 100644 --- a/packages/ui/src/components/Collapse/Collapse.stories.tsx +++ b/packages/ui/src/components/Collapse/Collapse.stories.tsx @@ -66,46 +66,6 @@ export const Default = DefaultComp.bind({}); export const OpenByDefault = DefaultComp.bind({}); OpenByDefault.args = { defaultOpen: true }; -export const StackedList: Story = () => ( -
- {[ - { - eyebrow: 'Shipping', - title: 'When will my order arrive?', - body: 'Orders ship within 1–2 business days and arrive in 3–5 business days.' - }, - { - eyebrow: 'Returns', - title: 'What is your return policy?', - body: 'Free returns within 30 days of delivery. Items must be unworn with tags attached.' - }, - { - eyebrow: 'Account', - title: 'How do I update my subscription?', - body: 'Sign in to your account and visit the Subscription page to pause, skip, or cancel.' - }, - { - eyebrow: 'Support', - title: 'How do I contact customer service?', - body: 'Reach our team 7 days a week via the chat widget, or email support@example.com — we typically reply within a few hours.' - } - ].map((item) => ( - - - - {item.eyebrow} - {item.title} - - - - -

{item.body}

-
-
- ))} -
-); - export const RichTextBody: Story = () => (
From 755efe2cbee9536d0bc72ea4a12656754c5736a0 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:11:20 -0400 Subject: [PATCH 3/7] refactor(ui): rebuild Collapse on Radix Accordion --- .../ui/src/components/Collapse/Collapse.css | 53 +++++ .../components/Collapse/Collapse.stories.tsx | 82 +++---- packages/ui/src/components/Collapse/index.tsx | 201 +++++------------- 3 files changed, 157 insertions(+), 179 deletions(-) create mode 100644 packages/ui/src/components/Collapse/Collapse.css diff --git a/packages/ui/src/components/Collapse/Collapse.css b/packages/ui/src/components/Collapse/Collapse.css new file mode 100644 index 0000000..3b4d128 --- /dev/null +++ b/packages/ui/src/components/Collapse/Collapse.css @@ -0,0 +1,53 @@ +/** + * Animations for the Collapse component. + * Hooks into Radix Accordion's --radix-accordion-content-height CSS variable + * so we can animate from 0 → measured intrinsic height (and back) without JS. + */ + +@keyframes pui-collapse-down { + from { + height: 0; + opacity: 0; + } + to { + height: var(--radix-accordion-content-height); + opacity: 1; + } +} + +@keyframes pui-collapse-up { + from { + height: var(--radix-accordion-content-height); + opacity: 1; + } + to { + height: 0; + opacity: 0; + } +} + +.pui-collapse-content { + overflow: hidden; +} + +.pui-collapse-content[data-state='open'] { + animation: pui-collapse-down 300ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +.pui-collapse-content[data-state='closed'] { + animation: pui-collapse-up 300ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +/* Plus → minus icon: rotate the vertical bar to align with the horizontal one. */ +.pui-collapse-icon-bar { + transform-origin: center; + transition: + transform 300ms cubic-bezier(0.215, 0.61, 0.355, 1), + opacity 300ms cubic-bezier(0.215, 0.61, 0.355, 1); +} + +[data-state='open'] > .pui-collapse-icon-bar, +[data-state='open'] .pui-collapse-icon-bar { + transform: rotate(90deg); + opacity: 0; +} diff --git a/packages/ui/src/components/Collapse/Collapse.stories.tsx b/packages/ui/src/components/Collapse/Collapse.stories.tsx index 1cd460b..b0fbe42 100644 --- a/packages/ui/src/components/Collapse/Collapse.stories.tsx +++ b/packages/ui/src/components/Collapse/Collapse.stories.tsx @@ -4,16 +4,16 @@ import Collapse, { CollapseEyebrow, CollapseHeading, CollapseIcon, + CollapseRoot, CollapseTitle, - CollapseTrigger, - type CollapseProps + CollapseTrigger } from './index'; type Props = { body?: string; eyebrow?: string; title?: string; -} & CollapseProps; +}; export default { title: 'Components/Collapse', @@ -30,23 +30,22 @@ export default { 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.' - }, - defaultOpen: { - control: { type: 'boolean' }, - defaultValue: false } } } satisfies StoryDefault; -const DefaultComp: Story = ({ +const SingleItem: React.FC<{ defaultOpen?: boolean } & Props> = ({ body, defaultOpen, eyebrow, - title, - ...props + title }) => ( -
- + + {eyebrow} @@ -58,36 +57,45 @@ const DefaultComp: Story = ({

{body}

-
+ ); -export const Default = DefaultComp.bind({}); +export const Default: Story = (args) => ( +
+ +
+); -export const OpenByDefault = DefaultComp.bind({}); -OpenByDefault.args = { defaultOpen: true }; +export const OpenByDefault: Story = (args) => ( +
+ +
+); export const RichTextBody: Story = () => (
- - - - Details - What's included in the box? - - - - -

Each kit ships with:

- -
-
+ + + + + Details + {`What's included in the box?`} + + + + +

Each kit ships with:

+ +
+
+
); diff --git a/packages/ui/src/components/Collapse/index.tsx b/packages/ui/src/components/Collapse/index.tsx index 2ffe5aa..81a2263 100644 --- a/packages/ui/src/components/Collapse/index.tsx +++ b/packages/ui/src/components/Collapse/index.tsx @@ -1,15 +1,9 @@ 'use client'; import { getBrandVariant } from '@/lib/brand-registry'; -import { AnimatePresence, cubicBezier } from 'motion/react'; -import * as m from 'motion/react-m'; -import { - createContext, - type PropsWithChildren, - useContext, - useId, - useState -} from 'react'; +import { Accordion } from 'radix-ui'; +import type { ComponentProps, PropsWithChildren } from 'react'; +import './Collapse.css'; import { collapseBrandVariants, collapseContentBrandVariants, @@ -18,129 +12,75 @@ import { collapseHeadingBrandVariants, collapseIconBrandVariants, collapseTitleBrandVariants, - collapseTriggerBrandVariants, - type CollapseVariantProps + collapseTriggerBrandVariants } from './brands'; -type CollapseContextProps = { - contentId: string; - isOpen: boolean; - setIsOpen: React.Dispatch>; - triggerId: string; -}; - -const CollapseContext = createContext(null); +export type CollapseRootProps = ComponentProps; /** - * Internal hook to access CollapseContext. Throws when used outside of Collapse. + * Wraps a group of Collapse items. Forwards all Radix Accordion.Root props, + * so consumers control single/multiple, controlled/uncontrolled, and default value. + * @example + * + * + * + * */ -const useCollapseContext = () => { - const ctx = useContext(CollapseContext); - if (!ctx) { - throw new Error('Collapse subcomponents must be used within '); - } - return ctx; -}; +const CollapseRoot: React.FC> = ({ + children, + ...props +}) => {children}; export type CollapseProps = { - defaultOpen?: boolean; - onOpenChange?: (open: boolean) => void; - open?: boolean; -} & CollapseVariantProps & - React.ComponentProps<'div'>; + value: string; +} & Omit, 'value'>; /** - * Collapse — a disclosure panel with an eyebrow, title, and rich text body. - * Animates open/close with motion. Brand-aware via getBrandVariant(). - * @example - * - * - * - * Section - * How does it work? - * - * - * - * - *

Rich text body content goes here.

- *
- *
+ * A single collapsible 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 Collapse: React.FC> = ({ children, className, - defaultOpen = false, - onOpenChange, - open: controlledOpen, + value, ...props }) => { - const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); - const isControlled = controlledOpen !== undefined; - const isOpen = isControlled ? controlledOpen : uncontrolledOpen; - - /** - * Setter that supports controlled and uncontrolled modes and forwards to onOpenChange. - */ - const setIsOpen: React.Dispatch> = (value) => { - const next = typeof value === 'function' ? value(isOpen) : value; - if (!isControlled) setUncontrolledOpen(next); - onOpenChange?.(next); - }; - - const id = useId(); - const triggerId = `collapse-trigger-${id}`; - const contentId = `collapse-content-${id}`; - const variants = getBrandVariant(collapseBrandVariants); - return ( - -
- {children} -
-
+ {children} + ); }; -export type CollapseTriggerProps = React.ComponentProps<'button'>; +export type CollapseTriggerProps = ComponentProps; /** - * Toggles the collapse open/closed. Should wrap the heading and icon. + * Toggles the collapse open/closed. Wraps the heading and icon. + * Rendered inside an Accordion.Header for proper landmark semantics. */ const CollapseTrigger: React.FC> = ({ children, className, - onClick, ...props }) => { - const { contentId, isOpen, setIsOpen, triggerId } = useCollapseContext(); const variants = getBrandVariant(collapseTriggerBrandVariants); - return ( - + +

+ + {children} + +

+
); }; -export type CollapseHeadingProps = React.ComponentProps<'span'>; +export type CollapseHeadingProps = ComponentProps<'span'>; /** * Wrapper for the eyebrow + title pair within a CollapseTrigger. @@ -158,10 +98,10 @@ const CollapseHeading: React.FC> = ({ ); }; -export type CollapseEyebrowProps = React.ComponentProps<'span'>; +export type CollapseEyebrowProps = ComponentProps<'span'>; /** - * Small label rendered above the title. + * Small label rendered below the title (visually) but ahead of it in source order for screen readers. */ const CollapseEyebrow: React.FC> = ({ children, @@ -176,7 +116,7 @@ const CollapseEyebrow: React.FC> = ({ ); }; -export type CollapseTitleProps = React.ComponentProps<'span'>; +export type CollapseTitleProps = ComponentProps<'span'>; /** * Main heading text for the collapse item. @@ -196,25 +136,20 @@ const CollapseTitle: React.FC> = ({ export type CollapseIconProps = { children?: React.ReactNode; -} & React.ComponentProps<'span'>; +} & ComponentProps<'span'>; /** - * Chevron indicator that rotates when open. Provide custom children to override the default chevron. + * Plus / minus indicator. Reads `data-state` from the parent Accordion.Trigger + * to morph the vertical bar in/out via CSS. Pass children to override. */ const CollapseIcon: React.FC = ({ children, className, ...props }) => { - const { isOpen } = useCollapseContext(); const variants = getBrandVariant(collapseIconBrandVariants); return ( - + {children ?? ( = ({ strokeWidth="1.5" /> )} @@ -247,41 +177,27 @@ const CollapseIcon: React.FC = ({ ); }; -export type CollapseContentProps = { - className?: string; -}; +export type CollapseContentProps = ComponentProps; /** - * Animated content panel. Renders rich text/children when open. + * Animated content panel. Height animation is driven by CSS keyframes that + * read --radix-accordion-content-height, so it works with Radix's native + * mount/unmount and stays accessible. */ const CollapseContent: React.FC> = ({ children, - className + className, + ...props }) => { - const { contentId, isOpen, triggerId } = useCollapseContext(); const wrapperVariants = getBrandVariant(collapseContentBrandVariants); const innerVariants = getBrandVariant(collapseContentInnerBrandVariants); - return ( - - {isOpen && ( - -
{children}
-
- )} -
+ +
{children}
+
); }; @@ -291,6 +207,7 @@ export { CollapseEyebrow, CollapseHeading, CollapseIcon, + CollapseRoot, CollapseTitle, CollapseTrigger }; From 1e3aee83ecde21d1b97ec1c1e9766a326306e772 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Fri, 1 May 2026 09:59:10 -0400 Subject: [PATCH 4/7] feat(ui): add Accordion built on Radix with brand-aware tokens --- packages/tokens/dist/brands/acorn.css | 13 ++ packages/tokens/src/sets/semantic/base.json | 58 +++++ packages/ui/src/brands/tailwind.css | 29 +++ .../Accordion/Accordion.stories.tsx | 176 +++++++++++++++ .../Accordion/brands/Accordion.acorn.brand.ts | 70 ++++++ .../Accordion.microbird-commercial.brand.ts | 28 +++ .../Accordion.microbird-school.brand.ts | 26 +++ .../src/components/Accordion/brands/index.ts | 146 ++++++++++++ .../ui/src/components/Accordion/index.tsx | 204 +++++++++++++++++ .../ui/src/components/Collapse/Collapse.css | 53 ----- .../components/Collapse/Collapse.stories.tsx | 101 --------- .../Collapse/brands/Collapse.acorn.brand.ts | 47 ---- .../src/components/Collapse/brands/index.ts | 46 ---- packages/ui/src/components/Collapse/index.tsx | 213 ------------------ 14 files changed, 750 insertions(+), 460 deletions(-) create mode 100644 packages/ui/src/components/Accordion/Accordion.stories.tsx create mode 100644 packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts create mode 100644 packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts create mode 100644 packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts create mode 100644 packages/ui/src/components/Accordion/brands/index.ts create mode 100644 packages/ui/src/components/Accordion/index.tsx delete mode 100644 packages/ui/src/components/Collapse/Collapse.css delete mode 100644 packages/ui/src/components/Collapse/Collapse.stories.tsx delete mode 100644 packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts delete mode 100644 packages/ui/src/components/Collapse/brands/index.ts delete mode 100644 packages/ui/src/components/Collapse/index.tsx 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..82bbd99 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); @@ -478,6 +483,30 @@ text-transform: var(--pui-typo-extra-tiny-transform); } +@utility typo-accordion-title { + font-family: var( + --pui-typo-accordion-title-family, + var(--pui-primitive-font-sans) + ); + font-size: var(--pui-typo-accordion-title-size); + line-height: var(--pui-typo-accordion-title-leading); + font-weight: var(--pui-typo-accordion-title-weight); + letter-spacing: var(--pui-typo-accordion-title-tracking); + text-transform: var(--pui-typo-accordion-title-transform); +} + +@utility typo-accordion-content { + font-family: var( + --pui-typo-accordion-content-family, + var(--pui-primitive-font-sans) + ); + font-size: var(--pui-typo-accordion-content-size); + line-height: var(--pui-typo-accordion-content-leading); + font-weight: var(--pui-typo-accordion-content-weight); + letter-spacing: var(--pui-typo-accordion-content-tracking); + text-transform: var(--pui-typo-accordion-content-transform); +} + /* ======================================== * INTERACTIVE PRIMARY UTILITIES (unlayered) * 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..86cfe79 --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts @@ -0,0 +1,70 @@ +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) + * - typo-accordion-*: title and content typography + */ +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:typo-accordion-title pui:text-pui-fg-default'] +}); + +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:typo-accordion-content pui:text-pui-fg-body pui:pb-4', + '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/Accordion.microbird-commercial.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts new file mode 100644 index 0000000..c5c6fd8 --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts @@ -0,0 +1,28 @@ +import { cva } from '@/lib/cva'; + +/** + * MicroBird Commercial brand Accordion variants. + * + * Most theming flows through semantic tokens (typo-accordion-*, + * border-pui-border-accordion-*, text colors). Add structural overrides here + * only when the brand needs a layout change beyond what tokens express. + */ +export const accordionMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionTriggerMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionHeadingMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionEyebrowMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionTitleMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionIconMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionIconBarMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionContentMicroBirdCommercialVariants = cva({ base: '' }); + +export const accordionContentInnerMicroBirdCommercialVariants = cva({ + base: '' +}); diff --git a/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts new file mode 100644 index 0000000..a66ee0a --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts @@ -0,0 +1,26 @@ +import { cva } from '@/lib/cva'; + +/** + * MicroBird School brand Accordion variants. + * + * Most theming flows through semantic tokens (typo-accordion-*, + * border-pui-border-accordion-*, text colors). Add structural overrides here + * only when the brand needs a layout change beyond what tokens express. + */ +export const accordionMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionTriggerMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionHeadingMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionEyebrowMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionTitleMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionIconMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionIconBarMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionContentMicroBirdSchoolVariants = cva({ base: '' }); + +export const accordionContentInnerMicroBirdSchoolVariants = cva({ base: '' }); 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..0372e0a --- /dev/null +++ b/packages/ui/src/components/Accordion/brands/index.ts @@ -0,0 +1,146 @@ +import { type BrandVariants } from '@/lib/brand-registry'; +import { compose } from '@/lib/cva'; +import { type VariantProps } from 'cva'; +import { + accordionAcornVariants, + accordionContentAcornVariants, + accordionContentInnerAcornVariants, + accordionEyebrowAcornVariants, + accordionHeadingAcornVariants, + accordionIconAcornVariants, + accordionIconBarAcornVariants, + accordionTitleAcornVariants, + accordionTriggerAcornVariants +} from './Accordion.acorn.brand'; +import { + accordionContentInnerMicroBirdCommercialVariants, + accordionContentMicroBirdCommercialVariants, + accordionEyebrowMicroBirdCommercialVariants, + accordionHeadingMicroBirdCommercialVariants, + accordionIconBarMicroBirdCommercialVariants, + accordionIconMicroBirdCommercialVariants, + accordionMicroBirdCommercialVariants, + accordionTitleMicroBirdCommercialVariants, + accordionTriggerMicroBirdCommercialVariants +} from './Accordion.microbird-commercial.brand'; +import { + accordionContentInnerMicroBirdSchoolVariants, + accordionContentMicroBirdSchoolVariants, + accordionEyebrowMicroBirdSchoolVariants, + accordionHeadingMicroBirdSchoolVariants, + accordionIconBarMicroBirdSchoolVariants, + accordionIconMicroBirdSchoolVariants, + accordionMicroBirdSchoolVariants, + accordionTitleMicroBirdSchoolVariants, + accordionTriggerMicroBirdSchoolVariants +} from './Accordion.microbird-school.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..6080af4 --- /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 `h3`). + */ +export const AccordionTrigger: React.FC< + PropsWithChildren +> = ({ children, className, headingLevel = 'h3', ...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; diff --git a/packages/ui/src/components/Collapse/Collapse.css b/packages/ui/src/components/Collapse/Collapse.css deleted file mode 100644 index 3b4d128..0000000 --- a/packages/ui/src/components/Collapse/Collapse.css +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Animations for the Collapse component. - * Hooks into Radix Accordion's --radix-accordion-content-height CSS variable - * so we can animate from 0 → measured intrinsic height (and back) without JS. - */ - -@keyframes pui-collapse-down { - from { - height: 0; - opacity: 0; - } - to { - height: var(--radix-accordion-content-height); - opacity: 1; - } -} - -@keyframes pui-collapse-up { - from { - height: var(--radix-accordion-content-height); - opacity: 1; - } - to { - height: 0; - opacity: 0; - } -} - -.pui-collapse-content { - overflow: hidden; -} - -.pui-collapse-content[data-state='open'] { - animation: pui-collapse-down 300ms cubic-bezier(0.215, 0.61, 0.355, 1); -} - -.pui-collapse-content[data-state='closed'] { - animation: pui-collapse-up 300ms cubic-bezier(0.215, 0.61, 0.355, 1); -} - -/* Plus → minus icon: rotate the vertical bar to align with the horizontal one. */ -.pui-collapse-icon-bar { - transform-origin: center; - transition: - transform 300ms cubic-bezier(0.215, 0.61, 0.355, 1), - opacity 300ms cubic-bezier(0.215, 0.61, 0.355, 1); -} - -[data-state='open'] > .pui-collapse-icon-bar, -[data-state='open'] .pui-collapse-icon-bar { - transform: rotate(90deg); - opacity: 0; -} diff --git a/packages/ui/src/components/Collapse/Collapse.stories.tsx b/packages/ui/src/components/Collapse/Collapse.stories.tsx deleted file mode 100644 index b0fbe42..0000000 --- a/packages/ui/src/components/Collapse/Collapse.stories.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import type { Story, StoryDefault } from '@ladle/react'; -import Collapse, { - CollapseContent, - CollapseEyebrow, - CollapseHeading, - CollapseIcon, - CollapseRoot, - CollapseTitle, - CollapseTrigger -} from './index'; - -type Props = { - body?: string; - eyebrow?: string; - title?: string; -}; - -export default { - title: 'Components/Collapse', - argTypes: { - title: { - control: { type: 'text' }, - defaultValue: 'How does the program work?' - }, - eyebrow: { - control: { type: 'text' }, - defaultValue: 'Frequently asked' - }, - 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:

- -
-
-
-
-); diff --git a/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts b/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts deleted file mode 100644 index 22c8bd8..0000000 --- a/packages/ui/src/components/Collapse/brands/Collapse.acorn.brand.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { cva } from '@/lib/cva'; - -/** - * Acorn brand Collapse variants (default/base theme). - * Uses semantic tokens for themeable properties: - * - text-pui-fg-*: title/body/eyebrow colors - * - border-pui-border-*: divider between collapsed items - */ -export const collapseAcornVariants = cva({ - base: [ - 'pui:flex pui:w-full pui:flex-col', - 'pui:border-b pui:border-pui-overlay-12/20' - ] -}); - -export const collapseTriggerAcornVariants = cva({ - base: [ - '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 collapseHeadingAcornVariants = cva({ - base: ['pui:flex pui:flex-col-reverse pui:gap-1'] -}); - -export const collapseEyebrowAcornVariants = cva({ - base: ['pui:typo-tagline pui:text-pui-fg-muted'] -}); - -export const collapseTitleAcornVariants = cva({ - base: ['pui:typo-heading-5 pui:text-pui-fg-default'] -}); - -export const collapseIconAcornVariants = cva({ - base: ['pui:shrink-0 pui:text-pui-interactive-primary'] -}); - -export const collapseContentAcornVariants = cva({ - base: ['pui:overflow-hidden'] -}); - -export const collapseContentInnerAcornVariants = cva({ - base: ['pui:typo-base pui:text-pui-fg-body pui:pb-4'] -}); diff --git a/packages/ui/src/components/Collapse/brands/index.ts b/packages/ui/src/components/Collapse/brands/index.ts deleted file mode 100644 index bdf1a1f..0000000 --- a/packages/ui/src/components/Collapse/brands/index.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type BrandVariants } from '@/lib/brand-registry'; -import { type VariantProps } from 'cva'; -import { - collapseAcornVariants, - collapseContentAcornVariants, - collapseContentInnerAcornVariants, - collapseEyebrowAcornVariants, - collapseHeadingAcornVariants, - collapseIconAcornVariants, - collapseTitleAcornVariants, - collapseTriggerAcornVariants -} from './Collapse.acorn.brand'; - -export const collapseBrandVariants = { - acorn: collapseAcornVariants -} as const satisfies BrandVariants; - -export const collapseTriggerBrandVariants = { - acorn: collapseTriggerAcornVariants -} as const satisfies BrandVariants; - -export const collapseHeadingBrandVariants = { - acorn: collapseHeadingAcornVariants -} as const satisfies BrandVariants; - -export const collapseEyebrowBrandVariants = { - acorn: collapseEyebrowAcornVariants -} as const satisfies BrandVariants; - -export const collapseTitleBrandVariants = { - acorn: collapseTitleAcornVariants -} as const satisfies BrandVariants; - -export const collapseIconBrandVariants = { - acorn: collapseIconAcornVariants -} as const satisfies BrandVariants; - -export const collapseContentBrandVariants = { - acorn: collapseContentAcornVariants -} as const satisfies BrandVariants; - -export const collapseContentInnerBrandVariants = { - acorn: collapseContentInnerAcornVariants -} as const satisfies BrandVariants; - -export type CollapseVariantProps = VariantProps; diff --git a/packages/ui/src/components/Collapse/index.tsx b/packages/ui/src/components/Collapse/index.tsx deleted file mode 100644 index 81a2263..0000000 --- a/packages/ui/src/components/Collapse/index.tsx +++ /dev/null @@ -1,213 +0,0 @@ -'use client'; - -import { getBrandVariant } from '@/lib/brand-registry'; -import { Accordion } from 'radix-ui'; -import type { ComponentProps, PropsWithChildren } from 'react'; -import './Collapse.css'; -import { - collapseBrandVariants, - collapseContentBrandVariants, - collapseContentInnerBrandVariants, - collapseEyebrowBrandVariants, - collapseHeadingBrandVariants, - collapseIconBrandVariants, - collapseTitleBrandVariants, - collapseTriggerBrandVariants -} from './brands'; - -export type CollapseRootProps = ComponentProps; - -/** - * Wraps a group of Collapse items. Forwards all Radix Accordion.Root props, - * so consumers control single/multiple, controlled/uncontrolled, and default value. - * @example - * - * - * - * - */ -const CollapseRoot: React.FC> = ({ - children, - ...props -}) => {children}; - -export type CollapseProps = { - value: string; -} & Omit, 'value'>; - -/** - * A single collapsible 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 Collapse: React.FC> = ({ - children, - className, - value, - ...props -}) => { - const variants = getBrandVariant(collapseBrandVariants); - return ( - - {children} - - ); -}; - -export type CollapseTriggerProps = ComponentProps; - -/** - * Toggles the collapse open/closed. Wraps the heading and icon. - * Rendered inside an Accordion.Header for proper landmark semantics. - */ -const CollapseTrigger: React.FC> = ({ - children, - className, - ...props -}) => { - const variants = getBrandVariant(collapseTriggerBrandVariants); - return ( - -

- - {children} - -

-
- ); -}; - -export type CollapseHeadingProps = ComponentProps<'span'>; - -/** - * Wrapper for the eyebrow + title pair within a CollapseTrigger. - */ -const CollapseHeading: React.FC> = ({ - children, - className, - ...props -}) => { - const variants = getBrandVariant(collapseHeadingBrandVariants); - return ( - - {children} - - ); -}; - -export type CollapseEyebrowProps = ComponentProps<'span'>; - -/** - * Small label rendered below the title (visually) but ahead of it in source order for screen readers. - */ -const CollapseEyebrow: React.FC> = ({ - children, - className, - ...props -}) => { - const variants = getBrandVariant(collapseEyebrowBrandVariants); - return ( - - {children} - - ); -}; - -export type CollapseTitleProps = ComponentProps<'span'>; - -/** - * Main heading text for the collapse item. - */ -const CollapseTitle: React.FC> = ({ - children, - className, - ...props -}) => { - const variants = getBrandVariant(collapseTitleBrandVariants); - return ( - - {children} - - ); -}; - -export type CollapseIconProps = { - 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. - */ -const CollapseIcon: React.FC = ({ - children, - className, - ...props -}) => { - const variants = getBrandVariant(collapseIconBrandVariants); - return ( - - {children ?? ( - - - - - )} - - ); -}; - -export type CollapseContentProps = ComponentProps; - -/** - * Animated content panel. Height animation is driven by CSS keyframes that - * read --radix-accordion-content-height, so it works with Radix's native - * mount/unmount and stays accessible. - */ -const CollapseContent: React.FC> = ({ - children, - className, - ...props -}) => { - const wrapperVariants = getBrandVariant(collapseContentBrandVariants); - const innerVariants = getBrandVariant(collapseContentInnerBrandVariants); - return ( - -
{children}
-
- ); -}; - -export default Collapse; -export { - CollapseContent, - CollapseEyebrow, - CollapseHeading, - CollapseIcon, - CollapseRoot, - CollapseTitle, - CollapseTrigger -}; From b4cc23d4e00eb8f3e8a1aac7d1c4f75a270966cd Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Fri, 1 May 2026 10:35:30 -0400 Subject: [PATCH 5/7] refactor(ui): drop empty microbird Accordion brand files --- .../Accordion.microbird-commercial.brand.ts | 28 ----- .../Accordion.microbird-school.brand.ts | 26 ---- .../src/components/Accordion/brands/index.ts | 113 ++---------------- 3 files changed, 9 insertions(+), 158 deletions(-) delete mode 100644 packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts delete mode 100644 packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts diff --git a/packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts deleted file mode 100644 index c5c6fd8..0000000 --- a/packages/ui/src/components/Accordion/brands/Accordion.microbird-commercial.brand.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { cva } from '@/lib/cva'; - -/** - * MicroBird Commercial brand Accordion variants. - * - * Most theming flows through semantic tokens (typo-accordion-*, - * border-pui-border-accordion-*, text colors). Add structural overrides here - * only when the brand needs a layout change beyond what tokens express. - */ -export const accordionMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionTriggerMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionHeadingMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionEyebrowMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionTitleMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionIconMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionIconBarMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionContentMicroBirdCommercialVariants = cva({ base: '' }); - -export const accordionContentInnerMicroBirdCommercialVariants = cva({ - base: '' -}); diff --git a/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts deleted file mode 100644 index a66ee0a..0000000 --- a/packages/ui/src/components/Accordion/brands/Accordion.microbird-school.brand.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { cva } from '@/lib/cva'; - -/** - * MicroBird School brand Accordion variants. - * - * Most theming flows through semantic tokens (typo-accordion-*, - * border-pui-border-accordion-*, text colors). Add structural overrides here - * only when the brand needs a layout change beyond what tokens express. - */ -export const accordionMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionTriggerMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionHeadingMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionEyebrowMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionTitleMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionIconMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionIconBarMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionContentMicroBirdSchoolVariants = cva({ base: '' }); - -export const accordionContentInnerMicroBirdSchoolVariants = cva({ base: '' }); diff --git a/packages/ui/src/components/Accordion/brands/index.ts b/packages/ui/src/components/Accordion/brands/index.ts index 0372e0a..05f8238 100644 --- a/packages/ui/src/components/Accordion/brands/index.ts +++ b/packages/ui/src/components/Accordion/brands/index.ts @@ -1,5 +1,4 @@ import { type BrandVariants } from '@/lib/brand-registry'; -import { compose } from '@/lib/cva'; import { type VariantProps } from 'cva'; import { accordionAcornVariants, @@ -12,135 +11,41 @@ import { accordionTitleAcornVariants, accordionTriggerAcornVariants } from './Accordion.acorn.brand'; -import { - accordionContentInnerMicroBirdCommercialVariants, - accordionContentMicroBirdCommercialVariants, - accordionEyebrowMicroBirdCommercialVariants, - accordionHeadingMicroBirdCommercialVariants, - accordionIconBarMicroBirdCommercialVariants, - accordionIconMicroBirdCommercialVariants, - accordionMicroBirdCommercialVariants, - accordionTitleMicroBirdCommercialVariants, - accordionTriggerMicroBirdCommercialVariants -} from './Accordion.microbird-commercial.brand'; -import { - accordionContentInnerMicroBirdSchoolVariants, - accordionContentMicroBirdSchoolVariants, - accordionEyebrowMicroBirdSchoolVariants, - accordionHeadingMicroBirdSchoolVariants, - accordionIconBarMicroBirdSchoolVariants, - accordionIconMicroBirdSchoolVariants, - accordionMicroBirdSchoolVariants, - accordionTitleMicroBirdSchoolVariants, - accordionTriggerMicroBirdSchoolVariants -} from './Accordion.microbird-school.brand'; export const accordionBrandVariants = { - acorn: accordionAcornVariants, - 'microbird-school': compose( - accordionAcornVariants, - accordionMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionAcornVariants, - accordionMicroBirdCommercialVariants - ) + acorn: accordionAcornVariants } as const satisfies BrandVariants; export const accordionTriggerBrandVariants = { - acorn: accordionTriggerAcornVariants, - 'microbird-school': compose( - accordionTriggerAcornVariants, - accordionTriggerMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionTriggerAcornVariants, - accordionTriggerMicroBirdCommercialVariants - ) + acorn: accordionTriggerAcornVariants } as const satisfies BrandVariants; export const accordionHeadingBrandVariants = { - acorn: accordionHeadingAcornVariants, - 'microbird-school': compose( - accordionHeadingAcornVariants, - accordionHeadingMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionHeadingAcornVariants, - accordionHeadingMicroBirdCommercialVariants - ) + acorn: accordionHeadingAcornVariants } as const satisfies BrandVariants; export const accordionEyebrowBrandVariants = { - acorn: accordionEyebrowAcornVariants, - 'microbird-school': compose( - accordionEyebrowAcornVariants, - accordionEyebrowMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionEyebrowAcornVariants, - accordionEyebrowMicroBirdCommercialVariants - ) + acorn: accordionEyebrowAcornVariants } as const satisfies BrandVariants; export const accordionTitleBrandVariants = { - acorn: accordionTitleAcornVariants, - 'microbird-school': compose( - accordionTitleAcornVariants, - accordionTitleMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionTitleAcornVariants, - accordionTitleMicroBirdCommercialVariants - ) + acorn: accordionTitleAcornVariants } as const satisfies BrandVariants; export const accordionIconBrandVariants = { - acorn: accordionIconAcornVariants, - 'microbird-school': compose( - accordionIconAcornVariants, - accordionIconMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionIconAcornVariants, - accordionIconMicroBirdCommercialVariants - ) + acorn: accordionIconAcornVariants } as const satisfies BrandVariants; export const accordionIconBarBrandVariants = { - acorn: accordionIconBarAcornVariants, - 'microbird-school': compose( - accordionIconBarAcornVariants, - accordionIconBarMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionIconBarAcornVariants, - accordionIconBarMicroBirdCommercialVariants - ) + acorn: accordionIconBarAcornVariants } as const satisfies BrandVariants; export const accordionContentBrandVariants = { - acorn: accordionContentAcornVariants, - 'microbird-school': compose( - accordionContentAcornVariants, - accordionContentMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionContentAcornVariants, - accordionContentMicroBirdCommercialVariants - ) + acorn: accordionContentAcornVariants } as const satisfies BrandVariants; export const accordionContentInnerBrandVariants = { - acorn: accordionContentInnerAcornVariants, - 'microbird-school': compose( - accordionContentInnerAcornVariants, - accordionContentInnerMicroBirdSchoolVariants - ), - 'microbird-commercial': compose( - accordionContentInnerAcornVariants, - accordionContentInnerMicroBirdCommercialVariants - ) + acorn: accordionContentInnerAcornVariants } as const satisfies BrandVariants; export type AccordionVariantProps = VariantProps; From 4f5549969215851060e542017133245ae9c47eb2 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Fri, 1 May 2026 10:39:05 -0400 Subject: [PATCH 6/7] refactor(ui): drop empty microbird Accordion brand files --- .../src/components/Accordion/brands/index.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/ui/src/components/Accordion/brands/index.ts b/packages/ui/src/components/Accordion/brands/index.ts index 05f8238..e445275 100644 --- a/packages/ui/src/components/Accordion/brands/index.ts +++ b/packages/ui/src/components/Accordion/brands/index.ts @@ -14,38 +14,56 @@ import { 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; From a14346836a5201b7db2974aa8a10e45e166c0ea0 Mon Sep 17 00:00:00 2001 From: Charline Mons <48532888+charlinemons@users.noreply.github.com> Date: Fri, 1 May 2026 11:08:59 -0400 Subject: [PATCH 7/7] refactor(ui): inline accordion typo tokens instead of @utility classes --- packages/ui/src/brands/tailwind.css | 24 ------------------- .../Accordion/brands/Accordion.acorn.brand.ts | 21 +++++++++++++--- .../ui/src/components/Accordion/index.tsx | 4 ++-- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/ui/src/brands/tailwind.css b/packages/ui/src/brands/tailwind.css index 82bbd99..00bf4b3 100644 --- a/packages/ui/src/brands/tailwind.css +++ b/packages/ui/src/brands/tailwind.css @@ -483,30 +483,6 @@ text-transform: var(--pui-typo-extra-tiny-transform); } -@utility typo-accordion-title { - font-family: var( - --pui-typo-accordion-title-family, - var(--pui-primitive-font-sans) - ); - font-size: var(--pui-typo-accordion-title-size); - line-height: var(--pui-typo-accordion-title-leading); - font-weight: var(--pui-typo-accordion-title-weight); - letter-spacing: var(--pui-typo-accordion-title-tracking); - text-transform: var(--pui-typo-accordion-title-transform); -} - -@utility typo-accordion-content { - font-family: var( - --pui-typo-accordion-content-family, - var(--pui-primitive-font-sans) - ); - font-size: var(--pui-typo-accordion-content-size); - line-height: var(--pui-typo-accordion-content-leading); - font-weight: var(--pui-typo-accordion-content-weight); - letter-spacing: var(--pui-typo-accordion-content-tracking); - text-transform: var(--pui-typo-accordion-content-transform); -} - /* ======================================== * INTERACTIVE PRIMARY UTILITIES (unlayered) * diff --git a/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts b/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts index 86cfe79..ce30b70 100644 --- a/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts +++ b/packages/ui/src/components/Accordion/brands/Accordion.acorn.brand.ts @@ -5,7 +5,8 @@ import { cva } from '@/lib/cva'; * Uses semantic tokens for themeable properties: * - text-pui-fg-*: title/body/eyebrow colors * - border-pui-border-accordion-*: per-position dividers (first / between / last) - * - typo-accordion-*: title and content typography + * - --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: [ @@ -35,7 +36,15 @@ export const accordionEyebrowAcornVariants = cva({ }); export const accordionTitleAcornVariants = cva({ - base: ['pui:typo-accordion-title pui:text-pui-fg-default'] + 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({ @@ -63,7 +72,13 @@ export const accordionContentAcornVariants = cva({ export const accordionContentInnerAcornVariants = cva({ base: [ - 'pui:typo-accordion-content pui:text-pui-fg-body pui:pb-4', + '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/index.tsx b/packages/ui/src/components/Accordion/index.tsx index 6080af4..7891918 100644 --- a/packages/ui/src/components/Accordion/index.tsx +++ b/packages/ui/src/components/Accordion/index.tsx @@ -66,11 +66,11 @@ export type AccordionTriggerProps = { /** * 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 `h3`). + * the surrounding document outline (defaults to `h4`). */ export const AccordionTrigger: React.FC< PropsWithChildren -> = ({ children, className, headingLevel = 'h3', ...props }) => { +> = ({ children, className, headingLevel = 'h4', ...props }) => { const variants = getBrandVariant(accordionTriggerBrandVariants); const Heading = headingLevel; return (