Offer a field's default or a suggested value under it, as an edit the reader makes.

A default

Wrap a field in FieldExtras and pass defaultOffer: a slate line under the field says the default, "Default: 3 days", and a tertiary button at the line's end, Reset to default, sets it. The line shows only while the field does not hold the default, so a settings page full of fields at their defaults shows no offers at all.

isCurrent and onTake are yours: isCurrent is your comparison of the field's value with the default, and onTake is the setter the field's onChange calls. The wrapper reads no value and writes none.

Example

WaitChanged
How long a payment waits before it counts.

Default: 3 days

Follows the page theme and corners
'use client';

import { FieldExtras, PickerOption, Select } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const WAITS = [
  ['day', '1 day'],
  ['three', '3 days'],
  ['week', 'A week'],
  ['month', 'A month'],
] as const;

export default function FieldExtrasDefault(): ReactElement {
  // Saved at a week; the product's default is 3 days.
  const [wait, setWait] = useState('week');
  return (
    <FieldExtras
      fieldLabel="Wait"
      defaultOffer={{
        value: '3 days',
        // Drawn only while the field does not hold the default.
        isCurrent: wait === 'three',
        // The same setter the field's onChange calls: an ordinary edit.
        onTake: () => {
          setWait('three');
        },
      }}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      <Select
        label="Wait"
        description="How long a payment waits before it counts."
        value={wait}
        onChange={(key) => {
          setWait(String(key));
        }}
      >
        {WAITS.map(([id, name]) => (
          <PickerOption key={id} id={id}>
            {name}
          </PickerOption>
        ))}
      </Select>
    </FieldExtras>
  );
}

A suggestion

suggestion offers a value your app worked out for this reader, with its reason after it: "Suggested: $2,340, a month of your fixed bills". It is never prefilled into the field and never shown as the field's placeholder: a placeholder is taupe, for a hint about the format, and a suggestion is words the reader reads and takes with one press.

Every figure and word is your string, as the field shows it. Where a figure sits in a sentence of another script, such as a dollar amount on a Persian page, isolate it so it keeps its order. value is a string, and a bdi written in a string is drawn as its letters, so mark the figure with the Unicode isolates that do what bdi does: value: '⁨-$40.00⁩', first strong isolate before it and pop directional isolate after it. A reason is a node, so it can hold a bdi.

Example

Changed
$
Kept aside before anything counts as spare.

Suggested: $2,340, a month of your fixed bills

Follows the page theme and corners
'use client';

