Skip to content

Form and picker dialogs ​

@chart-on/ui ships two dialog primitives that every settings surface can share, in the widget and in your own app:

  • createFormDialog builds a settings form from a schema: sections, groups and inputs, with conditional visibility, validation messages and a live preview that Cancel undoes.
  • createPickerDialog is a searchable list with categories, single or multiple selection, paging, row actions, and loading, error and empty states.

Both open at once in the element you pass (use the shell element, shell.el, so they pick up the theme), cover it while open, trap focus, close with Escape and give focus back to where it was. Below a host width of 600 px (compactWidth) they switch to a compact layout: tabs become a select, and a sidebar becomes a list of sections that opens one at a time.

ts
import type { Bar, LineStyle } from '@chart-on/core';
import {
  createChartShell,
  createFormDialog,
  registerInput,
  type FormInputBase,
  type FormDialog,
  type FormSchema,
  type FormValues,
  type InputRenderer,
} from '@chart-on/ui';
import '@chart-on/ui/style.css';
import type { ExampleContext } from './types';
import { sampleBars } from './sample-data';

// A custom input kind: a minus / value / plus stepper for small whole numbers.
interface StepperInputDef extends FormInputBase<number> {
  kind: 'stepper';
  min?: number;
  max?: number;
}

// Tell the schema types about the new kind.
declare module '@chart-on/ui' {
  interface FormInputKinds {
    stepper: StepperInputDef;
  }
}

const stepper: InputRenderer<StepperInputDef, number> = (input, ctx) => {
  let value = ctx.value;
  const min = input.min ?? 1;
  const max = input.max ?? 10;
  const el = document.createElement('div');
  el.className = 'stepper';
  el.setAttribute('role', 'group');
  el.setAttribute('aria-labelledby', ctx.labelId);
  const minus = document.createElement('button');
  const plus = document.createElement('button');
  const shown = document.createElement('output');
  minus.type = plus.type = 'button';
  minus.textContent = '−';
  plus.textContent = '+';
  minus.setAttribute('aria-label', `Decrease ${input.label}`);
  plus.setAttribute('aria-label', `Increase ${input.label}`);
  minus.setAttribute('id', ctx.controlId);
  const show = (next: number) => {
    value = next;
    shown.textContent = String(next);
    minus.disabled = next <= min;
    plus.disabled = next >= max;
  };
  const step = (by: number) => {
    show(Math.min(max, Math.max(min, value + by)));
    ctx.set(value);
  };
  minus.addEventListener('click', () => step(-1));
  plus.addEventListener('click', () => step(1));
  el.append(minus, shown, plus);
  show(value);
  return {
    el,
    focusTarget: minus,
    setValue: show,
    setDisabled: (disabled) => {
      minus.disabled = plus.disabled = disabled;
    },
  };
};

const schema: FormSchema = {
  id: 'moving-average',
  title: 'Moving average',
  sections: [
    {
      id: 'inputs',
      title: 'Inputs',
      groups: [
        {
          id: 'calc',
          inputs: [
            {
              kind: 'number',
              id: 'length',
              label: 'Length',
              default: 20,
              min: 2,
              max: 200,
              integer: true,
            },
            { kind: 'source', id: 'source', label: 'Source', default: 'close' },
          ],
        },
      ],
    },
    {
      id: 'style',
      title: 'Style',
      groups: [
        {
          id: 'line',
          label: 'Line',
          inputs: [
            { kind: 'color', id: 'color', label: 'Color', default: '#f59e0b' },
            {
              kind: 'stepper',
              id: 'width',
              label: 'Width',
              default: 2,
              min: 1,
              max: 6,
            },
            {
              kind: 'select',
              id: 'lineStyle',
              label: 'Style',
              default: 'solid',
              options: [
                { value: 'solid', label: 'Solid' },
                { value: 'dashed', label: 'Dashed' },
                { value: 'dotted', label: 'Dotted' },
              ],
            },
          ],
        },
      ],
    },
  ],
};

