Takazudo Modular Styleguide

Products/ProductBrandFilter

Interactive
Multi Selected
No Selection

Component Source

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>
  );
}

Story Source

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 />;