import { FieldExtras, NumberField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

/** A month of the reader's fixed bills, in cents, which the app worked out. */
const MONTH_OF_BILLS = 234000;

export default function FieldExtrasSuggestion(): ReactElement {
  // Empty until the reader types or takes the suggestion: never prefilled.
  const [cents, setCents] = useState<number | null>(null);
  return (
    <FieldExtras
      fieldLabel="Cushion"
      suggestion={{
        value: '$2,340',
        reason: 'a month of your fixed bills',
        isCurrent: cents === MONTH_OF_BILLS,
        onTake: () => {
          setCents(MONTH_OF_BILLS);
        },
      }}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      <NumberField
        label="Cushion"
        description="Kept aside before anything counts as spare."
        money={{ symbol: '$' }}
        value={cents}
        onChange={setCents}
      />
    </FieldExtras>
  );
}

Both

With both, the suggestion comes first, since it is about this reader and the default is about everyone, and each has its own line. When the suggestion's value is the default's, only the default is drawn: "the default" is the plainer reason.

Example

Changed
$

Suggested: $2,340, a month of your fixed bills

Default: $1,000

Follows the page theme and corners
'use client';

import { FieldExtras, NumberField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const SUGGESTED = 234000;
const DEFAULT = 100000;

export default function FieldExtrasBoth(): ReactElement {
  const [cents, setCents] = useState<number | null>(50000);
  return (
    <FieldExtras
      fieldLabel="Cushion"
      // The suggestion is drawn first: it is about this reader, the default about everyone.
      suggestion={{
        value: '$2,340',
        reason: 'a month of your fixed bills',
        isCurrent: cents === SUGGESTED,
        onTake: () => {
          setCents(SUGGESTED);
        },
      }}
      defaultOffer={{
        value: '$1,000',
        isCurrent: cents === DEFAULT,
        onTake: () => {
          setCents(DEFAULT);
        },
      }}
      style={{ width: '100%', maxWidth: '24rem' }}
    >
      <NumberField label="Cushion" money={{ symbol: '$' }} value={cents} onChange={setCents} />
    </FieldExtras>
  );
}

A pair of fields

scope="group" offers one default for a group of fields, such as quiet hours from and to. The line sits under the whole group, the wrapper spans the group instead of stopping at a field's measure, and after a reset focus goes to the group's first field. A set of rows, such as a Repeater, takes the same scope.

Example

Changed
Changed

Default: 10 PM to 7 AM

Follows the page theme and corners
'use client';

import { FieldExtras, TextField } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

const DEFAULT = { from: '10 PM', to: '7 AM' };

export default function FieldExtrasPair(): ReactElement {
  const [hours, setHours] = useState({ from: '11 PM', to: '6 AM' });
  return (
    <FieldExtras
      // One offer for the pair: its line sits under both, and a reset focuses From.
      scope="group"
      fieldLabel="Quiet hours"
      defaultOffer={{
        value: '10 PM to 7 AM',
        isCurrent: hours.from === DEFAULT.from && hours.to === DEFAULT.to,
        onTake: () => {
          setHours(DEFAULT);
        },
      }}
      style={{ width: '100%', maxWidth: '28rem' }}
    >
      <div className="row" style={{ alignItems: 'start' }}>
        <TextField
          label="From"
          value={hours.from}
          onChange={(from) => {
            setHours({ ...hours, from });
          }}
          style={{ flex: '1 1 8rem' }}
        />
        <TextField
          label="To"
          value={hours.to}
          onChange={(to) => {
            setHours({ ...hours, to });
          }}
          style={{ flex: '1 1 8rem' }}
        />
      </div>
    </FieldExtras>
  );
}

A set of rows

A Repeater's rows take one offer for the whole set: wrap it in FieldExtras with scope="group", compare its rows with the default set for isCurrent, and in onTake set the default rows, each with a new id. After a reset focus goes to the first row's first field, or to Add when the default set has no rows. The Repeater knows nothing of the default: the offer is yours, like any other.

Example

Wait tiers
  1. Tier 1
    Changed
    $
    WaitChanged
  2. Tier 2
    Changed
    $
    WaitChanged
2 of 3

Default: $100 waits 3 days, $500 a week, $1,500 a month

Follows the page theme and corners
'use client';

import { FieldExtras, NumberField, PickerOption, Repeater, Select } from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';

interface Tier {
  id: string;
  amount: number | null;
  wait: string;
}

const WAITS = [
  ['three', '3 days'],
  ['week', 'A week'],
  ['two-weeks', 'Two weeks'],
  ['month', 'A month'],
] as const;

/** The product's default set, without ids: a reset gives each row a new one. */
const DEFAULT_TIERS = [
  { amount: 10000, wait: 'three' },
  { amount: 50000, wait: 'week' },
  { amount: 150000, wait: 'month' },
];

let made = 0;
const newId = (): string => {
  made += 1;
  return `tier-${String(made)}`;
};

export default function FieldExtrasRows(): ReactElement {
  const [tiers, setTiers] = useState<Tier[]>([
    { id: 'small', amount: 20000, wait: 'three' },
    { id: 'large', amount: 80000, wait: 'two-weeks' },
  ]);
  const isDefault =
    JSON.stringify(tiers.map(({ amount, wait }) => ({ amount, wait }))) === JSON.stringify(DEFAULT_TIERS);
  return (
    <FieldExtras
      // One offer for the whole set: a reset replaces the rows and focuses the first row's first field.
      scope="group"
      fieldLabel="Wait tiers"
      defaultOffer={{
        value: '$100 waits 3 days, $500 a week, $1,500 a month',
        isCurrent: isDefault,
        onTake: () => {
          setTiers(DEFAULT_TIERS.map((tier) => ({ ...tier, id: newId() })));
        },
      }}
      style={{ width: '100%', maxWidth: '40rem' }}
    >
      <Repeater<Tier>
        label="Wait tiers"
        value={tiers}
        onChange={setTiers}
        createRow={() => ({ id: newId(), amount: null, wait: 'three' })}
        rowLabel={(index) => `Tier ${String(index + 1)}`}
        maxRows={3}
        labels={{ add: 'Add a tier' }}
      >
        {(tier, { update }) => (
          <>
            <NumberField
              label="Up to"
              money={{ symbol: '$' }}
              value={tier.amount}
              onChange={(amount) => {
                update({ ...tier, amount });
              }}
            />
            <Select
              label="Wait"
              value={tier.wait}
              onChange={(key) => {
                update({ ...tier, wait: String(key) });
              }}
            >
              {WAITS.map(([id, name]) => (
                <PickerOption key={id} id={id}>
                  {name}
                </PickerOption>
              ))}
            </Select>
          </>
        )}
      </Repeater>
    </FieldExtras>
  );
}

With the changed state and the save bar

Changed and Default are separate questions. Changed means the field differs from what is saved, and draws the clay chip; Default means the product's value. Since an offer taken is an ordinary edit, both follow your comparisons as they do for typing:

  • A field saved at its default shows no offer and no chip.
  • Edited away from the default, it shows the chip and Reset to default. Reset, the chip and the offer leave together.
  • Saved away from its default, it shows Reset to default and no chip. Reset, it shows the chip, since it now differs from what is saved, and no offer.
  • A save bar counts a reset or a use as one change, by your message, and its Cancel puts the saved values back, which brings back the offers they had.

An offer never fights the field's checks. Its button is type="button", so a press never submits the form or runs the browser's checks; it adds nothing to the field's description and nothing to the form's data; and the field's own error stays until your validation says otherwise. Do not offer a value to a field the reader cannot change, such as a disabled or read-only one.

Usage

A client component: the offers are in the server's HTML from the props, and nothing is read until a press. The words are the library's, in English, so a page in another language passes its own labels.

import { FieldExtras, Select, PickerOption } from '@snapshot-ui/react';

<FieldExtras
  fieldLabel="Wait"
  defaultOffer={{
    value: '3 days',
    isCurrent: wait === 'three',
    onTake: () => setWait('three'),
  }}
>
  <Select label="Wait" value={wait} onChange={(key) => setWait(String(key))}>
    <PickerOption id="three">3 days</PickerOption>
    <PickerOption id="week">A week</PickerOption>
  </Select>
</FieldExtras>;

After a take focus goes to the first control in the wrapper that takes focus, and to a NumberField's text box rather than the sign button allowsNegative draws before it. Pass controlRef when another control is the one to focus.

Props

className, ref and any other attribute go on the root, div.field-extras, which holds the field and its offers.

className
string

controlRef
RefObject<HTMLElement | null>

The control that takes focus after an offer is taken. Without it, the first control in children that takes focus.

defaultOffer
FieldOffer

The default, and Reset to default.

fieldLabel
string

The field's or the group's label as text, which ends each button's name: "Reset to default, Wait tiers".

labels
Partial<FieldExtrasLabels>

scope
"field" | "group"

field stops at the field's measure; group spans the group and puts its offers under the whole of it.

Default: 'field'

suggestion
FieldOffer

A suggested value, and Use.

isCurrent
boolean

Whether the field already holds it. The offer is drawn only when it does not.

onTake
() => void

Sets the field to the offered value: the app's setter, the one the field's onChange calls. Nothing is saved.

reason
ReactNode

Why it is offered, after the value: "from your last three months". Suggestion only.

value
string

The offered value as the app writes it: "24 hours", "$40.00". A string, so a figure among words of another script is isolated with U+2068 and U+2069, which do in a string what bdi does in markup.

default
(value: string) => string

Default: (value) => `Default: ${value}`

reset
string

Default: 'Reset to default'

resetStatus
string

Said politely after a default is taken, once focus is on the field, whose own words give its label and value.

Default: 'Set to the default'

suggested
(value: string) => string

Default: (value) => `Suggested: ${value}`

use
string

Default: 'Use'

usedStatus
string

Said politely after a suggestion is taken, once focus is on the field, whose own words give its label and value.

Default: 'Set to the suggestion'

Accessibility

Each offer's button is a native button named by its word and the field's label, "Reset to default, Wait", so a list of offers reads as a list of fields, and described by the offer's words, "Default: 3 days". The name starts with the word the button shows, so voice control finds it by what is seen.

When an offer is taken it leaves, since the field now holds it, and focus goes to the field's control in the next frame, never the body, after a key or a pointer press alike: the reader hears the field with its new value and can type in it at once. Then a polite status, in the server's HTML and empty until then, says what happened, "Set to the suggestion" or "Set to the default", for a screen reader that does not read a field focused by script. It speaks once focus is on the field, so it follows the field's own words rather than being cut by them, and it does not say the field's label or value again. If your setter moves focus itself, such as into a dialog that asks first, focus stays where you put it and the status says nothing, since nothing is set yet. Its words leave as soon as the field no longer holds what was taken, after a Cancel for one.

TabMoves from the field to its offers' buttons, in order.
EnterSpaceTakes the offer; focus goes to the field, and a status says what was set.

Design rules

What keeps offers in the system.

Say the value in the offer, as the field shows it.

Give a suggestion its reason.

Compare with the saved value for the Changed chip and the save bar, as you do for typing.

Never draw an offer in clay. It is not unsaved work.

Never in signal orange or success. It is not consent, nor a good outcome.

Never prefill a suggestion or show it as a taupe placeholder.

esc

↑ ↓ to move↵ to open