Shared/BorderedChevronToggle
Collapsed
Default
Disabled
Expanded
Interactive
Products Tone
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;
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 />;