Takazudo Modular Styleguide

Shared/FilterToggle

Closed
Default
Disabled
Expanded
In Toggle Row
Interactive
With Summary

Component Source

filter-toggle.tsx

/**
 * Shared presentational filter-toggle button — chevron + underlined label,
 * used to open/close a tag/keyword filter panel. Extracted from the
 * `/products/` page's filter toggle (issue #1244) so `/metamodule-ref/` can
 * reuse the same look instead of its own bordered-box toggle.
 *
 * Purely presentational — holds NO internal state. This lets it serve both
 * consumers:
 *   - `/products/` — a Preact-controlled control; `ariaExpanded` is driven by
 *     component state and changes on every re-render.
 *   - `/metamodule-ref/` — an SSR-rendered, `disabled` no-JS baseline that an
 *     island later enables and drives imperatively via `setAttribute`.
 * Chevron rotation is driven entirely by the `group-aria-expanded:` Tailwind
 * variant reading the DOM `aria-expanded` attribute, so it reacts correctly
 * either way — no prop-driven conditional class needed.
 */

import type { ComponentChildren } from 'preact';

export interface FilterToggleProps {
  label: ComponentChildren;
  summary?: ComponentChildren;
  onClick?: () => void;
  ariaExpanded?: boolean | 'true' | 'false';
  ariaControls?: string;
  disabled?: boolean;
  className?: 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;
}

const BASE_CLASS =
  'group inline-flex items-center gap-hgap-xs font-futura text-sm underline ' +
  'transition-opacity cursor-pointer hover:opacity-80 disabled:cursor-default';

export function FilterToggle({
  label,
  summary,
  onClick,
  ariaExpanded,
  ariaControls,
  disabled,
  className,
  ...rest
}: FilterToggleProps) {
  return (
    <button
      type="button"
      {...rest}
      onClick={onClick}
      disabled={disabled}
      aria-expanded={ariaExpanded}
      aria-controls={ariaControls}
      class={className ? `${BASE_CLASS} ${className}` : BASE_CLASS}
    >
      <img
        src="/images/icons/chevron-right.svg"
        alt=""
        width={22}
        height={22}
        class="inline-block no-underline transition-transform group-aria-expanded:rotate-90"
      />
      <span>{label}</span>
      {summary && <span class="text-zd-subtext">({summary})</span>}
    </button>
  );
}

export default FilterToggle;

Story Source

filter-toggle.stories.tsx

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

/**
 * FilterToggle Component
 *
 * Shared presentational filter-toggle button — chevron + underlined label,
 * used to open/close a tag/keyword filter panel. Extracted from the
 * `/products/` page's filter toggle (issue #1244) so `/metamodule-ref/` reuses
 * the same look instead of its own bordered-box toggle.
 *
 * Purely presentational — holds NO internal state. Chevron rotation is driven
 * entirely by the `group-aria-expanded:` Tailwind variant reading the DOM
 * `aria-expanded` attribute, so it works both for a Preact-controlled caller
 * AND an SSR'd `disabled` no-JS baseline an island later enables and drives
 * imperatively via `setAttribute`.
 */
export const meta = {
  title: 'Shared/FilterToggle',
};

export const controls: ControlsMap = {
  label: { type: 'text', default: '絞り込み' },
  ariaExpanded: { type: 'boolean', default: false },
  disabled: { type: 'boolean', default: false },
};

/**
 * Default - controllable via props panel
 */
export const Default = {
  args: { label: '絞り込み', ariaExpanded: false, disabled: false },
  render: ({
    label,
    ariaExpanded,
    disabled,
  }: {
    label: string;
    ariaExpanded: boolean;
    disabled: boolean;
  }) => <FilterToggle label={label} ariaExpanded={ariaExpanded} disabled={disabled} />,
};

/**
 * Closed — down chevron, the initial (collapsed panel) state
 */
export const Closed = () => <FilterToggle label="絞り込み" ariaExpanded={false} />;

/**
 * Expanded — chevron rotated via `group-aria-expanded:rotate-90`
 */
export const Expanded = () => <FilterToggle label="絞り込み" ariaExpanded={true} />;

/**
 * With a result-count summary shown in parentheses
 */
export const WithSummary = () => (
  <FilterToggle label="絞り込み" summary="12件" ariaExpanded={false} />
);

/**
 * Disabled — the SSR no-JS baseline before an island hydrates and enables it
 * (the `/metamodule-ref/` pattern — see `filter-panel.tsx`)
 */
export const Disabled = () => <FilterToggle label="絞り込み" disabled ariaExpanded="false" />;

/**
 * In a toggle row — mirrors the real `/metamodule-ref/` layout: the toggle
 * plus a live result-count text alongside it (`filter-panel.tsx`)
 */
export const InToggleRow = () => (
  <div class="flex flex-wrap items-center gap-x-hgap-md gap-y-vgap-2xs">
    <FilterToggle
      ariaExpanded={false}
      className="self-start"
      label={
        <>
          <span class="lg:hidden">絞り込み</span>
          <span class="hidden lg:inline">ブランド、キーワード、タグで絞り込み</span>
        </>
      }
    />
    <p class="font-futura text-sm text-zd-subtext whitespace-nowrap">
      <span>128</span> / 128 モジュール
    </p>
  </div>
);

/**
 * Interactive — click to open/close, chevron and label follow state
 */
const InteractiveToggle = () => {
  const [expanded, setExpanded] = useState(false);
  return (
    <FilterToggle
      label={expanded ? '閉じる' : '絞り込み'}
      ariaExpanded={expanded}
      onClick={() => setExpanded((v) => !v)}
    />
  );
};

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