MenuItem

One dish on a menu: the name, a two-line description, the price, dietary tags and a thumbnail, with an add button, a basket count and an optional stepper.

Commerce MenuItem.jsx MenuItem.d.ts MenuItem.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

MenuItem is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { MenuItem } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

One dish on a menu: the name, a two-line description, the price, dietary tags and a thumbnail, with an add button, a basket count and an optional stepper.

Use it when

  • Listing dishes or products in a MenuSection on a food-ordering page.
  • A dish needs a quick add and a way to open its details from the same row.

Don't use it when

  • It is a retail product with variants and a product page. Use a product card, or Card with Price.
  • It is a line in the basket or at checkout. Use a cart line with QuantityStepper.

Example

<MenuItem
  name="Pad thai"
  description="Rice noodles wok-fried with tamarind, egg, tofu, bean sprouts and crushed peanuts."
  price={12.5}
  image={{ src: padThai, alt: "Pad thai on a white plate" }}
  tags={[{ label: "Popular", kind: "popular" }, { label: "Contains nuts" }]}
  quantity={basket["pad-thai"] || 0}
  onSelect={() => openDetails("pad-thai")}
  onAdd={() => openOptions("pad-thai")}
  onQuantityChange={(n) => setQuantity("pad-thai", n)}
  locale="en-US"
/>

Variants

PropWhat it is for
imageA square thumbnail on the right at --dt-menu-thumb-size, its space reserved. The add button sits on its corner.
tagsSmall badges. kind picks the colour: vegetarian, vegan, spicy, gluten-free, popular, new, or default. Spicy gets a flame and popular a thumbs-up. The label always shows.
compareAtA sale: the old price struck through, through Price.
soldOutDims the text and picture, shows "Sold out", and removes the add button and stepper. The row can still open details.
quantityAbove 0, a "2 in basket" badge. With onQuantityChange, also a small QuantityStepper whose minus becomes remove at 1 and calls onQuantityChange(0).
onAddShows the add button, named Add ${name}. Usually opens a Sheet of ModifierGroups; for a dish with no options, add it straight away.
onSelectMakes the whole row open the dish's details.
layoutlist (a row, the default) or grid (a card). MenuSection sets it.

Composition

Lives in a MenuSection, which sets its layout and headingLevel. Renders Price for the price, Badge for the tags and counts, IconButton for add and QuantityStepper in the basket. The details or options it opens are yours: typically a Sheet with ModifierGroups and an "Add to basket · $14.50" Button.

Tokens

Tier 3, in tokens/component/commerce.css; the colour ones are repeated under .dark:

  • --dt-menu-item-gap (--dt-space-inline-md), --dt-menu-item-padding (--dt-space-inset-md).
  • --dt-menu-thumb-size (2 × --dt-size-control-lg), --dt-menu-thumb-radius (--dt-radius-media), --dt-menu-thumb-bg (--dt-surface-sunken).
  • --dt-menu-item-description-color (--dt-text-secondary), --dt-menu-item-soldout-color (--dt-text-disabled).
  • Grid cards: --dt-menu-item-card-bg (--dt-surface-base), --dt-menu-item-card-border (--dt-border-subtle), --dt-menu-item-bg-hover (--dt-surface-subtle).
  • Tags: --dt-dietary-{kind}-bg and --dt-dietary-{kind}-fg for each kind, from the success, danger, info, warning and brand subtle surfaces and their text roles; default is sunken and secondary.

The name is --dt-text-label-lg-*; the description --dt-text-body-sm-*.

Accessibility

  • The name is a heading (h3 in a section with an h2).
  • With onSelect, the name is a real <button> and a transparent layer inside it stretches over the whole row, so a press anywhere on the row is a press of that button. The add button and the stepper sit above that layer as their own targets: two tab stops, never one control nested in another. The row's focus ring is drawn around the whole item.
  • The row button is described by the price line, so focusing it reads "Pad thai, $12.50, 2 in basket".
  • The add button is named Add ${name} (override with addLabel), so a list of dishes does not read as a column of identical "Add" buttons. The stepper is named Quantity, ${name} and its remove button Remove ${name}.
  • Tags always show their words; colour and icons repeat them. Sold out is said in text as well as shown by dimming.

Content

  • The name as the menu prints it, sentence case.
  • The description says what is in the dish and how it is made. It is clamped to two lines, so lead with what matters.
  • Tag labels are one or two words: "Vegan", "Gluten free", "Contains nuts".

