Products/ProductBrandFilter
Interactive
Multi Selected
No Selection
product-brand-filter.tsx
/**
* ProductBrandFilter — a row of brand-logo thumbnails acting as multi-select
* filter toggles for the `/products/` filter (replaces the brand tag chips).
*
* Presentational: props-in / callback-out, no client state of its own (the
* hosting island owns `selectedTags`). Bundle-safe for hydration — it resolves
* brand logos via the esbuild-safe `@/src/components/brand-square-logo`, NOT
* the Vite `?raw` variant under `components/shared/` which would break the
* island bundle.
*
* Each thumb branches on `item.hasLogo` (authoritative — derived upstream from
* `brand.svg !== null`, never from `svgClassName` truthiness): a monochrome
* BrandSquareLogo when a logo exists (the same look as the footer brand grid),
* else a brand-name text-fallback square of the same footprint (any brand
* whose `svg` is `null`).
*
* Active vs inactive is the design deliverable. On the dark page background,
* inactive thumbs dim to `opacity-40` (desaturated, reads as "off"); active
* thumbs go full strength and gain an amber `zd-strong` ring so several
* simultaneous selections all stay legible at a glance. Ring is box-shadow
* based, so toggling causes no layout shift.
*/
import type { BrandFilterItem } from '@/lib/data/brand-tags';
import BrandSquareLogo from '@/src/components/brand-square-logo';
interface ProductBrandFilterProps {
brands: BrandFilterItem[];
selectedTags: string[];
onBrandToggle: (tagKey: string) => void;
}
export function ProductBrandFilter({
brands,
selectedTags,
onBrandToggle,
}: ProductBrandFilterProps) {
return (
<ul class="flex flex-wrap gap-x-hgap-xs gap-y-vgap-xs pt-vgap-sm">
{brands.map((item) => {
const active = selectedTags.includes(item.tagKey);
return (
<li key={item.tagKey} class="inline-flex">
<button
type="button"
onClick={() => onBrandToggle(item.tagKey)}
aria-pressed={active}
aria-label={item.brandName}
class="group flex w-[54px] cursor-pointer flex-col items-center gap-y-vgap-2xs focus-visible:outline-none focus-visible:ring-[2px] focus-visible:ring-zd-white focus-visible:ring-offset-[2px] focus-visible:ring-offset-zd-black"
>
<span
class={[
'block w-full overflow-hidden transition duration-150',
active
? 'opacity-100 ring-[3px] ring-zd-strong'
: 'opacity-40 group-hover:opacity-75',
].join(' ')}
>
{item.hasLogo ? (
<BrandSquareLogo
slug={item.brandSlug}
svgClassName={item.svgClassName}
className="w-full"
monochrome
/>
) : (
<span class="grid aspect-square place-items-center bg-zd-literal-white px-[3px] text-center">
<span class="line-clamp-2 font-futura text-[0.5rem] leading-tight text-zd-literal-black">
{item.brandName}
</span>
</span>
)}
</span>
<span
class={[
'font-futura text-[0.6rem] leading-none transition-colors duration-150',
active ? 'text-zd-strong' : 'text-zd-subtext',
].join(' ')}
>
{item.count}
</span>
</button>
</li>
);
})}
</ul>
);
}
product-brand-filter.stories.tsx
import { useState } from 'preact/hooks';
import { ProductBrandFilter } from './product-brand-filter';
import type { BrandFilterItem } from '@/lib/data/brand-tags';
/**
* ProductBrandFilter Component
*
* A row of brand-logo thumbnails that act as multi-select filter toggles for
* the `/products/` filter. Each thumb is a `<button aria-pressed>`; inactive
* thumbs dim on the dark page background, active thumbs go full strength with
* an amber `zd-strong` ring. Brands without a registered logo (`hasLogo:false`)
* render a brand-name text-fallback square of the same footprint.
*
* Presentational — the hosting island owns `selectedTags` and reacts to
* `onBrandToggle(tagKey)`.
*/
export const meta = {
title: 'Products/ProductBrandFilter',
};
// Representative fixture: logo-bearing brands (incl. a dark-bg logo — takazudo),
// plus one synthetic logo-less brand that exercises the text-fallback square
// (no live brand is logo-less, so this documents the fallback path).
const brandsFixture: BrandFilterItem[] = [
{
tagKey: 'addac-system',
brandSlug: 'addac',
brandName: 'ADDAC System',
svgClassName: 'w-5/6',
hasLogo: true,
count: 42,
},
{
tagKey: 'oxi',
brandSlug: 'oxi',
brandName: 'OXI Instruments',
svgClassName: 'w-5/6',
hasLogo: true,
count: 18,
},
{
tagKey: 'weston',
brandSlug: 'weston',
brandName: 'Weston Precision Audio',
svgClassName: 'w-5/6',
hasLogo: true,
count: 11,
},
{
tagKey: '4ms',
brandSlug: '4ms',
brandName: '4ms Company',
svgClassName: 'w-full',
hasLogo: true,
count: 7,
},
{
tagKey: 'takazudo-modular',
brandSlug: 'takazudo',
brandName: 'Takazudo Modular',
svgClassName: 'bg-zd-literal-black',
hasLogo: true,
count: 5,
},
{
tagKey: 'example-no-logo',
brandSlug: 'example-no-logo',
brandName: 'Logo-less Brand Example',
hasLogo: false,
count: 4,
},
];
/**
* No selection — every thumb reads as "off" (dimmed).
*/
export const NoSelection = () => (
<ProductBrandFilter brands={brandsFixture} selectedTags={[]} onBrandToggle={() => {}} />
);
/**
* Multi-selection — 3 active thumbs at full strength with the amber ring,
* including the logo-less text-fallback square, alongside dimmed inactive
* thumbs.
*/
export const MultiSelected = () => (
<ProductBrandFilter
brands={brandsFixture}
selectedTags={['addac-system', 'weston', 'example-no-logo']}
onBrandToggle={() => {}}
/>
);
/**
* Interactive — click thumbs to toggle selection on/off.
*/
const InteractiveFilter = () => {
const [selectedTags, setSelectedTags] = useState<string[]>(['oxi']);
const toggle = (tagKey: string) =>
setSelectedTags((prev) =>
prev.includes(tagKey) ? prev.filter((t) => t !== tagKey) : [...prev, tagKey],
);
return (
<div class="flex flex-col gap-vgap-md">
<ProductBrandFilter
brands={brandsFixture}
selectedTags={selectedTags}
onBrandToggle={toggle}
/>
<p class="font-futura text-xs text-zd-subtext">
Selected: <span class="text-zd-white">{selectedTags.join(', ') || '(none)'}</span>
</p>
</div>
);
};
export const Interactive = () => <InteractiveFilter />;