Shared/FilterToggle
Closed
Default
Disabled
Expanded
In Toggle Row
Interactive
With Summary
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;
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 />;