PromoCode

A field for a promo, discount or gift code, with an Apply button. It starts as a "Have a promo code?" disclosure so it doesn't tempt people without a code to leave and search for one, shows the server's error under the field, and replaces the field with a removable chip once a code is applied. It is presentational and controlled: it never validates a code or calls a server; onApply hands the code to your app, and you pass back loading, error or applied.

Commerce PromoCode.jsx PromoCode.d.ts PromoCode.md

Playground

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

Install and import

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

npm install @dovetail-ds/react react react-dom
import { PromoCode } 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

A field for a promo, discount or gift code, with an Apply button. It starts as a "Have a promo code?" disclosure so it doesn't tempt people without a code to leave and search for one, shows the server's error under the field, and replaces the field with a removable chip once a code is applied. It is presentational and controlled: it never validates a code or calls a server; onApply hands the code to your app, and you pass back loading, error or applied.

Use it when

  • A cart or checkout accepts discount or gift codes: in the OrderSummary footer, or under the cart lines.
  • One code at a time is in effect, and the customer should see which and be able to remove it.

Don't use it when

  • The code is a gift card balance with a PIN and an amount to apply. Build a form from Input fields.
  • Several codes stack. Show each applied code as its own chip and keep a separate field, or extend this per product need.
  • It is any other one-field form (a newsletter signup, a search). Use Input with a Button.

Example

const [code, setCode] = React.useState("");
const [state, setState] = React.useState({});

<PromoCode
  value={code}
  onChange={setCode}
  onApply={async (c) => {
    setState({ loading: true });
    const res = await cart.applyCode(c);
    setState(res.ok ? { applied: { code: res.code, description: res.summary } } : { error: res.message });
  }}
  applied={state.applied}
  onRemove={() => { cart.removeCode(); setState({}); setCode(""); }}
  error={state.error}
  loading={state.loading}
/>

Variants

StateHow you get itWhat shows
CollapsedDefault (collapsible true), no value or errorA link-style "Have a promo code?" button with aria-expanded="false".
OpenPress the disclosure, or collapsible={false}, or a non-empty valueThe labelled field and an Apply button (secondary). Focus moves to the field when the user opens it.
ErrorerrorThe field is marked aria-invalid with the error under it, and stays open.
LoadingloadingThe Apply button shows a spinner and is disabled; Enter does nothing until it clears.
AppliedappliedA chip with a tag icon, the code and its description, and a remove button when onRemove is given. The field is hidden.

Labels are props for translation: label ("Promo code"), toggleLabel ("Have a promo code?"), applyLabel ("Apply").

Composition

Put it in the footer of an OrderSummary, above the checkout Button, or under the lines in a cart Drawer. It uses Input for the field and Button for Apply, so both follow the input and button tokens. When a code is applied, show its effect as a kind: "discount" line in the OrderSummary.

Tokens

Tier 3, in tokens/component/commerce.css, repeated under .dark:

  • --dt-promo-chip-bg (--dt-surface-success-subtle), --dt-promo-chip-border (--dt-border-success), --dt-promo-chip-fg (--dt-text-primary): the applied chip.
  • --dt-promo-chip-icon (--dt-text-success): its tag icon.
  • --dt-promo-chip-description (--dt-text-secondary): the description beside the code.

The disclosure button reads --dt-text-link and --dt-text-label-md-*; the chip's remove button reads --dt-button-ghost-bg and --dt-button-ghost-bg-hover. The field and Apply read the --dt-input-* and --dt-button-* tokens.

Accessibility

  • The disclosure is a real <button> with aria-expanded and aria-controls pointing at the field's panel. Opening it moves focus into the field.
  • The field has a visible <label>. Enter in the field applies the code (and does not submit the surrounding form); Apply does the same.
  • An error shows under the field with role="alert", so it is announced when it appears, and the field is aria-invalid.
  • The applied chip sits in an aria-live="polite" region and reads "Code SUMMER10 applied: 10% off". Its remove button is named "Remove code SUMMER10". After removing, focus moves to the field, so it is not lost to the page.

Content

  • Error messages say what happened and what to do, in sentence case: "This code has expired", "Enter a code to apply it". The server's wording is fine if it follows this.
  • description is what the code does, short: "10% off", "Free shipping", "$5 off orders over $50".
  • Keep the disclosure a question: "Have a promo code?", "Have a gift card?".

Props

The typed contract, from PromoCode.d.ts.

import * as React from "react";

