Takazudo Modular Styleguide

Shared/FloatingFilterToggle

Collapsed
Drawer Tab
Fixed Below Sibling
Interactive

Component Source

floating-filter-toggle.tsx

/**
 * Shared presentational "floating" filter-drawer tab — the bottom-right
 * popped-out drawer-tab button that COLLAPSES a floating filter overlay.
 * Extracted from `renderFloatToggle` in
 * `src/components/guides-landing/guides-filter-island.tsx` (issue #1334,
 * epic #1333) so `/products/` and `/metamodule-ref/` reuse the same drawer-tab
 * look, each supplying its own glass-fill class (`guides-tab-glass` /
 * `mm-tab-glass` / `products-tab-glass`).
 *
 * This is the DRAWER-TAB variant only. The former `plain` (inline 36×36
 * bordered-box) variant was consolidated into `bordered-chevron-toggle.tsx` in
 * #1348 — the products collapsed bar now uses that shared primitive, so both
 * collapsed-bar chevrons come from one place. This component is exclusively the
 * 72×72 drawer tab (2× since #1341/#1343 — do NOT shrink it).
 *
 * Purely presentational — holds NO internal state. `expanded` drives which
 * chevron path renders (up = collapse-affordance) via a re-render. Both current
 * callers render it only in the expanded state (`expanded={true}`), so it always
 * shows the up chevron; the prop is kept so the path is derived, not hardcoded.
 * `ariaExpanded` lets a caller set the DOM `aria-expanded` attribute
 * independently, defaulting to `expanded` when omitted.
 *
 * The tab protrudes below a full-width rule (OPEN at the top via `border-t-0`),
 * filled via the caller-supplied `glassClassName`, at 72×72 with a 40×40 chevron.
 *
 * ── `placement` (issue #1360, MM Fog true-blur) ──────────────────────────────
 * The tab is `position: absolute` and pins its right edge to `right-[40px]`; the
 * `placement` prop only swaps its VERTICAL anchor within its positioned ancestor:
 *   - `'drawer-tab'` (DEFAULT): `bottom-[-72px]` — hangs 72px BELOW the ancestor's
 *     bottom edge. The tab lives INSIDE the caller's `.*-bar-glass` floating bar.
 *     Products still uses this.
 *   - `'fixed-below'`: `top-0` — pins to the ancestor's TOP edge instead. The
 *     caller wraps the tab in a PLAIN (no transform/filter/backdrop-filter/
 *     opacity<1/contain) `position: fixed` sibling of the bar, positioned at the
 *     MEASURED bar bottom, so the tab is NOT a descendant of the bar's
 *     backdrop-root and its OWN `backdrop-filter` truly blurs the page behind it
 *     (the bar's `backdrop-filter` otherwise makes a nested tab sample an empty
 *     backdrop — #1351). See `metamodule-ref-island.tsx` for the reference wiring.
 * ADDITIVE ONLY: the default keeps the original single-string class, so existing
 * callers (products) are byte-for-byte unchanged.
 */

export type FloatingFilterTogglePlacement = 'drawer-tab' | 'fixed-below';