Props

The typed contract, from MenuItem.d.ts.

import * as React from "react";

/** A small label on a dish. */
export interface MenuItemTag {
  /** Always shown, so colour is never the only signal: "Vegan", "Spicy", "Popular". */
  label: string;
  /** Picks the tag's colour tokens. spicy and popular also get an icon. @default "default" */
  kind?: "vegetarian" | "vegan" | "spicy" | "gluten-free" | "popular" | "new" | "default";
}

/** One dish on a menu: name, description, price, tags, a thumbnail and an add button. */
export interface MenuItemProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect" | "children"> {
  /** The dish's name, rendered as a heading. */
  name: string;
  /** What is in it. Clamped to two lines. */
  description?: string;
  /** Price in major units, shown with Price. */
  price: number;
  /** Original price. When higher than price, it shows struck through. */
  compareAt?: number;
  /** ISO 4217 currency code. @default "USD" */
  currency?: string;
  /** BCP 47 locale for the price. Defaults to the runtime's; set it when server rendering. */
  locale?: string;
  /** Square thumbnail on the right, at --dt-menu-thumb-size. Its space is reserved before it loads. */
  image?: { src?: string; alt: string };
  /** Dietary and highlight tags, shown as small badges. */
  tags?: MenuItemTag[];
  /** Dims the dish, shows soldOutLabel and removes the add button and stepper. */
  soldOut?: boolean;
  /** How many are in the basket. Above 0 it shows quantityLabel as a badge, and a stepper when onQuantityChange is given. @default 0 */
  quantity?: number;
  /** Shows an add button, named `Add ${name}`. Usually opens a Sheet of ModifierGroups. */
  onAdd?: () => void;
  /** With quantity above 0, shows a QuantityStepper; its remove button calls this with 0. */
  onQuantityChange?: (next: number) => void;
  /** Makes the whole row a button that opens the dish's details. The add button stays a separate target. */
  onSelect?: () => void;
  /** list: a row in a list. grid: a card. Set by MenuSection; set it yourself only outside one. @default "list" */
  layout?: "list" | "grid";
  /** Level of the name's heading. MenuSection sets it one below its own. @default 3 */
  headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
  /** Accessible name of the add button. @default `Add ${name}` */
  addLabel?: string;
  /** Badge text for a sold-out dish. @default "Sold out" */
  soldOutLabel?: string;
  /** Badge text for the basket count. @default (n) => `${n} in basket` */
  quantityLabel?: (quantity: number) => string;
}

export declare function MenuItem(props: MenuItemProps): React.JSX.Element;

Tokens it reads

Every custom property MenuItem.jsx resolves, read from the source: 26 component, 165 semantic. Component tier first, because that is the one to reach for.

  • component Its own tier. Override one of these and only this component moves.
  • semantic Shared roles. Override one and everything using that role moves with it.
