Skip to content

Latest commit

 

History

History
321 lines (250 loc) · 8.32 KB

File metadata and controls

321 lines (250 loc) · 8.32 KB

TpAccordion

INP-optimized, accessible accordion component with SEO-friendly architecture.

Features

  • ✅ INP-optimized with useCallbackWithYield
  • ✅ SEO-friendly (content always in DOM)
  • ✅ CSS Grid animations (200ms, hardware-accelerated)
  • ✅ WAI-ARIA compliant
  • ✅ Controlled and uncontrolled modes
  • ✅ Multiple items can be open simultaneously
  • ✅ Keyboard navigation (Tab key)
  • ✅ TypeScript first

Basic Usage

import {
  TpAccordion,
  TpAccordionItem,
  TpAccordionTrigger,
  TpAccordionContent,
} from '@travelopia/react-components'

function FAQ() {
  return (
    <TpAccordion defaultValue={['item-1']}>
      <TpAccordionItem value="item-1">
        <TpAccordionTrigger>What is your refund policy?</TpAccordionTrigger>
        <TpAccordionContent>
          <p>You can request a full refund within 30 days of purchase.</p>
        </TpAccordionContent>
      </TpAccordionItem>

      <TpAccordionItem value="item-2">
        <TpAccordionTrigger>How do I contact support?</TpAccordionTrigger>
        <TpAccordionContent>
          <p>Email us at support@example.com or use our live chat.</p>
        </TpAccordionContent>
      </TpAccordionItem>
    </TpAccordion>
  )
}

Controlled Mode

import { useState } from 'react'

function ControlledAccordion() {
  const [value, setValue] = useState<string[]>(['item-1'])

  return (
    <>
      <button onClick={() => setValue(['item-1', 'item-2'])}>
        Open First Two
      </button>
      <button onClick={() => setValue([])}>
        Close All
      </button>

      <TpAccordion value={value} onValueChange={setValue}>
        <TpAccordionItem value="item-1">
          <TpAccordionTrigger>Section 1</TpAccordionTrigger>
          <TpAccordionContent>Content 1</TpAccordionContent>
        </TpAccordionItem>

        <TpAccordionItem value="item-2">
          <TpAccordionTrigger>Section 2</TpAccordionTrigger>
          <TpAccordionContent>Content 2</TpAccordionContent>
        </TpAccordionItem>
      </TpAccordion>
    </>
  )
}

With Custom Hook

import { useTpAccordion } from '@travelopia/react-components'

function HookExample() {
  const accordion = useTpAccordion(['item-1'])

  return (
    <>
      <button onClick={() => accordion.toggleItem('item-1')}>
        Toggle Item 1
      </button>
      <button onClick={() => accordion.openAll(['item-1', 'item-2', 'item-3'])}>
        Open All
      </button>
      <button onClick={() => accordion.closeAll()}>
        Close All
      </button>

      <TpAccordion value={accordion.value} onValueChange={(v) => accordion.openAll(v)}>
        <TpAccordionItem value="item-1">
          <TpAccordionTrigger>Item 1</TpAccordionTrigger>
          <TpAccordionContent>Content 1</TpAccordionContent>
        </TpAccordionItem>

        <TpAccordionItem value="item-2">
          <TpAccordionTrigger>Item 2</TpAccordionTrigger>
          <TpAccordionContent>Content 2</TpAccordionContent>
        </TpAccordionItem>

        <TpAccordionItem value="item-3">
          <TpAccordionTrigger>Item 3</TpAccordionTrigger>
          <TpAccordionContent>Content 3</TpAccordionContent>
        </TpAccordionItem>
      </TpAccordion>
    </>
  )
}

Styling with Tailwind CSS

<TpAccordion>
  <TpAccordionItem 
    value="item-1" 
    className="border border-gray-200 rounded-lg mb-2 overflow-hidden"
  >
    <TpAccordionTrigger className="w-full px-4 py-3 text-left font-semibold bg-gray-50 hover:bg-gray-100 aria-expanded:bg-blue-500 aria-expanded:text-white transition-colors">
      Question 1
    </TpAccordionTrigger>
    <TpAccordionContent className="bg-white">
      <div className="px-4 py-3 text-gray-700">
        Answer 1
      </div>
    </TpAccordionContent>
  </TpAccordionItem>