export default function mount(el: HTMLElement, { dark }: ExampleContext) {
  const unregister = registerInput('stepper', stepper);
  let dialog: FormDialog | null = null;
  const shell = createChartShell(el, {
    theme: dark ? 'dark' : 'light',
    rail: false,
    toolbar: [
      {
        type: 'button',
        id: 'settings',
        label: 'Moving average settings',
        text: 'Settings',
        // Every change previews live; Cancel or Escape puts the opening values back.
        onClick: () =>
          (dialog = createFormDialog(shell.el, schema, {
            values: settings,
            onChange: apply,
            onAction: keep,
          })),
      },
    ],
  });
  const bars = sampleBars();
  shell.chart.addCandles({ title: 'SAMPLE' }).setData(bars);
  const line = shell.chart.addLine({ title: 'MA' });
  let settings: FormValues = {};
  const apply = (values: FormValues) => {
    line.applyOptions({
      color: values.color as string,
      lineWidth: values.width as number,
      lineStyle: values.lineStyle as LineStyle,
    });
    line.setData(movingAverage(bars, values.length as number, values.source as keyof Bar));
  };
  const keep = (action: string, values: FormValues) => {
    if (action === 'ok') settings = values;
  };
  settings = {
    length: 20,
    source: 'close',
    color: '#f59e0b',
    width: 2,
    lineStyle: 'solid',
  };
  apply(settings);
  shell.chart.fitContent();

  return () => {
    dialog?.close();
    unregister();
    shell.destroy();
  };
}

/** Simple moving average of one bar field over `length` bars; averages such as `hl2` are computed. */
function movingAverage(bars: Bar[], length: number, source: string) {
  const pick = (b: Bar) =>
    source === 'hl2'
      ? (b.high + b.low) / 2
      : source === 'hlc3'
        ? (b.high + b.low + b.close) / 3
        : source === 'ohlc4'
          ? (b.open + b.high + b.low + b.close) / 4
          : (b[source as 'open' | 'high' | 'low' | 'close'] ?? b.close);
  const out = [];
  let sum = 0;
  for (let i = 0; i < bars.length; i++) {
    sum += pick(bars[i]!);
    if (i >= length) sum -= pick(bars[i - length]!);
    if (i >= length - 1) out.push({ time: bars[i]!.time, value: sum / length });
  }
  return out;
}

Form dialog ​

ts
import { createFormDialog, type FormSchema } from '@chart-on/ui';

const schema: FormSchema = {
  id: 'bands',
  title: 'Bands',
  layout: 'single', // or 'sidebar'
  sections: [
    {
      id: 'inputs',
      title: 'Inputs',
      groups: [
        {
          id: 'calc',
          inputs: [
            { kind: 'number', id: 'length', label: 'Length', default: 20, min: 2, integer: true },
            { kind: 'source', id: 'source', label: 'Source', default: 'close' },
            { kind: 'boolean', id: 'smooth', label: 'Smooth', default: false },
            // Shown only while `smooth` is on.
            { kind: 'number', id: 'factor', label: 'Factor', default: 2, visible: (v) => v.smooth === true },
          ],
        },
      ],
    },
  ],
};

const dialog = createFormDialog(shell.el, schema, {
  values: current,                       // inputs without a value start from `default`
  onChange: (values) => preview(values), // every edit that leaves the form valid
  onAction: (id, values) => {
    if (id === 'ok') save(values);
  },
});
Option
valuesValues to edit, by input id
onChange(values)Live preview: after each edit that leaves the form valid, and with the opening values on cancel
onFieldChange(id, value, values)After each edit whose own value is valid
onAction(id, values, dialog)An action ran. A submit only reaches it when the form is valid; return false to keep the dialog open
inputsRenderers for this dialog only, by kind
sectionSection to show first
compactWidthHost width in px below which the compact layout is used. Default 600
presentation'modal' (default) or 'panel': a non-modal panel docked to the host's right edge, see Panel
headerControlsExtra elements in the header, before the close button
closeLabelAccessible name of the close button. Default the dialogClose message in the localization's language, 'Close' in English
localizationLanguage of the dialog's own text (Ok, Cancel, the close button, validation messages), usually chart.localization; read when the dialog opens. Default English. Actions take a labelKey (dialogOk, say) that applies while their label is the key's English text

The dialog handle has values, errors, section, showSection(id), setValues(patch) (no callbacks), submit(), cancel() and close().

Inputs ​