TokenTierDeclared as
--dt-dietary-default-bgcomponentvar(--dt-surface-sunken)
--dt-dietary-default-fgcomponentvar(--dt-text-secondary)
--dt-dietary-gluten-free-bgcomponentvar(--dt-surface-info-subtle)
--dt-dietary-gluten-free-fgcomponentvar(--dt-text-info)
--dt-dietary-new-bgcomponentvar(--dt-surface-brand-muted)
--dt-dietary-new-fgcomponentvar(--dt-text-brand)
--dt-dietary-spicy-bgcomponentvar(--dt-surface-danger-subtle)
--dt-dietary-spicy-fgcomponentvar(--dt-text-danger)
--dt-dietary-vegan-bgcomponentvar(--dt-surface-success-subtle)
--dt-dietary-vegan-fgcomponentvar(--dt-text-success)
--dt-dietary-vegetarian-bgcomponentvar(--dt-surface-success-subtle)
--dt-dietary-vegetarian-fgcomponentvar(--dt-text-success)
--dt-menu-item-bg-hovercomponentvar(--dt-surface-subtle)
--dt-menu-item-card-bgcomponentvar(--dt-surface-base)
--dt-menu-item-card-bordercomponentvar(--dt-border-subtle)
--dt-menu-item-description-colorcomponentvar(--dt-text-secondary)
--dt-menu-item-gapcomponentvar(--dt-space-inline-md)
--dt-menu-item-paddingcomponentvar(--dt-space-inset-md)
--dt-menu-item-soldout-colorcomponentvar(--dt-text-disabled)
--dt-menu-thumb-bgcomponentvar(--dt-surface-sunken)
--dt-menu-thumb-radiuscomponentvar(--dt-radius-media)
--dt-menu-thumb-sizecomponentcalc(2 * var(--dt-size-control-lg))
--dt-price-colorcomponentvar(--dt-text-primary)
--dt-price-sale-colorcomponentvar(--dt-text-danger)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-border-width-strongsemanticvar(--dt-dim-hair-2)
--dt-elevation-2semanticvar(--dt-shadow-raw-2)
--dt-focus-ring-colorsemanticvar(--dt-color-neutral-900)
--dt-focus-ring-offsetsemanticvar(--dt-dim-hair-2)
--dt-focus-ring-widthsemanticvar(--dt-dim-hair-2)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-radius-containersemanticvar(--dt-radius-raw-16)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-size-icon-xssemanticvar(--dt-dim-3)
--dt-space-inline-2xssemanticvar(--dt-dim-1)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-text-artboard-body-linesemanticcalc(var(--dt-line-height-md) * 2.35)
--dt-text-artboard-body-sizesemanticcalc(var(--dt-font-size-md) * 2.5)
--dt-text-artboard-display-familysemanticvar(--dt-text-display-lg-family)
--dt-text-artboard-display-linesemanticcalc(var(--dt-line-height-7xl) * 2.55)
--dt-text-artboard-display-sizesemanticcalc(var(--dt-font-size-7xl) * 2.8)
--dt-text-artboard-display-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-artboard-display-weightsemanticvar(--dt-text-display-lg-weight)
--dt-text-artboard-meta-sizesemanticcalc(var(--dt-font-size-sm) * 2.2)
--dt-text-artboard-meta-trackingsemanticvar(--dt-tracking-wider)
--dt-text-artboard-title-linesemanticcalc(var(--dt-line-height-7xl) * 1.7)
--dt-text-artboard-title-sizesemanticcalc(var(--dt-font-size-7xl) * 1.75)
--dt-text-body-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-body-lg-linesemanticvar(--dt-line-height-lg)
--dt-text-body-lg-sizesemanticvar(--dt-font-size-lg)
--dt-text-body-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-lg-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-md-familysemanticvar(--dt-font-family-sans)
--dt-text-body-md-linesemanticvar(--dt-line-height-md)
--dt-text-body-md-sizesemanticvar(--dt-font-size-md)
--dt-text-body-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-body-sm-linesemanticvar(--dt-line-height-sm)
--dt-text-body-sm-sizesemanticvar(--dt-font-size-sm)
--dt-text-body-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-body-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-body-xs-linesemanticvar(--dt-line-height-xs)
--dt-text-body-xs-sizesemanticvar(--dt-font-size-xs)
--dt-text-body-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-body-xs-weightsemanticvar(--dt-font-weight-regular)
--dt-text-brandsemanticvar(--dt-color-primary-700)
--dt-text-brand-secondarysemanticvar(--dt-color-secondary-700)
--dt-text-code-md-familysemanticvar(--dt-font-family-mono)
--dt-text-code-md-linesemanticvar(--dt-line-height-sm)
--dt-text-code-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-code-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-md-weightsemanticvar(--dt-font-weight-regular)
--dt-text-code-sm-familysemanticvar(--dt-font-family-mono)
--dt-text-code-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-code-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-code-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-code-sm-weightsemanticvar(--dt-font-weight-regular)
--dt-text-dangersemanticvar(--dt-color-red-800)
--dt-text-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-display-2xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-2xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-2xl-sizesemanticvar(--dt-font-size-fluid-2xl)
--dt-text-display-2xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-2xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-display-lg-linesemanticvar(--dt-line-height-7xl)
--dt-text-display-lg-sizesemanticvar(--dt-font-size-7xl)
--dt-text-display-lg-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-md-familysemanticvar(--dt-font-family-sans)
--dt-text-display-md-linesemanticvar(--dt-line-height-6xl)
--dt-text-display-md-sizesemanticvar(--dt-font-size-6xl)
--dt-text-display-md-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-display-sm-linesemanticvar(--dt-line-height-5xl)
--dt-text-display-sm-sizesemanticvar(--dt-font-size-5xl)
--dt-text-display-sm-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-display-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-display-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-display-xl-linesemanticvar(--dt-line-height-fluid)
--dt-text-display-xl-sizesemanticvar(--dt-font-size-fluid-xl)
--dt-text-display-xl-trackingsemanticvar(--dt-tracking-tightest)
--dt-text-display-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-eyebrow-linesemanticvar(--dt-line-height-2xs)
--dt-text-eyebrow-sizesemanticvar(--dt-font-size-2xs)
--dt-text-eyebrow-trackingsemanticvar(--dt-tracking-wider)
--dt-text-eyebrow-weightsemanticvar(--dt-font-weight-semibold)
--dt-text-heading-lg-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-lg-linesemanticvar(--dt-line-height-3xl)
--dt-text-heading-lg-sizesemanticvar(--dt-font-size-3xl)
--dt-text-heading-lg-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-md-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-md-linesemanticvar(--dt-line-height-2xl)
--dt-text-heading-md-sizesemanticvar(--dt-font-size-2xl)
--dt-text-heading-md-trackingsemanticvar(--dt-tracking-tight)
--dt-text-heading-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-sm-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-sm-linesemanticvar(--dt-line-height-xl)
--dt-text-heading-sm-sizesemanticvar(--dt-font-size-xl)
--dt-text-heading-sm-trackingsemanticvar(--dt-tracking-snug)
--dt-text-heading-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xl-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xl-linesemanticvar(--dt-line-height-4xl)
--dt-text-heading-xl-sizesemanticvar(--dt-font-size-4xl)
--dt-text-heading-xl-trackingsemanticvar(--dt-tracking-tighter)
--dt-text-heading-xl-weightsemanticvar(--dt-font-weight-medium)
--dt-text-heading-xs-familysemanticvar(--dt-font-family-sans)
--dt-text-heading-xs-linesemanticvar(--dt-line-height-lg)
--dt-text-heading-xs-sizesemanticvar(--dt-font-size-lg)
--dt-text-heading-xs-trackingsemanticvar(--dt-tracking-normal)
--dt-text-heading-xs-weightsemanticvar(--dt-font-weight-medium)
--dt-text-headlinesemanticvar(--dt-text-primary)
--dt-text-infosemanticvar(--dt-color-cyan-900)
--dt-text-inversesemanticvar(--dt-color-neutral-050)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-linesemanticvar(--dt-line-height-md)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-lg-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-lg-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-linesemanticvar(--dt-line-height-sm)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-md-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-md-weightsemanticvar(--dt-font-weight-medium)
--dt-text-label-sm-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-sm-linesemanticvar(--dt-line-height-xs)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-label-sm-trackingsemanticvar(--dt-tracking-normal)
--dt-text-label-sm-weightsemanticvar(--dt-font-weight-medium)
--dt-text-on-actionsemanticvar(--dt-color-white)
--dt-text-on-action-brandsemanticvar(--dt-color-white)
--dt-text-on-action-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-action-dangersemanticvar(--dt-color-white)
--dt-text-on-action-disabledsemanticvar(--dt-color-neutral-400)
--dt-text-on-action-ghostsemanticvar(--dt-color-neutral-900)
--dt-text-on-action-secondarysemanticvar(--dt-color-neutral-900)
--dt-text-on-brandsemanticvar(--dt-color-white)
--dt-text-on-brand-mutedsemanticvar(--dt-color-primary-900)
--dt-text-on-brand-secondarysemanticvar(--dt-color-white)
--dt-text-on-brand-secondary-mutedsemanticvar(--dt-color-secondary-900)
--dt-text-on-dangersemanticvar(--dt-color-white)
--dt-text-on-infosemanticvar(--dt-color-white)
--dt-text-on-scrimsemanticvar(--dt-color-neutral-050)
--dt-text-on-scrim-brandsemanticvar(--dt-color-primary-200)
--dt-text-on-scrim-secondarysemanticvar(--dt-color-neutral-200)
--dt-text-on-scrim-strongsemanticvar(--dt-color-white)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-on-selected-brandsemanticvar(--dt-color-primary-900)
--dt-text-on-successsemanticvar(--dt-color-white)
--dt-text-on-warningsemanticvar(--dt-color-neutral-950)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-successsemanticvar(--dt-color-green-800)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-text-warningsemanticvar(--dt-color-amber-900)
--dt-text-wordmarksemanticvar(--dt-text-primary)

