v1.0

MenuIcon

Preview

Basic

Loading…

Preview

Code

ts
import MenuIcon from '@/components/layout/MenuIcon';

src/components/layout/MenuIcon.tsx

AI prompt

text
Build an icon-name resolver component in React + TypeScript + Tailwind CSS (icons from lucide-react).

Menu rows stored in a database keep a lucide icon NAME (a string like "Settings"), never markup. This turns that name back into an icon.

## Behaviour
- An EXPLICIT map of name → lucide component — not `import * as Icons from 'lucide-react'`, which pulls the whole icon set into the client bundle when the menu needs about twenty. Include: Activity, Bell, Building2, ClipboardList, ClockAlert, ClockCheck, Coins, Database, FileText, HandCoins, History, KeyRound, Layers, LayoutDashboard, ListTree, Receipt, Settings, Shield, ShieldCheck, SquareCheckBig, UserCog, Users, Wallet.
- An unknown or empty name is not an error: it renders the fallback, and the fix is adding one line to the map.
- The fallback is deliberately neutral — `KeyRound` — so an unassigned icon reads as unassigned. Falling back to `Home` made unrelated menus share an icon and look intentional.

## API
- Default export `MenuIcon({ name?: string | null; className?: string; title?: string })` — renders the icon with `className`. Lucide icons don't accept `title`, so when a tooltip is given, wrap the icon in `<span title={title} class="inline-flex shrink-0">`.
- Also export `ICON_MAP`, `ICON_NAMES` (the map's keys, sorted — for an icon picker in a menu editor) and `resolveMenuIcon(name)` returning the component.

## Demo
A row of LayoutDashboard, Layers, Settings, Users, Shield and "NoSuchIcon" (showing the key fallback), each 20px slate-600, labelled with its name.

## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.

Source

tsx
'use client';

/* Origin: bonus-adjustment (96S2), verbatim. */

import {
  Activity,
  Bell,
  Building2,
  ClipboardList,
  ClockAlert,
  ClockCheck,
  Coins,
  Database,
  FileText,
  HandCoins,
  History,
  KeyRound,
  Layers,
  LayoutDashboard,
  ListTree,
  Receipt,
  Settings,
  Shield,
  ShieldCheck,
  SquareCheckBig,
  UserCog,
  Users,
  Wallet,
  type LucideIcon,
} from 'lucide-react';
import { FALLBACK_ICON } from '@/lib/menu';

/**
 * Menu rows store a lucide icon NAME, never markup — this rehydrates it.
 *
 * The map is EXPLICIT rather than `import * as Icons from 'lucide-react'`: a
 * namespace import pulls the entire icon set into the client bundle, and the
 * sidebar needs about twenty of them. Adding a menu row with an icon not listed
 * here is not an error — it renders the fallback, and the fix is one line.
 *
 * The fallback is deliberately neutral (`KeyRound`) so an unassigned icon reads
 * as unassigned. An earlier version fell back to `Home`, which made several
 * unrelated menus render the same icon and looked intentional.
 */
export const ICON_MAP: Record<string, LucideIcon> = {
  Activity,
  Bell,
  Building2,
  ClipboardList,
  ClockAlert,
  ClockCheck,
  Coins,
  Database,
  FileText,
  HandCoins,
  History,
  KeyRound,
  Layers,
  LayoutDashboard,
  ListTree,
  Receipt,
  Settings,
  Shield,
  ShieldCheck,
  SquareCheckBig,
  UserCog,
  Users,
  Wallet,
};

/** The names offered in the menus editor's icon picker. */
export const ICON_NAMES = Object.keys(ICON_MAP).sort();

export function resolveMenuIcon(name?: string | null): LucideIcon {
  return (name && ICON_MAP[name]) || ICON_MAP[FALLBACK_ICON] || KeyRound;
}

export default function MenuIcon({
  name,
  className,
  title,
}: {
  name?: string | null;
  className?: string;
  /** Tooltip. Lucide's props don't include `title`, so it goes on a wrapper. */
  title?: string;
}) {
  const Cmp = resolveMenuIcon(name);
  const icon = <Cmp className={className} />;
  return title ? (
    <span title={title} className="inline-flex shrink-0">
      {icon}
    </span>
  ) : (
    icon
  );
}

Props

PropTypeDefaultDescription
namestring | null—
classNamestring—
titlestring—Tooltip. Lucide's props don't include `title`, so it goes on a wrapper.