KindValueExtra fields
numbernumbermin, max, step, integer
textstringplaceholder, maxLength, required
booleanboolean
selectstring or numberoptions, searchable (a search box over the list, for long lists such as timezones; it matches labels and values)
multi-selectarray of option valuesoptions
colorCSS colorswatches, placeholder (the color shown while the value is empty; with one, an empty value is valid)
slidernumbermin, max, step, unit
range[from, to]min, max, step
sourcestringoptions, default defaultSources (open, high, low, close and the usual averages)

Every input has id, label, and optionally hint, default, visible, disabled and validate. visible and disabled are a boolean or a function of the current values; groups and sections take visible too. Hidden and disabled inputs keep their values and are not validated.

validate(value, values) returns a message, or nothing when the value is fine. It runs after the built-in checks (a number in range, a required text, a known option…). Messages show under the input once it has been edited, or for every input after a submit, which then moves to the first one.

Groups can have a label, and with collapsible (and collapsed) the label folds them.

select and source fields show the chosen option on a closed control (a button with aria-haspopup="listbox", labelled by the field label) that opens a themed list in a popover; it is fixed to the viewport, so a scrolling dialog body does not clip it, and it closes when the field scrolls out of view. Arrow and Page keys move the active option, Enter or a click picks it and closes the list, and Escape or a press outside closes it without a change; typing on the closed control opens it with that text. Without searchable, focus moves into the list (the active option is its aria-activedescendant), where Home and End go to the ends, typing jumps to the option starting with the typed text and Space picks. With searchable, a search box (a combobox over the list) filters the options instead.

Actions ​

The footer defaults to Cancel and Ok. Pass actions to change it:

ts
actions: [
  { id: 'defaults', label: 'Defaults', role: 'reset', align: 'start' },
  { id: 'cancel', label: 'Cancel', role: 'cancel' },
  { id: 'ok', label: 'Ok', role: 'submit' },
],

submit validates and closes, cancel (also Escape and the close button) restores the opening values through onChange and closes, reset puts back the inputs' defaults, and button only calls onAction. Enter in a one-line input submits. align: 'header' puts an action in the header beside the close button instead of the footer. align: 'nav' puts one under the section list of a sidebar layout, for buttons that lead elsewhere; in the compact layout it joins the section list, and without a sidebar it sits at the footer start.

Panel ​

With presentation: 'panel' the form slides in from the right edge of the host instead of opening over a backdrop, so whatever it edits stays in view and usable while the preview updates. It has no focus trap and no aria-modal: Tab can leave it, and presses outside reach the chart. Escape (with focus inside) and the close button still cancel. Below compactWidth it becomes a bottom sheet. It slides out on close, and appears and disappears at once when the user prefers reduced motion. The indicator settings use it; mount it in the chart's container (the shell's .pc-stage) so it covers the chart only.

Custom input kinds ​

Register a renderer for a new kind with registerInput(kind, renderer); it returns a function that removes it again. A renderer gets the input and a context, and returns the control:

ts
import { registerInput, type FormInputBase, type InputRenderer } from '@chart-on/ui';

interface StepperInputDef extends FormInputBase<number> {
  kind: 'stepper';
  max?: number;
}

// Let schemas use the kind with full typing.
declare module '@chart-on/ui' {
  interface FormInputKinds {
    stepper: StepperInputDef;
  }
}

const stepper: InputRenderer<StepperInputDef, number> = (input, ctx) => {
  let value = ctx.value;
  const el = document.createElement('button');
  el.id = ctx.controlId; // the field's label points at it
  el.textContent = String(value);
  el.addEventListener('click', () => {
    value = Math.min(input.max ?? 10, value + 1);
    el.textContent = String(value);
    ctx.set(value);
  });
  return {
    el,
    // Reset, cancel and setValues: show the value without reporting it.
    setValue: (next) => (el.textContent = String((value = next))),
    setDisabled: (disabled) => (el.disabled = disabled),
  };
};

const unregister = registerInput('stepper', stepper);

ctx.set(value) reports what the user entered; the form validates it, updates visibility and calls onChange. Put ctx.controlId on the focusable element (or return it as focusTarget), or label a group of controls with aria-labelledby set to ctx.labelId. Registering a built-in kind's name replaces it until the returned function runs. For one dialog only, pass inputs: { stepper } instead.