Source

React only, no dependencies, every value a token.

import React from "react";
import { Badge } from "../display/Badge.jsx";
import { IconButton } from "../actions/IconButton.jsx";
import { Price } from "./Price.jsx";
import { QuantityStepper } from "./QuantityStepper.jsx";

const role = (name) => ({
  fontFamily: `var(--dt-text-${name}-family)`,
  fontSize: `var(--dt-text-${name}-size)`,
  lineHeight: `var(--dt-text-${name}-line)`,
  fontWeight: `var(--dt-text-${name}-weight)`,
  letterSpacing: `var(--dt-text-${name}-tracking)`,
});

const ICONS = {
  plus: ["M5 12h14", "M12 5v14"],
  flame: ["M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5z"],
  thumb: ["M7 10v12", "M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"],
};

function Icon({ name, size }) {
  return (
    <svg
      viewBox="0 0 24 24" aria-hidden="true" focusable="false"
      fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
      style={{ display: "block", flex: "none", width: size, height: size }}
    >
      {ICONS[name].map((d) => <path key={d} d={d} />)}
    </svg>
  );
}

/* Each tag kind reads its own pair of tokens. Spicy and popular carry an
   icon as well; every kind always shows its label. */
const KINDS = ["vegetarian", "vegan", "spicy", "gluten-free", "popular", "new", "default"];
const TAG_ICON = { spicy: "flame", popular: "thumb" };

