Shared/FloatingFilterToggle
Collapsed
Drawer Tab
Fixed Below Sibling
Interactive
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;
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 />;