/** A promo or gift code field with an Apply button, a disclosure to reveal it, and a removable chip once a code is applied. */
export interface PromoCodeProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "children"> {
  /** What is typed in the field. Controlled. */
  value: string;
  /** Called with the field's new text. */
  onChange: (value: string) => void;
  /** Called with the trimmed code when Apply is pressed or Enter is pressed in the field. Not called when the field is empty or loading. */
  onApply: (code: string) => void;
  /** The code in effect. Replaces the field with a chip showing the code and its description. */
  applied?: {
    /** The code as the customer should see it, e.g. "SUMMER10". */
    code: string;
    /** What it does: "10% off", "Free shipping". */
    description?: string;
  };
  /** Removes the applied code. Adds a remove button, named "Remove code SUMMER10", to the chip; focus moves to the field afterwards. */
  onRemove?: () => void;
  /** Why the code was not accepted. Shows under the field, which stays open while it is set. */
  error?: string;
  /** Checking the code: the Apply button shows a spinner and Apply does nothing until it clears. @default false */
  loading?: boolean;
  /** The field's visible label. @default "Promo code" */
  label?: string;
  /** Starts as a "Have a promo code?" disclosure button that reveals the field. false shows the field at once. @default true */
  collapsible?: boolean;
  /** Text of the disclosure button. @default "Have a promo code?" */
  toggleLabel?: string;
  /** Text of the apply button. @default "Apply" */
  applyLabel?: string;
  /** id of the text field. Generated when omitted. */
  id?: string;
}

export declare function PromoCode(props: PromoCodeProps): React.JSX.Element;

Tokens it reads

Every custom property PromoCode.jsx resolves, read from the source: 8 component, 161 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-button-ghost-bgcomponentvar(--dt-surface-action-ghost)
--dt-button-ghost-bg-hovercomponentvar(--dt-surface-action-ghost-hover)
--dt-input-label-gapcomponentvar(--dt-space-stack-2xs)
--dt-promo-chip-bgcomponentvar(--dt-surface-success-subtle)
--dt-promo-chip-bordercomponentvar(--dt-border-success)
--dt-promo-chip-descriptioncomponentvar(--dt-text-secondary)
--dt-promo-chip-fgcomponentvar(--dt-text-primary)
--dt-promo-chip-iconcomponentvar(--dt-text-success)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-motion-microsemanticvar(--dt-duration-100) var(--dt-easing-standard)
--dt-radius-pillsemanticvar(--dt-radius-raw-full)
--dt-size-control-smsemanticvar(--dt-dim-8)
--dt-size-control-xssemanticvar(--dt-dim-6)
--dt-size-icon-smsemanticvar(--dt-dim-4)
--dt-space-inline-2xssemanticvar(--dt-dim-1)
--dt-space-inline-xssemanticvar(--dt-dim-2)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-stack-xssemanticvar(--dt-dim-2)
--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 { Button } from "../actions/Button.jsx";
import { Input } from "../forms/Input.jsx";
import { VisuallyHidden } from "../primitives/VisuallyHidden.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)`,
});

function Icon({ paths, size = "var(--dt-size-icon-sm)" }) {
  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 }}
    >
      {paths.map((d) => <path key={d} d={d} />)}
    </svg>
  );
}

const TAG = ["M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z", "M7.5 7.5h.01"];
const CLOSE = ["M18 6 6 18", "m6 6 12 12"];
const CHEVRON = ["m6 9 6 6 6-6"];

