From c95fa3ee57fb44d626b75a828dba324b6c2f1f60 Mon Sep 17 00:00:00 2001 From: Philip Levy Date: Mon, 24 Aug 2026 16:32:43 -0400 Subject: [PATCH] Add icons and large size to TagField (#161) - Add icon/iconPosition to TagItem (Lucide icon, START/END, default START) - Icon color inherits the tag's text color via currentColor - Add LARGE tag size (SMALL | STANDARD | LARGE, no MEDIUM per SAIL docs) - Add tagSizeMap/tagIconSizeMap to sailMaps.ts as single source of truth - Tighten padding by 2px on the icon-adjacent side for visual balance - Add TagField.test.tsx covering sizing, icons, and accessibility - Add LargeSize, WithIcons, IconSizes stories - Update TAILWIND-SAIL-MAPPING.md Tag Sizes section --- TAILWIND-SAIL-MAPPING.md | 18 +- src/components/Tag/Tag.stories.tsx | 45 ++++- src/components/Tag/TagField.test.tsx | 239 +++++++++++++++++++++++++++ src/components/Tag/TagField.tsx | 63 +++++-- src/components/Tag/TagItem.tsx | 7 + src/utils/sailMaps.ts | 30 ++++ 6 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 src/components/Tag/TagField.test.tsx diff --git a/TAILWIND-SAIL-MAPPING.md b/TAILWIND-SAIL-MAPPING.md index 906c34e..3cd33f5 100644 --- a/TAILWIND-SAIL-MAPPING.md +++ b/TAILWIND-SAIL-MAPPING.md @@ -133,16 +133,28 @@ const sizeMap: Record = { // SAIL Component API // Maps to: px-2 py-1 text-xs // Maps to: px-4 py-1 text-base + // Maps to: px-5 py-1.5 text-xl ``` -Internal mapping in TagField.tsx: +Internal mapping (`tagSizeMap` in `src/utils/sailMaps.ts`): ```tsx -const sizeMap = { +export const tagSizeMap = { SMALL: 'text-xs px-2 py-1', // 12px text, 8px horizontal, 4px vertical - STANDARD: 'text-base px-4 py-1' // 14px text, 16px horizontal, 4px vertical + STANDARD: 'text-base px-4 py-1', // 16px text, 16px horizontal, 4px vertical + LARGE: 'text-xl px-5 py-1.5' // 20px text, 20px horizontal, 6px vertical } ``` +Tags don't support `MEDIUM` per SAIL docs. Vertical padding stays shallow across +all three sizes (compared to `buttonSizeMap`) so tags keep their compact pill +shape rather than a button's taller click target; text size scales in step +with `buttonSizeMap` for visual consistency across components. + +Tag icons (`icon`/`iconPosition` on `TagItem`) size alongside the tag +(`tagIconSizeMap` in `src/utils/sailMaps.ts`: 12px / 16px / 20px for +SMALL / STANDARD / LARGE) and inherit the tag's text color via `currentColor` +— there is no separate icon color prop. + ## Theme Configuration The `@theme` directive in `index.css` is **fully generated from `tokens/tokens.json`** via `pnpm run generate:css`. Do not edit the generated regions by hand. diff --git a/src/components/Tag/Tag.stories.tsx b/src/components/Tag/Tag.stories.tsx index e21bd5a..7a1a1c0 100644 --- a/src/components/Tag/Tag.stories.tsx +++ b/src/components/Tag/Tag.stories.tsx @@ -13,7 +13,7 @@ const meta = { ], }, argTypes: { - size: { control: 'select', options: ['SMALL', 'STANDARD'] }, + size: { control: 'select', options: ['SMALL', 'STANDARD', 'LARGE'] }, marginBelow: { control: 'select', options: ['NONE', 'EVEN_LESS', 'LESS', 'STANDARD', 'MORE', 'EVEN_MORE'] }, }, } satisfies Meta @@ -54,3 +54,46 @@ export const SmallSize: Story = { ], }, } + +export const LargeSize: Story = { + args: { + size: 'LARGE', + tags: [ + { text: 'ACTIVE', backgroundColor: 'POSITIVE' }, + { text: 'PENDING', backgroundColor: 'SECONDARY' }, + ], + }, +} + +export const WithIcons: Story = { + args: { + size: 'STANDARD', + tags: [ + { text: 'ACTIVE', backgroundColor: 'POSITIVE', icon: 'loader-circle', iconPosition: 'START' }, + { text: 'APPROVED', backgroundColor: 'ACCENT', icon: 'check', iconPosition: 'END' }, + { text: 'REJECTED', backgroundColor: 'NEGATIVE', icon: 'x', iconPosition: 'END' }, + ], + }, +} + +export const IconSizes: Story = { + args: { + tags: [], + }, + render: () => ( +
+ + + +
+ ), +} diff --git a/src/components/Tag/TagField.test.tsx b/src/components/Tag/TagField.test.tsx new file mode 100644 index 0000000..d34dd9a --- /dev/null +++ b/src/components/Tag/TagField.test.tsx @@ -0,0 +1,239 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import { TagField } from "./TagField"; + +describe("TagField - visibility", () => { + it("returns null when showWhen is false", () => { + const { container } = render( + + ); + expect(container.innerHTML).toBe(""); + }); + + it("renders when showWhen is true (default)", () => { + render(); + expect(screen.getByRole("list")).toBeInTheDocument(); + }); + + it("filters out tags with showWhen=false", () => { + render( + + ); + expect(screen.getByText("Visible")).toBeInTheDocument(); + expect(screen.queryByText("Hidden")).not.toBeInTheDocument(); + }); + + it("filters out tags with empty text", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(1); + }); + + it("does not render the tag list container when there are no visible tags", () => { + render(); + expect(screen.queryByRole("list")).not.toBeInTheDocument(); + }); +}); + +describe("TagField - rendering", () => { + it("renders all tag text", () => { + render( + + ); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("Approved")).toBeInTheDocument(); + expect(screen.getByText("Rejected")).toBeInTheDocument(); + }); + + it("renders the correct number of listitem elements", () => { + render(); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + }); + + it("renders label text when provided", () => { + render(); + expect(screen.getByText("Status")).toBeInTheDocument(); + }); + + it("renders as an anchor when link is provided", () => { + render(); + const item = screen.getByText("Active").closest("a"); + expect(item).toHaveAttribute("href", "/status"); + }); + + it("renders as a span when no link is provided", () => { + render(); + expect(screen.queryByRole("link")).not.toBeInTheDocument(); + }); + + it("applies tooltip as title and aria-label", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]'); + expect(item).toHaveAttribute("title", "Currently active"); + expect(item).toHaveAttribute("aria-label", "Currently active"); + }); +}); + +describe("TagField - size", () => { + it("defaults to STANDARD size classes", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]'); + expect(item).toHaveClass("text-base"); + }); + + it("applies SMALL size classes", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]'); + expect(item).toHaveClass("text-xs"); + }); + + it("applies LARGE size classes", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]'); + expect(item).toHaveClass("text-xl"); + }); +}); + +describe("TagField - icons", () => { + it("renders an icon when icon is provided", () => { + const { container } = render( + + ); + expect(container.querySelector("svg")).toBeInTheDocument(); + }); + + it("does not render an icon when icon is omitted", () => { + const { container } = render(); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + }); + + it("marks the icon as aria-hidden so it isn't announced twice", () => { + const { container } = render( + + ); + const svg = container.querySelector("svg"); + expect(svg).toHaveAttribute("aria-hidden", "true"); + }); + + it("places the icon before the text by default (iconPosition START)", () => { + const { container } = render( + + ); + const item = container.querySelector('[role="listitem"]'); + const firstChild = item?.firstElementChild; + expect(firstChild?.tagName.toLowerCase()).toBe("svg"); + }); + + it("places the icon after the text when iconPosition is END", () => { + const { container } = render( + + ); + const item = container.querySelector('[role="listitem"]'); + const lastChild = item?.lastElementChild; + expect(lastChild?.tagName.toLowerCase()).toBe("svg"); + }); + + it("icon inherits the tag's text color (no separate icon color prop)", () => { + const { container } = render( + + ); + const item = container.querySelector('[role="listitem"]'); + const svg = container.querySelector("svg"); + // The icon has no explicit color class/style — it inherits `currentColor` + // from the text color class applied to the parent tag element. + expect(item).toHaveClass("text-red-700"); + expect(svg).not.toHaveAttribute("style"); + }); + + it("scales icon size up for LARGE tags", () => { + const { container: small } = render( + + ); + const { container: large } = render( + + ); + const smallSvg = small.querySelector("svg"); + const largeSvg = large.querySelector("svg"); + const smallWidth = Number(smallSvg?.getAttribute("width")); + const largeWidth = Number(largeSvg?.getAttribute("width")); + expect(largeWidth).toBeGreaterThan(smallWidth); + }); + + it("warns and skips rendering when icon name is not a valid Lucide icon", () => { + const { container } = render( + + ); + expect(container.querySelector("svg")).not.toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); +}); + +describe("TagField - icon-adjacent padding", () => { + it("does not apply inline padding overrides when there is no icon", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]') as HTMLElement; + expect(item.style.paddingLeft).toBe(""); + expect(item.style.paddingRight).toBe(""); + }); + + it("tightens left padding when icon is at START (default)", () => { + render(); + const item = screen.getByText("Active").closest('[role="listitem"]') as HTMLElement; + // STANDARD base px-4 (16px) tightens to 14px on the icon side only + expect(item.style.paddingLeft).toBe("0.875rem"); + expect(item.style.paddingRight).toBe(""); + }); + + it("tightens right padding when icon is at END", () => { + render( + + ); + const item = screen.getByText("Active").closest('[role="listitem"]') as HTMLElement; + expect(item.style.paddingRight).toBe("0.875rem"); + expect(item.style.paddingLeft).toBe(""); + }); + + it("scales the tightened padding value per tag size", () => { + const { container: small } = render( + + ); + const { container: large } = render( + + ); + const smallItem = small.querySelector('[role="listitem"]') as HTMLElement; + const largeItem = large.querySelector('[role="listitem"]') as HTMLElement; + expect(smallItem.style.paddingLeft).toBe("0.375rem"); // SMALL px-2 (8px) → 6px + expect(largeItem.style.paddingLeft).toBe("1.125rem"); // LARGE px-5 (20px) → 18px + }); +}); + +describe("TagField - accessibility", () => { + it("renders the tag list with role='list' and items with role='listitem'", () => { + render(); + expect(screen.getByRole("list")).toBeInTheDocument(); + expect(screen.getAllByRole("listitem")).toHaveLength(2); + }); + + it("applies accessibilityText as aria-label on the root", () => { + render( + + ); + expect(screen.getByLabelText("Status tags")).toBeInTheDocument(); + }); + + it("tag text remains visible to screen readers even with an icon present", () => { + render(); + // The accessible name comes from the visible text node, not the hidden icon. + expect(screen.getByText("Active")).toBeInTheDocument(); + }); +}); diff --git a/src/components/Tag/TagField.tsx b/src/components/Tag/TagField.tsx index e85e250..cb92e68 100644 --- a/src/components/Tag/TagField.tsx +++ b/src/components/Tag/TagField.tsx @@ -1,15 +1,16 @@ import * as React from 'react' +import * as LucideIcons from 'lucide-react' import type { TagItemProps } from './TagItem' import type { SAILSize, SAILAlign, SAILLabelPosition, SAILMarginSize } from '../../types/sail' import { FieldLabel } from '../shared/FieldLabel' import { mergeClasses } from '../../utils/classNames' import { resolveColorClass, isSemanticColor, isPaletteColor } from '../../utils/colorResolver' -import { marginAboveMap, marginBelowMap, alignMap } from '../../utils/sailMaps' +import { marginAboveMap, marginBelowMap, alignMap, tagSizeMap, tagIconSizeMap, tagHorizontalPaddingMap } from '../../utils/sailMaps' /** - * Tag size - only SMALL and STANDARD are supported per SAIL docs + * Tag size - SMALL, STANDARD, and LARGE are supported (no MEDIUM per SAIL docs) */ -type TagSize = Extract +type TagSize = Extract /** * Props for the TagField component @@ -30,7 +31,7 @@ export interface TagFieldProps { align?: SAILAlign /** Additional text for screen readers */ accessibilityText?: string - /** Size of the tags */ + /** Size of the tags (SMALL, STANDARD, or LARGE) */ size?: TagSize /** Controls field visibility */ showWhen?: boolean @@ -68,12 +69,6 @@ export const TagField: React.FC = ({ // Filter out hidden tags const visibleTags = tags.filter(tag => tag.showWhen !== false && tag.text) - // Size mappings - using Tailwind standard classes that map to SAIL values - const sizeMap = { - SMALL: 'text-xs px-2 py-1', // SAIL SMALL: 12px text, 8px horizontal padding, 4px vertical - STANDARD: 'text-base px-4 py-1' // SAIL STANDARD: 16px text, 16px horizontal padding, 4px vertical - } - // Semantic color mappings — tags use light tints for backgrounds const bgColorMap: Record = { ACCENT: 'bg-blue-50', @@ -91,6 +86,25 @@ export const TagField: React.FC = ({ STANDARD: 'text-gray-900' } + // Map a Lucide icon name (kebab-case or PascalCase) to its component + const getIconComponent = (iconName: string) => { + const kebabToPascal = (str: string) => + str.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join('') + + const pascalIconName = kebabToPascal(iconName) + if (pascalIconName in LucideIcons) { + return LucideIcons[pascalIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean }> + } + + const directIconName = iconName.charAt(0).toUpperCase() + iconName.slice(1).toLowerCase() + if (directIconName in LucideIcons) { + return LucideIcons[directIconName as keyof typeof LucideIcons] as React.ComponentType<{ size?: number; className?: string; 'aria-hidden'?: boolean }> + } + + console.warn(`Icon "${iconName}" not found in Lucide icons`) + return null + } + // Render individual tag const renderTag = (tag: TagItemProps, index: number) => { const colorKey = tag.backgroundColor || 'ACCENT' @@ -127,16 +141,35 @@ export const TagField: React.FC = ({ const Component = tag.link ? 'a' : 'span' const componentProps = tag.link ? { href: tag.link } : {} + // Resolve icon (icon color follows text color — inherits via currentColor) + const IconComponent = tag.icon ? getIconComponent(tag.icon) : null + const iconPosition = tag.iconPosition || 'START' + const iconSize = tagIconSizeMap[size] + const iconElement = IconComponent && ( + + ) + + // When an icon is present, tighten the padding on the icon's side by 2px. + // The icon's own visual weight plus the gap to the text otherwise makes + // that side look heavier than the plain (text-only) side. + if (IconComponent) { + const { tight } = tagHorizontalPaddingMap[size] + if (iconPosition === 'START') { + inlineStyle.paddingLeft = tight + } else { + inlineStyle.paddingRight = tight + } + } + return ( = ({ title={tag.tooltip} aria-label={tag.tooltip} > - {tag.text} + {iconElement && iconPosition === 'START' && iconElement} + {tag.text} + {iconElement && iconPosition === 'END' && iconElement} ) } diff --git a/src/components/Tag/TagItem.tsx b/src/components/Tag/TagItem.tsx index 8213946..8efaea9 100644 --- a/src/components/Tag/TagItem.tsx +++ b/src/components/Tag/TagItem.tsx @@ -1,5 +1,8 @@ import type { SAILColorInput } from '../../types/sail' +/** Position of the icon relative to the tag text */ +export type TagIconPosition = "START" | "END" + /** * Props for individual tag items * Maps to SAIL's a!tagItem() function @@ -17,6 +20,10 @@ export interface TagItemProps { showWhen?: boolean /** Link to apply to the tag (href string for React implementation) */ link?: string + /** Icon to display (Lucide icon name, e.g. "check" or "circle") */ + icon?: string + /** Position of the icon relative to the text */ + iconPosition?: TagIconPosition } /** diff --git a/src/utils/sailMaps.ts b/src/utils/sailMaps.ts index ee03875..cc898dc 100644 --- a/src/utils/sailMaps.ts +++ b/src/utils/sailMaps.ts @@ -68,6 +68,36 @@ export const buttonIconOnlySizeMap: Record = { LARGE: 'p-5 text-xl' } +/** + * Size classes for tags (SMALL, STANDARD, LARGE only — no MEDIUM per SAIL docs). + * Text sizes align to buttonSizeMap for consistency; vertical padding stays + * shallow so tags keep their compact pill shape rather than a button's height. + */ +export const tagSizeMap: Record, string> = { + SMALL: 'text-xs px-2 py-1', + STANDARD: 'text-base px-4 py-1', + LARGE: 'text-xl px-5 py-1.5' +} + +/** + * Horizontal padding for tags, split by side, so the side adjacent to an + * icon can be reduced by 2px. The icon's own visual weight plus the gap to + * the text otherwise makes that side look heavier than the plain side. + * `base` matches tagSizeMap's px-* value; `tight` is base minus 2px. + */ +export const tagHorizontalPaddingMap: Record, { base: string; tight: string }> = { + SMALL: { base: '0.5rem', tight: '0.375rem' }, // px-2 (8px) → 6px + STANDARD: { base: '1rem', tight: '0.875rem' }, // px-4 (16px) → 14px + LARGE: { base: '1.25rem', tight: '1.125rem' } // px-5 (20px) → 18px +} + +/** Icon size (px) for tags, keyed by tag size */ +export const tagIconSizeMap: Record, number> = { + SMALL: 12, + STANDARD: 16, + LARGE: 20 +} + // --- Alignment Maps --- /** Flex alignment (for button arrays, tags, images, stamps — modern START/CENTER/END only) */