Takazudo Modular Styleguide

Shared/ArrowTag

Active
Aria Pressed Reflection
Default
Large
Parenthesized Label
Small
Tag Filter Row
With Count Suffix

Component Source

arrow-tag.tsx

/**
 * SSR-only arrow polygon tag (Preact port of
 * `src/astro/components/shared/arrow-tag.astro`).
 *
 * Sub 8.1 (#70). Used by `TagSidebar` on the JA tags detail page to
 * render every available tag as a clipped-path pill, with an inverted
 * active state for the current tag.
 */

import type { ComponentChildren } from 'preact';

interface ArrowTagProps {
  label: string;
  size?: 'sm' | 'lg';
  active?: boolean;
  children?: ComponentChildren;
}

const SIZE_STYLES = {
  sm: {
    borderClip: '[clip-path:polygon(0_0,calc(100%-6px)_0,100%_50%,calc(100%-6px)_100%,0_100%)]',
    contentClip:
      '[clip-path:polygon(1px_1px,calc(100%-7px)_1px,calc(100%-1px)_50%,calc(100%-7px)_calc(100%-1px),1px_calc(100%-1px))]',
    contentSize: 'text-[0.65rem] lg:text-xs pl-[6px] pr-[12px] pt-[.2em] pb-[.2em]',
  },
  lg: {
    borderClip: '[clip-path:polygon(0_0,calc(100%-16px)_0,100%_50%,calc(100%-16px)_100%,0_100%)]',
    contentClip:
      '[clip-path:polygon(1px_1px,calc(100%-17px)_1px,calc(100%-1px)_50%,calc(100%-17px)_calc(100%-1px),1px_calc(100%-1px))]',
    contentSize: 'text-sm lg:text-base pl-[8px] pr-[22px] pt-[.25em] pb-[.25em]',
  },
} as const;