createFormDialog throws when the schema has mistakes (duplicate ids, unknown kinds, a select without options…); validateFormSchema(schema) lists them without opening anything.

Picker dialog ​

ts
import { createPickerDialog } from '@chart-on/ui';

createPickerDialog(
  shell.el,
  {
    title: 'Add indicator',
    source: { items: definitions.map((d) => ({ id: d.id, label: d.name, category: d.category })) },
    categories: [
      { id: 'trend', label: 'Trend' },
      { id: 'momentum', label: 'Momentum' },
    ],
    rowActions: [{ id: 'star', label: 'Favorite', icon: Star, pressed: (item) => favorites.has(item.id) }],
  },
  {
    onSelect: ([item]) => add(item.id),
    onAction: (action, item, dialog) => {
      toggleFavorite(item.id);
      dialog.refresh();
    },
  },
  { localization: chart.localization }, // optional
);

Items need an id and a label; description, category (one id or several), keywords and disabled are optional. With source: { items } the picker filters by category and ranks matches itself: exact label, id or keyword first, then label prefix, keyword or id prefix, acronym of the label or a keyword (rsi finds "Relative Strength Index"), word prefix, substring of the label or a keyword, all query words, and description. Equal matches keep their order.

rankItems(items, query, options?) is the same ranking on its own, and matchScore(item, query, { typos }) scores one item (lower is better, null for no match). Two options change the ranking:

  • typos: true also matches a query of three or more characters one typo away (a missing, extra, wrong or swapped letter) from a label, a keyword or one of their words, ranked just above description matches: rsl finds RSI. The indicator picker ranks this way.
  • favorites: ['id', …] puts those items first among equally good matches; a better match still comes first.

For server-side search pass source: { fetch(query, { page, pageSize, category, signal }) }, returning an array or { items, hasMore }. Typing is debounced (debounce, default 200 ms for fetch, none for items), older requests are aborted through signal, and the next page loads as the list scrolls.

Option
categories, allLabelCategory list beside the results; the first entry, paletteAll ("All") by default, lists everything. allLabel: false drops it
multiple, selectedPick several, confirmed with Done; selected holds the ids picked on open
closeOnSelectSingle select: false keeps the dialog open to pick several in a row
rowActionsButtons at the end of each row, with an optional pressed state and appliesTo(item) to show one on some rows only
pageSizeItems per page. Default 50
placeholder, searchLabel, emptyText, closeLabelTexts (closeLabel names the close button). Their defaults are the dialogSearch, pickerEmpty and dialogClose messages

The third argument holds the callbacks. The optional fourth, { localization } (usually chart.localization), sets the language of the defaults above and of Done, Cancel, the loading and empty messages and the counts; without it they are in English.

Keyboard: typing searches, the arrow and Page keys move through the rows, Enter picks (or toggles, with multiple; Ctrl+Enter confirms), Tab reaches the active row's buttons and the category list, whose arrows, Home and End switch category, and Escape closes. Up and Down on a row button move the active row and go back to the search box, and a row button keeps focus when dialog.refresh() redraws the rows. The indicator picker runs on the same list.

Styling ​

Both dialogs use the shell's --pc-* tokens, plus --pc-dialog-bg, --pc-dialog-backdrop, --pc-dialog-radius, --pc-dialog-shadow, --pc-dialog-width, --pc-dialog-panel-width, --pc-dialog-height, --pc-dialog-sidebar-width, --pc-dialog-padding, --pc-dialog-btn-min-width (the smallest width of a footer text button), --pc-dialog-field-height and --pc-dialog-error. The panel carries .pc-dialog with .pc-form-dialog or .pc-picker, and data-compact="true" in the compact layout. A non-modal panel adds .pc-dialog--panel (and data-closing="true" while it slides out); its positioning wrapper is .pc-dialog-backdrop--panel.

The symbol search and the indicator picker keep their own result lists but share this frame's behaviour (Escape, focus trap, presses outside, compact mode) and tokens. Their panels carry .pc-symbol-search or .pc-indicator-picker and the same data-compact attribute while open; in the compact layout the symbol search puts its exchange select on a row of its own under the search box.