export interface FloatingFilterToggleProps {
  expanded: boolean;
  glassClassName?: string;
  onClick?: () => void;
  ariaLabel: string;
  ariaExpanded?: boolean | 'true' | 'false';
  ariaControls?: string;
  className?: string;
  /** Vertical anchor of the tab. Defaults to `'drawer-tab'` (hang below). */
  placement?: FloatingFilterTogglePlacement;
  // data-* passthrough 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 =
  'inline-flex shrink-0 items-center justify-center cursor-pointer transition-colors';

// Placement-specific vertical anchor (see the `placement` doc above). It slots
// into `drawerTabClass` in the SAME position the original single `bottom-[-72px]`
// held, so the default (`drawer-tab`) class string is byte-identical to before.
const PLACEMENT_ANCHOR: Record<FloatingFilterTogglePlacement, string> = {
  'drawer-tab': 'bottom-[-72px]',
  'fixed-below': 'top-0',
};

// Geometry: 72×72 tab, `right-[40px]` (the hgap-md gutter) so the tab's right
// edge lands on the content-column right edge; `border-t-0` keeps the top open so
// the tab reads as continuous with the bar interior. The vertical anchor is the
// only placement-varying part. See the per-page overlay markup for the
// notched-rule geometry that pairs with this tab.
const drawerTabClass = (placement: FloatingFilterTogglePlacement): string =>
  `absolute w-[72px] h-[72px] right-[40px] ${PLACEMENT_ANCHOR[placement]} border border-t-0 border-zd-white text-zd-white hover:text-zd-link`;

const DRAWER_TAB_SVG_CLASS = 'w-[40px] h-[40px]';

export function FloatingFilterToggle({
  expanded,
  glassClassName,
  onClick,
  ariaLabel,
  ariaExpanded,
  ariaControls,
  className,
  placement = 'drawer-tab',
  ...rest
}: FloatingFilterToggleProps) {
  const classes = [BASE_CLASS, drawerTabClass(placement), glassClassName, className]
    .filter(Boolean)
    .join(' ');

  return (
    <button
      type="button"
      {...rest}
      onClick={onClick}
      aria-label={ariaLabel}
      aria-expanded={ariaExpanded ?? expanded}
      aria-controls={ariaControls}
      class={classes}
    >
      <svg
        class={DRAWER_TAB_SVG_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={expanded ? 'M18 15l-6-6-6 6' : 'M6 9l6 6 6-6'}
        />
      </svg>
    </button>
  );
}

export default FloatingFilterToggle;

Story Source

floating-filter-toggle.stories.tsx

import { useState } from 'preact/hooks';
import { FloatingFilterToggle } from './floating-filter-toggle';

/**
 * FloatingFilterToggle Component
 *
 * Shared presentational "floating" filter-drawer tab — the bottom-right
 * popped-out drawer-tab button that COLLAPSES a floating filter overlay.
 * Extracted from `renderFloatToggle` in the guides filter island (issue
 * #1334, epic #1333) so `/products/` and `/metamodule-ref/` reuse the same
 * drawer-tab look, each supplying its own glass-fill class via
 * `glassClassName`. 72×72 with a 40×40 chevron (2× since #1341/#1343 — do
 * NOT shrink it).
 *
 * `placement` (issue #1360, MM Fog true-blur): the tab is `position:
 * absolute`, pinned to `right-[40px]`; `placement` only swaps its VERTICAL
 * anchor within its positioned ancestor:
 *   - `'drawer-tab'` (default): `bottom-[-72px]` — hangs 72px BELOW the
 *     ancestor's bottom edge. The tab lives INSIDE the caller's
 *     `.*-bar-glass` floating bar (still used by `/products/`).
 *   - `'fixed-below'`: `top-0` — pins to the ancestor's TOP edge instead.
 *     The caller wraps the tab in a PLAIN (no transform/filter/
 *     backdrop-filter/opacity<1/contain) `position: fixed` SIBLING of the
 *     bar, positioned at the MEASURED bar bottom, so the tab is NOT a
 *     descendant of the bar's backdrop-root and its OWN `backdrop-filter`
 *     truly blurs the page behind it — a `fixed`/`absolute` child of a
 *     `backdrop-filter` element gets re-anchored INTO that element's
 *     backdrop-root instead of the real page, so a nested tab would sample
 *     an empty backdrop (#1351). See `metamodule-ref-island.tsx` for the
 *     reference wiring.
 */
export const meta = {
  title: 'Shared/FloatingFilterToggle',
};

/**
 * Reusable demo "bar" — a `relative` positioned ancestor standing in for the
 * real `.*-bar-glass` floating bar, so the tab's absolute anchor is visible.
 */
const DemoBar = ({ children }: { children: preact.ComponentChildren }) => (
  <div class="relative w-[420px] h-[64px] border border-zd-white bg-zd-gray2 flex items-center px-hgap-sm">
    <p class="font-futura text-xs text-zd-subtext">Floating filter bar…</p>
    {children}
  </div>
);

/**
 * `drawer-tab` placement (default) — the tab lives INSIDE the same
 * positioned bar it collapses, hanging 72px below the bar's bottom edge.
 * This is still what `/products/` uses.
 */
export const DrawerTab = () => (
  <DemoBar>
    <FloatingFilterToggle
      placement="drawer-tab"
      expanded={true}
      ariaLabel="Collapse filters"
      glassClassName="bg-zd-black"
    />
  </DemoBar>
);

/**
 * `fixed-below` placement (#1360) — the tab is a SIBLING of the bar (its own
 * plain wrapper below it), pinned to that wrapper's TOP edge instead, so its
 * `backdrop-filter` samples the real page instead of the bar's backdrop-root.
 * `/metamodule-ref/` uses this so its sticky bar's blur stays genuine.
 */
export const FixedBelowSibling = () => (
  <div class="w-[420px]">
    <div class="h-[64px] border border-zd-white bg-zd-gray2 flex items-center px-hgap-sm">
      <p class="font-futura text-xs text-zd-subtext">Sticky filter bar…</p>
    </div>
    {/* Plain sibling wrapper, positioned right where the bar's bottom edge is
        (production measures this; here a `relative` div directly below the
        bar achieves the same layout for the story). */}
    <div class="relative">
      <FloatingFilterToggle
        placement="fixed-below"
        expanded={true}
        ariaLabel="Collapse filters"
        glassClassName="bg-zd-black"
      />
    </div>
  </div>
);

/**
 * Collapsed chevron — `expanded={false}` renders the down-chevron
 * (collapse-affordance) path; both current callers only render `expanded`
 * so this documents the icon swap the prop is kept for.
 */
export const Collapsed = () => (
  <DemoBar>
    <FloatingFilterToggle
      placement="drawer-tab"
      expanded={false}
      ariaLabel="Expand filters"
      glassClassName="bg-zd-black"
    />
  </DemoBar>
);

/**
 * Interactive — click to flip between expanded/collapsed chevron
 */
const InteractiveTab = () => {
  const [expanded, setExpanded] = useState(true);
  return (
    <DemoBar>
      <FloatingFilterToggle
        placement="drawer-tab"
        expanded={expanded}
        ariaLabel={expanded ? 'Collapse filters' : 'Expand filters'}
        glassClassName="bg-zd-black"
        onClick={() => setExpanded((v) => !v)}
      />
    </DemoBar>
  );
};

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