</TpAccordion>

API Reference

TpAccordion

Root component that manages accordion state.

Prop Type Default Description
value string[] - Controlled state - array of open item values
defaultValue string[] [] Uncontrolled default open items
onValueChange (value: string[]) => void - Callback when open state changes
children ReactNode - Accordion items

TpAccordionItem

Individual accordion item wrapper.

Prop Type Default Description
value string required Unique identifier for this item
className string - Custom CSS class
children ReactNode - Trigger and content components

TpAccordionTrigger

The clickable button that toggles the accordion item.

Prop Type Default Description
className string - Custom CSS class
children ReactNode - Trigger content (usually text)

ARIA Attributes:

  • aria-expanded: true when open, false when closed
  • aria-controls: Links to content ID
  • type="button": Proper button semantics

TpAccordionContent

The collapsible content area.

Prop Type Default Description
className string - Custom CSS class
children ReactNode - Content to display

ARIA Attributes:

  • aria-labelledby: Links to trigger ID
  • data-state: "open" or "closed" for CSS targeting

Semantic HTML:

  • Renders as <section> element (implicit region role)

Hooks

useTpAccordion

Custom hook to manage accordion state.

const accordion = useTpAccordion(defaultValue?: string[])

Returns:

{
  value: string[]                           // Current open items
  openItem: (itemValue: string) => void     // Open specific item
  closeItem: (itemValue: string) => void    // Close specific item
  toggleItem: (itemValue: string) => void   // Toggle specific item
  openAll: (itemValues: string[]) => void   // Set open items
  closeAll: () => void                      // Close all items
}

Example:

const accordion = useTpAccordion(['item-1'])

<button onClick={() => accordion.openItem('item-2')}>Open Item 2</button>
<button onClick={() => accordion.closeItem('item-1')}>Close Item 1</button>
<button onClick={() => accordion.toggleItem('item-3')}>Toggle Item 3</button>
<button onClick={() => accordion.openAll(['item-1', 'item-2'])}>Open Multiple</button>
<button onClick={() => accordion.closeAll()}>Close All</button>

Performance Optimizations

INP Optimization with useCallbackWithYield

The trigger's click handler uses useCallbackWithYield which automatically yields to the main thread before updating state:

// Internal implementation
const handleClick = useCallbackWithYield(() => {
  onItemToggle(value)
}, [onItemToggle, value])

This improves INP (Interaction to Next Paint) by allowing the browser to paint visual feedback before executing heavy state updates.

CSS Grid Animation

Instead of animating height (which triggers layout recalculation), we use CSS Grid's grid-template-rows:

.content {
  display: grid;
  grid-template-rows: 0fr;  /* Closed */
  transition: grid-template-rows 200ms ease;
}

.content[data-state="open"] {
  grid-template-rows: 1fr;  /* Open */
}

This is hardware-accelerated and performs better than traditional height animations.

SEO-Friendly Architecture

Content is always in the DOM (not unmounted when closed), making it:

  • ✅ Searchable with Ctrl+F
  • ✅ Indexable by search engines
  • ✅ Accessible to screen readers

Accessibility

Keyboard Navigation

  • Tab: Move focus between triggers
  • Enter/Space: Toggle accordion item (when trigger is focused)

Screen Reader Support

  • Triggers announce their expanded/collapsed state
  • Content regions are properly labeled
  • Semantic HTML (<button>, <section>) for better compatibility

ARIA Attributes

<button
  id="trigger-id"
  aria-expanded="true"
  aria-controls="content-id"
>
  Trigger Text
</button>

<section
  id="content-id"
  aria-labelledby="trigger-id"
>
  Content
</section>

Styling Guide

Targeting States with CSS

Use aria-expanded and data-state for state-based styling:

/* Trigger when open */
.my-trigger[aria-expanded="true"] {
  background: blue;
  color: white;
}

/* Content when open */
.my-content[data-state="open"] {
  /* Custom styles */
}

/* Content when closed */
.my-content[data-state="closed"] {
  /* Custom styles */
}