export function ArrowTag({ label, size = 'sm', active = false, children }: ArrowTagProps) {
  const styles = SIZE_STYLES[size];
  // Parse label: render parenthesized portions in smaller font
  const parts = label.split(/(([^)]*)|\([^)]*\))/);

  const borderBg = active ? 'bg-zd-literal-black' : 'bg-zd-literal-white';
  const contentBg = active
    ? 'text-zd-literal-black bg-zd-literal-white'
    : 'text-zd-literal-white bg-zd-literal-black';

  return (
    <span class="relative inline-flex">
      <span
        class={[
          'absolute inset-0 zd-tag-border',
          borderBg,
          'group-hover:bg-zd-literal-black group-active:!bg-zd-active',
          // Reflect an ancestor toggle-button's pressed state via CSS so an
          // imperatively-wired button (no `active` prop re-render — the
          // metamodule-ref filter island) still shows the active look.
          'group-aria-pressed:bg-zd-literal-black',
          styles.borderClip,
        ].join(' ')}
      />
      <span
        class={[
          'relative inline-flex items-center font-thin no-underline text-shadow-none zd-tag-content',
          contentBg,
          'group-hover:text-zd-literal-black group-hover:bg-zd-literal-white group-active:!bg-zd-active',
          'group-aria-pressed:text-zd-literal-black group-aria-pressed:bg-zd-literal-white',
          styles.contentSize,
          styles.contentClip,
        ].join(' ')}
      >
        <span class="zd-hash">#</span>
        <span>
          {parts.length === 1
            ? label
            : parts.map((part, i) =>
                /^[((]/.test(part) ? (
                  <span key={i} class="text-[.7em]">
                    {part}
                  </span>
                ) : (
                  <span key={i}>{part}</span>
                ),
              )}
          {children}
        </span>
      </span>
    </span>
  );
}

export default ArrowTag;

Story Source

arrow-tag.stories.tsx

import { ArrowTag } from './arrow-tag';
import type { ControlsMap } from '@/sub-packages/styleguide-v2/src/data/control-types';

/**
 * ArrowTag Component
 *
 * SSR-only arrow polygon tag (Preact port of
 * `src/astro/components/shared/arrow-tag.astro`, sub 8.1 / #70), used by
 * `TagSidebar` on the JA tags detail page and by the `/metamodule-ref/`
 * filter panel's tag chips (`filter-panel.tsx`).
 *
 * DATA-FREE — this is the plain presentational port. Distinct from
 * `tag-list.tsx`'s export, which is coupled to the taxonomy data module;
 * `filter-panel.tsx` deliberately imports THIS component (not `tag-list.tsx`)
 * so it doesn't drag taxonomy data imports into `/metamodule-ref/`, whose tag
 * labels are raw VCV tag strings, not taxonomy keys. Also distinct from
 * `components/taxonomy/arrow-tag.tsx` (`Taxonomy/ArrowTag` story) — an
 * earlier near-duplicate that takes a `suffix` prop instead of `children`.
 *
 * Hover/active inversion is self-contained via `group-hover`/`group-active`
 * — the parent element needs a `group` class for hover to work. The
 * `group-aria-pressed:` variant additionally reflects an ancestor toggle
 * button's `aria-pressed` state via CSS, so an imperatively-wired button (no
 * `active` prop re-render — the `/metamodule-ref/` filter island) still shows
 * the active look.
 */
export const meta = {
  title: 'Shared/ArrowTag',
};

export const controls: ControlsMap = {
  label: { type: 'text', default: 'Eurorack' },
  size: { type: 'select', default: 'sm', options: ['sm', 'lg'] },
  active: { type: 'boolean', default: false },
};

/**
 * Default - controllable via props panel
 */
export const Default = {
  args: { label: 'Eurorack', size: 'sm', active: false },
  render: ({ label, size, active }: { label: string; size: 'sm' | 'lg'; active: boolean }) => (
    <ArrowTag label={label} size={size} active={active} />
  ),
};

/**
 * Small — the default list/chip size
 */
export const Small = () => <ArrowTag label="Eurorack" size="sm" />;

/**
 * Large — the detail-page header size
 */
export const Large = () => <ArrowTag label="Eurorack" size="lg" />;

/**
 * Active — the inverted (filled) selected state
 */
export const Active = () => <ArrowTag label="Eurorack" size="sm" active />;

/**
 * With a count suffix via `children` — mirrors the real
 * `/metamodule-ref/` tag-filter chip (`filter-panel.tsx`)
 */
export const WithCountSuffix = () => (
  <ArrowTag label="Eurorack" size="sm">
    <span class="ml-[.3em] opacity-60">42</span>
  </ArrowTag>
);

/**
 * Tag row — mirrors the `/metamodule-ref/` tag-filter chip list, each tag
 * wrapped in its own toggle `<button aria-pressed>` with a count suffix
 */
export const TagFilterRow = () => (
  <ul class="flex flex-wrap gap-x-[6px] gap-y-[3px]">
    {[
      { tag: 'Eurorack', count: 42, active: true },
      { tag: 'MIDI', count: 18, active: false },
      { tag: 'Sequencer', count: 11, active: false },
      { tag: 'Clock', count: 7, active: false },
    ].map(({ tag, count, active }) => (
      <li key={tag} class="inline-flex whitespace-nowrap">
        <button type="button" class="group cursor-pointer" aria-pressed={active}>
          <ArrowTag label={tag} size="sm" active={active}>
            <span class="ml-[.3em] opacity-60">{count}</span>
          </ArrowTag>
        </button>
      </li>
    ))}
  </ul>
);

/**
 * `aria-pressed` reflection — the imperatively-wired button pattern:
 * `active` is left `false` on the component, and the ancestor button's
 * `aria-pressed="true"` alone drives the active look via
 * `group-aria-pressed:`
 */
export const AriaPressedReflection = () => (
  <button type="button" class="group cursor-pointer" aria-pressed="true">
    <ArrowTag label="Eurorack" size="sm" active={false} />
  </button>
);

/**
 * Parenthesized label portion — rendered in a smaller font
 */
export const ParenthesizedLabel = () => <ArrowTag label="Case(ケース)" size="sm" />;