function DishTag({ label, kind }) {
  const k = KINDS.includes(kind) ? kind : "default";
  return (
    <Badge
      style={{
        background: `var(--dt-dietary-${k}-bg)`,
        color: `var(--dt-dietary-${k}-fg)`,
        border: "var(--dt-border-width-default) solid transparent",
      }}
    >
      {TAG_ICON[k] && <Icon name={TAG_ICON[k]} size="var(--dt-size-icon-xs)" />}
      {label}
    </Badge>
  );
}

const defaultQuantityLabel = (n) => `${n} in basket`;

export function MenuItem({
  name,
  description,
  price,
  compareAt,
  currency = "USD",
  locale,
  image,
  tags = [],
  soldOut = false,
  quantity = 0,
  onAdd,
  onQuantityChange,
  onSelect,
  layout = "list",
  headingLevel = 3,
  addLabel,
  soldOutLabel = "Sold out",
  quantityLabel = defaultQuantityLabel,
  style,
  ...rest
}) {
  const metaId = React.useId();
  const [hover, setHover] = React.useState(false);
  const [ring, setRing] = React.useState(false);
  const grid = layout === "grid";
  const level = Math.min(Math.max(Math.round(headingLevel) || 3, 1), 6);
  const H = `h${level}`;
  const inBasket = quantity > 0;
  const showStepper = inBasket && !!onQuantityChange && !soldOut;
  const showAdd = !!onAdd && !soldOut;
  const selectable = !!onSelect;
  const dim = soldOut ? "var(--dt-menu-item-soldout-color)" : null;

  /* The row's focus ring is drawn on the whole item, not on the name, so a
     keyboard user sees the target the stretched button covers. */
  const onFocus = (e) => {
    let visible = true;
    try { visible = e.currentTarget.matches(":focus-visible"); } catch (err) { visible = true; }
    setRing(visible);
  };

  const title = selectable ? (
    <button
      type="button"
      onClick={onSelect}
      onFocus={onFocus}
      onBlur={() => setRing(false)}
      aria-describedby={metaId}
      style={{
        appearance: "none", background: "none", border: 0, margin: 0, padding: 0,
        font: "inherit", color: "inherit", letterSpacing: "inherit", textAlign: "start",
        cursor: "pointer", outline: "none", position: "static",
        textDecoration: hover && !grid ? "underline" : "none",
        textUnderlineOffset: "var(--dt-border-width-strong)",
      }}
    >
      {/* The stretched target: positioned against the item and stacked over
          its text, price and picture, so a press anywhere on the row is a
          press of this button. The add button and stepper sit above it. */}
      <span aria-hidden="true" style={{ position: "absolute", inset: 0, zIndex: 1 }} />
      {name}
    </button>
  ) : name;

  return (
    <div
      onMouseEnter={selectable ? () => setHover(true) : undefined}
      onMouseLeave={selectable ? () => setHover(false) : undefined}
      style={{
        position: "relative", isolation: "isolate", boxSizing: "border-box",
        display: "flex", alignItems: "flex-start", gap: "var(--dt-menu-item-gap)",
        minWidth: 0, height: grid ? "100%" : undefined,
        padding: grid ? "var(--dt-menu-item-padding)" : "var(--dt-menu-item-padding) 0",
        borderRadius: "var(--dt-radius-container)",
        border: grid ? "var(--dt-border-width-default) solid var(--dt-menu-item-card-border)" : undefined,
        background: grid ? (hover ? "var(--dt-menu-item-bg-hover)" : "var(--dt-menu-item-card-bg)") : undefined,
        outline: ring ? "var(--dt-focus-ring-width) solid var(--dt-focus-ring-color)" : "none",
        outlineOffset: "var(--dt-focus-ring-offset)",
        cursor: selectable ? "pointer" : undefined,
        transition: "background var(--dt-motion-micro)",
        ...(soldOut ? { "--dt-price-color": "var(--dt-menu-item-soldout-color)", "--dt-price-sale-color": "var(--dt-menu-item-soldout-color)" } : null),
        ...style,
      }}
      {...rest}
    >
      <div style={{ flex: "1 1 auto", minWidth: 0, display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)" }}>
        <H style={{ ...role("label-lg"), margin: 0, overflowWrap: "anywhere", color: dim || "var(--dt-text-primary)" }}>{title}</H>
        {description && (
          <p
            style={{
              ...role("body-sm"), margin: 0,
              color: dim || "var(--dt-menu-item-description-color)",
              display: "-webkit-box", WebkitBoxOrient: "vertical", WebkitLineClamp: 2, overflow: "hidden",
              overflowWrap: "anywhere",
            }}
          >
            {description}
          </p>
        )}
        <div id={metaId} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "var(--dt-space-inline-xs)", minWidth: 0 }}>
          <Price amount={price} compareAt={compareAt} currency={currency} locale={locale} size="sm" />
          {soldOut && <Badge tone="neutral">{soldOutLabel}</Badge>}
          {inBasket && !soldOut && <Badge tone="primary">{quantityLabel(quantity)}</Badge>}
        </div>
        {tags.length > 0 && (
          <div style={{ display: "flex", flexWrap: "wrap", gap: "var(--dt-space-inline-2xs)", minWidth: 0 }}>
            {tags.map((t) => <DishTag key={t.label} label={t.label} kind={t.kind} />)}
          </div>
        )}
        {showStepper && (
          <div style={{ position: "relative", zIndex: 2, marginTop: "var(--dt-space-stack-2xs)", alignSelf: "flex-start" }}>
            <QuantityStepper
              size="sm"
              value={quantity}
              min={1}
              onChange={onQuantityChange}
              onRemove={() => onQuantityChange(0)}
              label={`Quantity, ${name}`}
              removeLabel={`Remove ${name}`}
            />
          </div>
        )}
      </div>

      {(image || showAdd) && (
        <div style={{ position: "relative", flex: "none", display: "flex", flexDirection: "column", alignItems: "flex-end" }}>
          {image && (
            <span
              style={{
                display: "block", position: "relative", overflow: "hidden",
                width: "var(--dt-menu-thumb-size)", height: "var(--dt-menu-thumb-size)",
                borderRadius: "var(--dt-menu-thumb-radius)", background: "var(--dt-menu-thumb-bg)",
              }}
            >
              {image.src && (
                <img
                  src={image.src}
                  alt={image.alt || ""}
                  loading="lazy"
                  style={{ position: "absolute", inset: 0, width: "100%", height: "100%", objectFit: "cover", display: "block", filter: soldOut ? "grayscale(1)" : undefined }}
                />
              )}
            </span>
          )}
          {showAdd && (
            <IconButton
              label={addLabel || `Add ${name}`}
              variant="solid"
              size="sm"
              onClick={onAdd}
              style={{
                position: image ? "absolute" : "relative", zIndex: 2,
                insetBlockEnd: image ? "var(--dt-space-inset-2xs)" : undefined,
                insetInlineEnd: image ? "var(--dt-space-inset-2xs)" : undefined,
                borderRadius: "var(--dt-radius-pill)",
                boxShadow: image ? "var(--dt-elevation-2)" : undefined,
              }}
            >
              <Icon name="plus" size="var(--dt-size-icon-sm)" />
            </IconButton>
          )}
        </div>
      )}
    </div>
  );
}