export function PromoCode({
  value,
  onChange,
  onApply,
  applied,
  onRemove,
  error,
  loading = false,
  label = "Promo code",
  collapsible = true,
  toggleLabel = "Have a promo code?",
  applyLabel = "Apply",
  id,
  style,
  ...rest
}) {
  const auto = React.useId();
  const inputId = id || auto + "-input";
  const panelId = auto + "-panel";
  const [open, setOpen] = React.useState(() => !collapsible || !!error || !!value);
  const [removeHover, setRemoveHover] = React.useState(false);
  /* Set when the user opens the field or removes a code, so focus follows
     them to the field once it is on the page. */
  const focusNext = React.useRef(false);
  const shown = !collapsible || open || !!error;

  React.useEffect(() => {
    if (!focusNext.current || applied || !shown) return;
    focusNext.current = false;
    const el = document.getElementById(inputId);
    if (el) el.focus();
  });

  const apply = () => {
    const code = (value || "").trim();
    if (!code || loading) return;
    onApply(code);
  };

  const remove = () => {
    focusNext.current = true;
    setOpen(true);
    if (onRemove) onRemove();
  };

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-xs)", minWidth: 0, ...style }} {...rest}>
      {/* Always on the page, so a code that becomes applied is announced.
          Empty, it is taken out of the flow so it adds no gap. */}
      <div aria-live="polite" style={applied ? { display: "flex" } : { position: "absolute" }}>
        {applied && (
          <span
            style={{
              display: "inline-flex", alignItems: "center", flexWrap: "wrap", maxWidth: "100%", boxSizing: "border-box",
              columnGap: "var(--dt-space-inline-xs)",
              minHeight: "var(--dt-size-control-sm)",
              paddingInlineStart: "var(--dt-space-inset-sm)",
              paddingInlineEnd: onRemove ? "var(--dt-space-inset-2xs)" : "var(--dt-space-inset-sm)",
              borderRadius: "var(--dt-radius-pill)",
              border: "var(--dt-border-width-default) solid var(--dt-promo-chip-border)",
              background: "var(--dt-promo-chip-bg)", color: "var(--dt-promo-chip-fg)",
            }}
          >
            <span style={{ color: "var(--dt-promo-chip-icon)" }}><Icon paths={TAG} /></span>
            <span style={{ ...role("label-md"), overflowWrap: "anywhere" }}>
              <VisuallyHidden>Code </VisuallyHidden>
              {applied.code}
              <VisuallyHidden> applied</VisuallyHidden>
            </span>
            {applied.description && (
              <span style={{ ...role("body-xs"), color: "var(--dt-promo-chip-description)" }}>
                <VisuallyHidden>: </VisuallyHidden>
                {applied.description}
              </span>
            )}
            {onRemove && (
              <button
                type="button"
                aria-label={`Remove code ${applied.code}`}
                title={`Remove code ${applied.code}`}
                onClick={remove}
                onMouseEnter={() => setRemoveHover(true)}
                onMouseLeave={() => setRemoveHover(false)}
                style={{
                  display: "inline-flex", alignItems: "center", justifyContent: "center", flex: "none",
                  width: "var(--dt-size-control-xs)", height: "var(--dt-size-control-xs)", padding: 0, border: 0,
                  borderRadius: "var(--dt-radius-pill)", cursor: "pointer", color: "inherit",
                  background: removeHover ? "var(--dt-button-ghost-bg-hover)" : "var(--dt-button-ghost-bg)",
                  transition: "background var(--dt-motion-micro)",
                }}
              >
                <Icon paths={CLOSE} />
              </button>
            )}
          </span>
        )}
      </div>

      {!applied && collapsible && (
        <button
          type="button"
          aria-expanded={shown}
          aria-controls={panelId}
          onClick={() => {
            focusNext.current = !shown;
            setOpen(!shown);
          }}
          style={{
            display: "inline-flex", alignItems: "center", gap: "var(--dt-space-inline-2xs)", alignSelf: "flex-start",
            padding: 0, border: 0, background: "none", cursor: "pointer",
            ...role("label-md"), color: "var(--dt-text-link)",
            textDecoration: "underline", textUnderlineOffset: "var(--dt-space-inline-2xs)",
          }}
        >
          {toggleLabel}
          <span style={{ display: "flex", transform: shown ? "rotate(180deg)" : undefined, transition: "transform var(--dt-motion-micro)" }}>
            <Icon paths={CHEVRON} />
          </span>
        </button>
      )}

      {!applied && shown && (
        <div id={panelId} style={{ display: "flex", flexDirection: "column", gap: "var(--dt-input-label-gap)" }}>
          <label htmlFor={inputId} style={{ ...role("label-md"), color: "var(--dt-text-primary)" }}>{label}</label>
          <div style={{ display: "flex", alignItems: "flex-start", gap: "var(--dt-space-inline-xs)" }}>
            <Input
              id={inputId}
              value={value}
              error={error}
              autoComplete="off"
              autoCapitalize="characters"
              spellCheck={false}
              enterKeyHint="go"
              onChange={(e) => onChange(e.target.value)}
              onKeyDown={(e) => {
                if (e.key !== "Enter") return;
                e.preventDefault();
                apply();
              }}
              style={{ flex: "1 1 auto", minWidth: 0 }}
            />
            <Button variant="secondary" loading={loading} onClick={apply} style={{ flex: "none" }}>
              {applyLabel}
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}