Shared/ArrowTag
Active
Aria Pressed Reflection
Default
Large
Parenthesized Label
Small
Tag Filter Row
With Count Suffix
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;
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" />;