Takazudo Modular Styleguide

Shared/BorderedChevronToggle

Collapsed
Default
Disabled
Expanded
Interactive
Products Tone

Component Source

bordered-chevron-toggle.tsx

/**
 * Shared bordered-box chevron toggle button — a 36×36 bordered square with a
 * chevron icon, used to open/expand a collapsible filter panel or floating
 * filter bar. This is the ONE plain-chevron primitive: #1348 merged the former
 * `FloatingFilterToggle` PLAIN variant into this component, so BOTH the
 * `/products/` collapsed float bar AND the `/metamodule-ref/` sticky bar draw
 * their collapsed-bar chevron from here (previously each hand-rolled its own).
 *
 * The chevron direction is driven ENTIRELY by the `group-aria-expanded:` CSS
 * variant reading the button's own `aria-expanded` attribute — never a
 * JS-computed path swap. This lets ONE component serve both caller kinds:
 *   - a Preact-controlled caller, where `ariaExpanded` changes on re-render
 *     (mm-ref's sticky-bar chevron, products' collapsed-bar chevron)
 *   - an SSR'd, `disabled` no-JS baseline that an island later enables and
 *     drives imperatively via `setAttribute('aria-expanded', ...)` with no
 *     re-render — the same split `FilterToggle` (filter-toggle.tsx) documents.
 *     Preserving the CSS-driven chevron keeps this imperative path working.
 *
 * Per-caller TONE + icon SIZE are caller-supplied, NOT baked in, because the two
 * collapsed-bar callers differ visually and both must be preserved byte-for-byte
 * (#1348 — no visible change):
 *   - `className` carries the rest/hover text color. mm-ref passes
 *     `text-zd-subtext hover:text-zd-white`; products passes `hover:text-zd-link`
 *     (no rest color → inherits). The shared BASE deliberately sets NO text color
 *     so neither caller can be forced into the other's tone (Tailwind can't
 *     un-set a baked-in class).
 *   - `iconClassName` carries the chevron size (default 18×18; products passes
 *     20×20 to match its former FloatingFilterToggle plain look).
 */

export interface BorderedChevronToggleProps {
  onClick?: () => void;
  ariaLabel: string;
  ariaExpanded?: boolean | 'true' | 'false';
  ariaControls?: string;
  disabled?: boolean;
  className?: string;
  /** Chevron size utilities. Defaults to `w-[18px] h-[18px]`. */
  iconClassName?: string;
  // data-* passthrough (e.g. `data-mm-filter-toggle`) so callers can hang a
  // DOM-contract hook off the rendered <button> without the component
  // needing to know about it.
  [key: `data-${string}`]: unknown;
}

// No text color here — callers supply their tone via `className` (see docstring).
const BASE_CLASS =
  'group inline-flex shrink-0 items-center justify-center w-[36px] h-[36px] border ' +
  'border-zd-gray bg-transparent cursor-pointer transition-colors ' +
  'hover:border-zd-white disabled:cursor-default';

const DEFAULT_ICON_CLASS = 'w-[18px] h-[18px]';

export function BorderedChevronToggle({
  onClick,
  ariaLabel,
  ariaExpanded,
  ariaControls,
  disabled,
  className,
  iconClassName,
  ...rest
}: BorderedChevronToggleProps) {
  return (
    <button
      type="button"
      {...rest}
      onClick={onClick}
      disabled={disabled}
      aria-label={ariaLabel}
      aria-expanded={ariaExpanded}
      aria-controls={ariaControls}
      class={className ? `${BASE_CLASS} ${className}` : BASE_CLASS}
    >
      <svg
        class={`transition-transform group-aria-expanded:rotate-180 ${iconClassName ?? DEFAULT_ICON_CLASS}`}
        fill="none"
        stroke="currentColor"
        viewBox="0 0 24 24"
        xmlns="http://www.w3.org/2000/svg"
        aria-hidden="true"
      >
        <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 9l6 6 6-6" />
      </svg>
    </button>
  );
}

export default BorderedChevronToggle;

Story Source

bordered-chevron-toggle.stories.tsx

import { useState } from 'preact/hooks';
import { BorderedChevronToggle } from './bordered-chevron-toggle';
import type { ControlsMap } from '@/sub-packages/styleguide-v2/src/data/control-types';

/**
 * BorderedChevronToggle Component
 *
 * Shared bordered-box chevron toggle button — a 36×36 bordered square with a
 * chevron icon, used to open/expand a collapsible filter panel or floating
 * filter bar. This is the ONE plain-chevron primitive: #1348 merged the
 * former `FloatingFilterToggle` PLAIN variant into this component, so BOTH
 * the `/products/` collapsed float bar AND the `/metamodule-ref/` sticky bar
 * draw their collapsed-bar chevron from here.
 *
 * The chevron direction is driven ENTIRELY by the `group-aria-expanded:
 * rotate-180` CSS variant reading the button's own `aria-expanded` attribute
 * — never a JS-computed path swap. This lets ONE component serve both a
 * Preact-controlled caller AND an SSR'd `disabled` no-JS baseline an island
 * later enables and drives imperatively via `setAttribute`.
 *
 * Per-caller tone (`className`) and icon size (`iconClassName`) are
 * caller-supplied, NOT baked in, because `/products/` and `/metamodule-ref/`
 * differ visually and both must be preserved byte-for-byte (#1348).
 */
export const meta = {
  title: 'Shared/BorderedChevronToggle',
};

export const controls: ControlsMap = {
  ariaLabel: { type: 'text', default: 'Expand filters' },
  ariaExpanded: { type: 'boolean', default: false },
  disabled: { type: 'boolean', default: false },
};

/**
 * Default - controllable via props panel
 */
export const Default = {
  args: { ariaLabel: 'Expand filters', ariaExpanded: false, disabled: false },
  render: ({
    ariaLabel,
    ariaExpanded,
    disabled,
  }: {
    ariaLabel: string;
    ariaExpanded: boolean;
    disabled: boolean;
  }) => (
    <BorderedChevronToggle ariaLabel={ariaLabel} ariaExpanded={ariaExpanded} disabled={disabled} />
  ),
};

/**
 * Collapsed — down chevron (`aria-expanded="false"`)
 */
export const Collapsed = () => (
  <BorderedChevronToggle ariaLabel="Expand filters" ariaExpanded={false} />
);

/**
 * Expanded — chevron flipped via `group-aria-expanded:rotate-180`
 */
export const Expanded = () => (
  <BorderedChevronToggle ariaLabel="Collapse filters" ariaExpanded={true} />
);

/**
 * `/products/` collapsed-bar tone — `hover:text-zd-link` + a 20×20 icon
 */
export const ProductsTone = () => (
  <BorderedChevronToggle
    ariaLabel="Expand filters"
    ariaExpanded={false}
    className="hover:text-zd-link"
    iconClassName="w-[20px] h-[20px]"
  />
);

/**
 * Disabled — the SSR no-JS baseline before an island hydrates and enables it
 */
export const Disabled = () => (
  <BorderedChevronToggle ariaLabel="Expand filters" ariaExpanded={false} disabled />
);

/**
 * Interactive — click to flip between collapsed/expanded
 */
const InteractiveToggle = () => {
  const [expanded, setExpanded] = useState(false);
  return (
    <BorderedChevronToggle
      ariaLabel={expanded ? 'Collapse filters' : 'Expand filters'}
      ariaExpanded={expanded}
      onClick={() => setExpanded((v) => !v)}
    />
  );
};

export const Interactive = () => <InteractiveToggle />;