• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// Copyright (C) 2023 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import m from 'mithril';
16import {classNames} from '../../base/classnames';
17import {Hotkey, Platform} from '../../base/hotkeys';
18import {isString} from '../../base/object_utils';
19import {Icons} from '../../base/semantic_icons';
20import {Anchor} from '../../widgets/anchor';
21import {Button} from '../../widgets/button';
22import {Callout} from '../../widgets/callout';
23import {Checkbox} from '../../widgets/checkbox';
24import {Editor} from '../../widgets/editor';
25import {EmptyState} from '../../widgets/empty_state';
26import {Form, FormLabel} from '../../widgets/form';
27import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs';
28import {Icon} from '../../widgets/icon';
29import {Menu, MenuDivider, MenuItem, PopupMenu} from '../../widgets/menu';
30import {showModal} from '../../widgets/modal';
31import {
32  MultiSelect,
33  MultiSelectDiff,
34  PopupMultiSelect,
35} from '../../widgets/multiselect';
36import {Popup, PopupPosition} from '../../widgets/popup';
37import {Portal} from '../../widgets/portal';
38import {Select} from '../../widgets/select';
39import {Spinner} from '../../widgets/spinner';
40import {Switch} from '../../widgets/switch';
41import {TextInput} from '../../widgets/text_input';
42import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph';
43import {LazyTreeNode, Tree, TreeNode} from '../../widgets/tree';
44import {VegaView} from '../../components/widgets/vega_view';
45import {PageAttrs} from '../../public/page';
46import {TableShowcase} from './table_showcase';
47import {TreeTable, TreeTableAttrs} from '../../components/widgets/treetable';
48import {Intent} from '../../widgets/common';
49import {
50  VirtualTable,
51  VirtualTableAttrs,
52  VirtualTableRow,
53} from '../../widgets/virtual_table';
54import {TagInput} from '../../widgets/tag_input';
55import {SegmentedButtons} from '../../widgets/segmented_buttons';
56import {MiddleEllipsis} from '../../widgets/middle_ellipsis';
57import {Chip, ChipBar} from '../../widgets/chip';
58import {TrackShell} from '../../widgets/track_shell';
59import {CopyableLink} from '../../widgets/copyable_link';
60import {VirtualOverlayCanvas} from '../../widgets/virtual_overlay_canvas';
61import {SplitPanel} from '../../widgets/split_panel';
62import {TabbedSplitPanel} from '../../widgets/tabbed_split_panel';
63import {parseAndPrintTree} from '../../base/perfetto_sql_lang/language';
64
65const DATA_ENGLISH_LETTER_FREQUENCY = {
66  table: [
67    {category: 'a', amount: 8.167},
68    {category: 'b', amount: 1.492},
69    {category: 'c', amount: 2.782},
70    {category: 'd', amount: 4.253},
71    {category: 'e', amount: 12.7},
72    {category: 'f', amount: 2.228},
73    {category: 'g', amount: 2.015},
74    {category: 'h', amount: 6.094},
75    {category: 'i', amount: 6.966},
76    {category: 'j', amount: 0.253},
77    {category: 'k', amount: 1.772},
78    {category: 'l', amount: 4.025},
79    {category: 'm', amount: 2.406},
80    {category: 'n', amount: 6.749},
81    {category: 'o', amount: 7.507},
82    {category: 'p', amount: 1.929},
83    {category: 'q', amount: 0.095},
84    {category: 'r', amount: 5.987},
85    {category: 's', amount: 6.327},
86    {category: 't', amount: 9.056},
87    {category: 'u', amount: 2.758},
88    {category: 'v', amount: 0.978},
89    {category: 'w', amount: 2.36},
90    {category: 'x', amount: 0.25},
91    {category: 'y', amount: 1.974},
92    {category: 'z', amount: 0.074},
93  ],
94};
95
96const DATA_POLISH_LETTER_FREQUENCY = {
97  table: [
98    {category: 'a', amount: 8.965},
99    {category: 'b', amount: 1.482},
100    {category: 'c', amount: 3.988},
101    {category: 'd', amount: 3.293},
102    {category: 'e', amount: 7.921},
103    {category: 'f', amount: 0.312},
104    {category: 'g', amount: 1.377},
105    {category: 'h', amount: 1.072},
106    {category: 'i', amount: 8.286},
107    {category: 'j', amount: 2.343},
108    {category: 'k', amount: 3.411},
109    {category: 'l', amount: 2.136},
110    {category: 'm', amount: 2.911},
111    {category: 'n', amount: 5.6},
112    {category: 'o', amount: 7.59},
113    {category: 'p', amount: 3.101},
114    {category: 'q', amount: 0.003},
115    {category: 'r', amount: 4.571},
116    {category: 's', amount: 4.263},
117    {category: 't', amount: 3.966},
118    {category: 'u', amount: 2.347},
119    {category: 'v', amount: 0.034},
120    {category: 'w', amount: 4.549},
121    {category: 'x', amount: 0.019},
122    {category: 'y', amount: 3.857},
123    {category: 'z', amount: 5.62},
124  ],
125};
126
127const DATA_EMPTY = {};
128
129const SPEC_BAR_CHART = `
130{
131  "$schema": "https://vega.github.io/schema/vega/v5.json",
132  "description": "A basic bar chart example, with value labels shown upon mouse hover.",
133  "width": 400,
134  "height": 200,
135  "padding": 5,
136
137  "data": [
138    {
139      "name": "table"
140    }
141  ],
142
143  "signals": [
144    {
145      "name": "tooltip",
146      "value": {},
147      "on": [
148        {"events": "rect:mouseover", "update": "datum"},
149        {"events": "rect:mouseout",  "update": "{}"}
150      ]
151    }
152  ],
153
154  "scales": [
155    {
156      "name": "xscale",
157      "type": "band",
158      "domain": {"data": "table", "field": "category"},
159      "range": "width",
160      "padding": 0.05,
161      "round": true
162    },
163    {
164      "name": "yscale",
165      "domain": {"data": "table", "field": "amount"},
166      "nice": true,
167      "range": "height"
168    }
169  ],
170
171  "axes": [
172    { "orient": "bottom", "scale": "xscale" },
173    { "orient": "left", "scale": "yscale" }
174  ],
175
176  "marks": [
177    {
178      "type": "rect",
179      "from": {"data":"table"},
180      "encode": {
181        "enter": {
182          "x": {"scale": "xscale", "field": "category"},
183          "width": {"scale": "xscale", "band": 1},
184          "y": {"scale": "yscale", "field": "amount"},
185          "y2": {"scale": "yscale", "value": 0}
186        },
187        "update": {
188          "fill": {"value": "steelblue"}
189        },
190        "hover": {
191          "fill": {"value": "red"}
192        }
193      }
194    },
195    {
196      "type": "text",
197      "encode": {
198        "enter": {
199          "align": {"value": "center"},
200          "baseline": {"value": "bottom"},
201          "fill": {"value": "#333"}
202        },
203        "update": {
204          "x": {"scale": "xscale", "signal": "tooltip.category", "band": 0.5},
205          "y": {"scale": "yscale", "signal": "tooltip.amount", "offset": -2},
206          "text": {"signal": "tooltip.amount"},
207          "fillOpacity": [
208            {"test": "datum === tooltip", "value": 0},
209            {"value": 1}
210          ]
211        }
212      }
213    }
214  ]
215}
216`;
217
218const SPEC_BAR_CHART_LITE = `
219{
220  "$schema": "https://vega.github.io/schema/vega-lite/v5.json",
221  "description": "A simple bar chart with embedded data.",
222  "data": {
223    "name": "table"
224  },
225  "mark": "bar",
226  "encoding": {
227    "x": {"field": "category", "type": "nominal", "axis": {"labelAngle": 0}},
228    "y": {"field": "amount", "type": "quantitative"}
229  }
230}
231`;
232
233const SPEC_BROKEN = `{
234  "description": 123
235}
236`;
237
238enum SpecExample {
239  BarChart = 'Barchart',
240  BarChartLite = 'Barchart (Lite)',
241  Broken = 'Broken',
242}
243
244enum DataExample {
245  English = 'English',
246  Polish = 'Polish',
247  Empty = 'Empty',
248}
249
250function arg<T>(
251  anyArg: unknown,
252  valueIfTrue: T,
253  valueIfFalse: T | undefined = undefined,
254): T | undefined {
255  return Boolean(anyArg) ? valueIfTrue : valueIfFalse;
256}
257
258function getExampleSpec(example: SpecExample): string {
259  switch (example) {
260    case SpecExample.BarChart:
261      return SPEC_BAR_CHART;
262    case SpecExample.BarChartLite:
263      return SPEC_BAR_CHART_LITE;
264    case SpecExample.Broken:
265      return SPEC_BROKEN;
266    default:
267      const exhaustiveCheck: never = example;
268      throw new Error(`Unhandled case: ${exhaustiveCheck}`);
269  }
270}
271
272function getExampleData(example: DataExample) {
273  switch (example) {
274    case DataExample.English:
275      return DATA_ENGLISH_LETTER_FREQUENCY;
276    case DataExample.Polish:
277      return DATA_POLISH_LETTER_FREQUENCY;
278    case DataExample.Empty:
279      return DATA_EMPTY;
280    default:
281      const exhaustiveCheck: never = example;
282      throw new Error(`Unhandled case: ${exhaustiveCheck}`);
283  }
284}
285
286const options: {[key: string]: boolean} = {
287  foobar: false,
288  foo: false,
289  bar: false,
290  baz: false,
291  qux: false,
292  quux: false,
293  corge: false,
294  grault: false,
295  garply: false,
296  waldo: false,
297  fred: false,
298  plugh: false,
299  xyzzy: false,
300  thud: false,
301};
302
303function PortalButton() {
304  let portalOpen = false;
305
306  return {
307    // eslint-disable-next-line @typescript-eslint/no-explicit-any
308    view: function ({attrs}: any) {
309      const {zIndex = true, absolute = true, top = true} = attrs;
310      return [
311        m(Button, {
312          label: 'Toggle Portal',
313          intent: Intent.Primary,
314          onclick: () => {
315            portalOpen = !portalOpen;
316          },
317        }),
318        portalOpen &&
319          m(
320            Portal,
321            {
322              style: {
323                position: arg(absolute, 'absolute'),
324                top: arg(top, '0'),
325                zIndex: arg(zIndex, '10', '0'),
326                background: 'white',
327              },
328            },
329            m(
330              '',
331              `A very simple portal - a div rendered outside of the normal
332              flow of the page`,
333            ),
334          ),
335      ];
336    },
337  };
338}
339
340function lorem() {
341  const text = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
342      tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
343      veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
344      commodo consequat.Duis aute irure dolor in reprehenderit in voluptate
345      velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
346      cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
347      est laborum.`;
348  return m('', {style: {width: '200px'}}, text);
349}
350
351function ControlledPopup() {
352  let popupOpen = false;
353
354  return {
355    view: function () {
356      return m(
357        Popup,
358        {
359          trigger: m(Button, {label: `${popupOpen ? 'Close' : 'Open'} Popup`}),
360          isOpen: popupOpen,
361          onChange: (shouldOpen: boolean) => (popupOpen = shouldOpen),
362        },
363        m(Button, {
364          label: 'Close Popup',
365          onclick: () => {
366            popupOpen = !popupOpen;
367          },
368        }),
369      );
370    },
371  };
372}
373
374type Options = {
375  [key: string]: EnumOption | boolean | string | number;
376};
377
378class EnumOption {
379  constructor(
380    public initial: string,
381    public options: string[],
382  ) {}
383}
384
385interface WidgetTitleAttrs {
386  label: string;
387}
388
389function recursiveTreeNode(): m.Children {
390  return m(LazyTreeNode, {
391    left: 'Recursive',
392    right: '...',
393    fetchData: async () => {
394      // await new Promise((r) => setTimeout(r, 1000));
395      return () => recursiveTreeNode();
396    },
397  });
398}
399
400class WidgetTitle implements m.ClassComponent<WidgetTitleAttrs> {
401  view({attrs}: m.CVnode<WidgetTitleAttrs>) {
402    const {label} = attrs;
403    const id = label.replaceAll(' ', '').toLowerCase();
404    const href = `#!/widgets#${id}`;
405    return m(Anchor, {id, href}, m('h2', label));
406  }
407}
408
409interface WidgetShowcaseAttrs {
410  label: string;
411  description?: string;
412  initialOpts?: Options;
413  // eslint-disable-next-line @typescript-eslint/no-explicit-any
414  renderWidget: (options: any) => any;
415  wide?: boolean;
416}
417
418// A little helper class to render any vnode with a dynamic set of options
419class WidgetShowcase implements m.ClassComponent<WidgetShowcaseAttrs> {
420  // eslint-disable-next-line @typescript-eslint/no-explicit-any
421  private optValues: any = {};
422  private opts?: Options;
423
424  renderOptions(listItems: m.Child[]): m.Child {
425    if (listItems.length === 0) {
426      return null;
427    }
428    return m('.widget-controls', m('h3', 'Options'), m('ul', listItems));
429  }
430
431  oninit({attrs: {initialOpts: opts}}: m.Vnode<WidgetShowcaseAttrs, this>) {
432    this.opts = opts;
433    if (opts) {
434      // Make the initial options values
435      for (const key in opts) {
436        if (Object.prototype.hasOwnProperty.call(opts, key)) {
437          const option = opts[key];
438          if (option instanceof EnumOption) {
439            this.optValues[key] = option.initial;
440          } else {
441            this.optValues[key] = option;
442          }
443        }
444      }
445    }
446  }
447
448  view({attrs}: m.CVnode<WidgetShowcaseAttrs>) {
449    const {renderWidget, wide, label, description} = attrs;
450    const listItems = [];
451
452    if (this.opts) {
453      for (const key in this.opts) {
454        if (Object.prototype.hasOwnProperty.call(this.opts, key)) {
455          listItems.push(m('li', this.renderControlForOption(key)));
456        }
457      }
458    }
459
460    return [
461      m(WidgetTitle, {label}),
462      description && m('p', description),
463      m(
464        '.widget-block',
465        m(
466          'div',
467          {
468            class: classNames(
469              'widget-container',
470              wide && 'widget-container-wide',
471            ),
472          },
473          renderWidget(this.optValues),
474        ),
475        this.renderOptions(listItems),
476      ),
477    ];
478  }
479
480  private renderControlForOption(key: string) {
481    if (!this.opts) return null;
482    const value = this.opts[key];
483    if (value instanceof EnumOption) {
484      return this.renderEnumOption(key, value);
485    } else if (typeof value === 'boolean') {
486      return this.renderBooleanOption(key);
487    } else if (isString(value)) {
488      return this.renderStringOption(key);
489    } else if (typeof value === 'number') {
490      return this.renderNumberOption(key);
491    } else {
492      return null;
493    }
494  }
495
496  private renderBooleanOption(key: string) {
497    return m(Checkbox, {
498      checked: this.optValues[key],
499      label: key,
500      onchange: () => {
501        this.optValues[key] = !Boolean(this.optValues[key]);
502      },
503    });
504  }
505
506  private renderStringOption(key: string) {
507    return m(
508      'label',
509      `${key}:`,
510      m(TextInput, {
511        placeholder: key,
512        value: this.optValues[key],
513        oninput: (e: Event) => {
514          this.optValues[key] = (e.target as HTMLInputElement).value;
515        },
516      }),
517    );
518  }
519
520  private renderNumberOption(key: string) {
521    return m(
522      'label',
523      `${key}:`,
524      m(TextInput, {
525        type: 'number',
526        placeholder: key,
527        value: this.optValues[key],
528        oninput: (e: Event) => {
529          this.optValues[key] = Number.parseInt(
530            (e.target as HTMLInputElement).value,
531          );
532        },
533      }),
534    );
535  }
536
537  private renderEnumOption(key: string, opt: EnumOption) {
538    const optionElements = opt.options.map((option: string) => {
539      return m('option', {value: option}, option);
540    });
541    return m(
542      'label',
543      `${key}:`,
544      m(
545        Select,
546        {
547          value: this.optValues[key],
548          onchange: (e: Event) => {
549            const el = e.target as HTMLSelectElement;
550            this.optValues[key] = el.value;
551          },
552        },
553        optionElements,
554      ),
555    );
556  }
557}
558
559interface File {
560  name: string;
561  size: string;
562  date: string;
563  children?: File[];
564}
565
566const files: File[] = [
567  {
568    name: 'foo',
569    size: '10MB',
570    date: '2023-04-02',
571  },
572  {
573    name: 'bar',
574    size: '123KB',
575    date: '2023-04-08',
576    children: [
577      {
578        name: 'baz',
579        size: '4KB',
580        date: '2023-05-07',
581      },
582      {
583        name: 'qux',
584        size: '18KB',
585        date: '2023-05-28',
586        children: [
587          {
588            name: 'quux',
589            size: '4KB',
590            date: '2023-05-07',
591          },
592          {
593            name: 'corge',
594            size: '18KB',
595            date: '2023-05-28',
596            children: [
597              {
598                name: 'grault',
599                size: '4KB',
600                date: '2023-05-07',
601              },
602              {
603                name: 'garply',
604                size: '18KB',
605                date: '2023-05-28',
606              },
607              {
608                name: 'waldo',
609                size: '87KB',
610                date: '2023-05-02',
611              },
612            ],
613          },
614        ],
615      },
616    ],
617  },
618  {
619    name: 'fred',
620    size: '8KB',
621    date: '2022-12-27',
622  },
623];
624
625let virtualTableData: {offset: number; rows: VirtualTableRow[]} = {
626  offset: 0,
627  rows: [],
628};
629
630function TagInputDemo() {
631  const tags: string[] = ['foo', 'bar', 'baz'];
632  let tagInputValue: string = '';
633
634  return {
635    view: () => {
636      return m(TagInput, {
637        tags,
638        value: tagInputValue,
639        onTagAdd: (tag) => {
640          tags.push(tag);
641          tagInputValue = '';
642        },
643        onChange: (value) => {
644          tagInputValue = value;
645        },
646        onTagRemove: (index) => {
647          tags.splice(index, 1);
648        },
649      });
650    },
651  };
652}
653
654function SegmentedButtonsDemo({attrs}: {attrs: {}}) {
655  let selectedIdx = 0;
656  return {
657    view: () => {
658      return m(SegmentedButtons, {
659        ...attrs,
660        options: [{label: 'Yes'}, {label: 'Maybe'}, {label: 'No'}],
661        selectedOption: selectedIdx,
662        onOptionSelected: (num) => {
663          selectedIdx = num;
664        },
665      });
666    },
667  };
668}
669
670export class WidgetsPage implements m.ClassComponent<PageAttrs> {
671  view() {
672    return m(
673      '.widgets-page',
674      m('h1', 'Widgets'),
675      m(WidgetShowcase, {
676        label: 'Button',
677        renderWidget: ({label, icon, rightIcon, ...rest}) =>
678          m(Button, {
679            icon: arg(icon, 'send'),
680            rightIcon: arg(rightIcon, 'arrow_forward'),
681            label: arg(label, 'Button', ''),
682            onclick: () => alert('button pressed'),
683            ...rest,
684          }),
685        initialOpts: {
686          label: true,
687          icon: true,
688          rightIcon: false,
689          disabled: false,
690          intent: new EnumOption(Intent.None, Object.values(Intent)),
691          active: false,
692          compact: false,
693          loading: false,
694        },
695      }),
696      m(WidgetShowcase, {
697        label: 'Segmented Buttons',
698        description: `
699          Segmented buttons are a group of buttons where one of them is
700          'selected'; they act similar to a set of radio buttons.
701        `,
702        renderWidget: (opts) => m(SegmentedButtonsDemo, opts),
703        initialOpts: {
704          disabled: false,
705        },
706      }),
707      m(WidgetShowcase, {
708        label: 'Checkbox',
709        renderWidget: (opts) => m(Checkbox, {label: 'Checkbox', ...opts}),
710        initialOpts: {
711          disabled: false,
712        },
713      }),
714      m(WidgetShowcase, {
715        label: 'Switch',
716        // eslint-disable-next-line @typescript-eslint/no-explicit-any
717        renderWidget: ({label, ...rest}: any) =>
718          m(Switch, {label: arg(label, 'Switch'), ...rest}),
719        initialOpts: {
720          label: true,
721          disabled: false,
722        },
723      }),
724      m(WidgetShowcase, {
725        label: 'Text Input',
726        renderWidget: ({placeholder, ...rest}) =>
727          m(TextInput, {
728            placeholder: arg(placeholder, 'Placeholder...', ''),
729            ...rest,
730          }),
731        initialOpts: {
732          placeholder: true,
733          disabled: false,
734        },
735      }),
736      m(WidgetShowcase, {
737        label: 'Select',
738        renderWidget: (opts) =>
739          m(Select, opts, [
740            m('option', {value: 'foo', label: 'Foo'}),
741            m('option', {value: 'bar', label: 'Bar'}),
742            m('option', {value: 'baz', label: 'Baz'}),
743          ]),
744        initialOpts: {
745          disabled: false,
746        },
747      }),
748      m(WidgetShowcase, {
749        label: 'Empty State',
750        renderWidget: ({header, content}) =>
751          m(
752            EmptyState,
753            {
754              title: arg(header, 'No search results found...'),
755            },
756            arg(content, m(Button, {label: 'Try again'})),
757          ),
758        initialOpts: {
759          header: true,
760          content: true,
761        },
762      }),
763      m(WidgetShowcase, {
764        label: 'Anchor',
765        renderWidget: ({icon}) =>
766          m(
767            Anchor,
768            {
769              icon: arg(icon, 'open_in_new'),
770              href: 'https://perfetto.dev/docs/',
771              target: '_blank',
772            },
773            'This is some really long text and it will probably overflow the container',
774          ),
775        initialOpts: {
776          icon: true,
777        },
778      }),
779      m(WidgetShowcase, {
780        label: 'CopyableLink',
781        renderWidget: ({noicon}) =>
782          m(CopyableLink, {
783            noicon: arg(noicon, true),
784            url: 'https://perfetto.dev/docs/',
785          }),
786        initialOpts: {
787          noicon: false,
788        },
789      }),
790      m(WidgetShowcase, {
791        label: 'Table',
792        renderWidget: () => m(TableShowcase),
793        initialOpts: {},
794        wide: true,
795      }),
796      m(WidgetShowcase, {
797        label: 'Portal',
798        description: `A portal is a div rendered out of normal flow
799          of the hierarchy.`,
800        renderWidget: (opts) => m(PortalButton, opts),
801        initialOpts: {
802          absolute: true,
803          zIndex: true,
804          top: true,
805        },
806      }),
807      m(WidgetShowcase, {
808        label: 'Popup',
809        description: `A popup is a nicely styled portal element whose position is
810        dynamically updated to appear to float alongside a specific element on
811        the page, even as the element is moved and scrolled around.`,
812        renderWidget: (opts) =>
813          m(
814            Popup,
815            {
816              trigger: m(Button, {label: 'Toggle Popup'}),
817              ...opts,
818            },
819            lorem(),
820          ),
821        initialOpts: {
822          position: new EnumOption(
823            PopupPosition.Auto,
824            Object.values(PopupPosition),
825          ),
826          closeOnEscape: true,
827          closeOnOutsideClick: true,
828        },
829      }),
830      m(WidgetShowcase, {
831        label: 'Controlled Popup',
832        description: `The open/close state of a controlled popup is passed in via
833        the 'isOpen' attribute. This means we can get open or close the popup
834        from wherever we like. E.g. from a button inside the popup.
835        Keeping this state external also means we can modify other parts of the
836        page depending on whether the popup is open or not, such as the text
837        on this button.
838        Note, this is the same component as the popup above, but used in
839        controlled mode.`,
840        renderWidget: (opts) => m(ControlledPopup, opts),
841        initialOpts: {},
842      }),
843      m(WidgetShowcase, {
844        label: 'Icon',
845        renderWidget: (opts) => m(Icon, {icon: 'star', ...opts}),
846        initialOpts: {filled: false},
847      }),
848      m(WidgetShowcase, {
849        label: 'MultiSelect panel',
850        renderWidget: ({...rest}) =>
851          m(MultiSelect, {
852            options: Object.entries(options).map(([key, value]) => {
853              return {
854                id: key,
855                name: key,
856                checked: value,
857              };
858            }),
859            onChange: (diffs: MultiSelectDiff[]) => {
860              diffs.forEach(({id, checked}) => {
861                options[id] = checked;
862              });
863            },
864            ...rest,
865          }),
866        initialOpts: {
867          repeatCheckedItemsAtTop: false,
868          fixedSize: false,
869        },
870      }),
871      m(WidgetShowcase, {
872        label: 'Popup with MultiSelect',
873        renderWidget: ({icon, ...rest}) =>
874          m(PopupMultiSelect, {
875            options: Object.entries(options).map(([key, value]) => {
876              return {
877                id: key,
878                name: key,
879                checked: value,
880              };
881            }),
882            popupPosition: PopupPosition.Top,
883            label: 'Multi Select',
884            icon: arg(icon, Icons.LibraryAddCheck),
885            onChange: (diffs: MultiSelectDiff[]) => {
886              diffs.forEach(({id, checked}) => {
887                options[id] = checked;
888              });
889            },
890            ...rest,
891          }),
892        initialOpts: {
893          icon: true,
894          showNumSelected: true,
895          repeatCheckedItemsAtTop: false,
896        },
897      }),
898      m(WidgetShowcase, {
899        label: 'Menu',
900        renderWidget: () =>
901          m(
902            Menu,
903            m(MenuItem, {label: 'New', icon: 'add'}),
904            m(MenuItem, {label: 'Open', icon: 'folder_open'}),
905            m(MenuItem, {label: 'Save', icon: 'save', disabled: true}),
906            m(MenuDivider),
907            m(MenuItem, {label: 'Delete', icon: 'delete'}),
908            m(MenuDivider),
909            m(
910              MenuItem,
911              {label: 'Share', icon: 'share'},
912              m(MenuItem, {label: 'Everyone', icon: 'public'}),
913              m(MenuItem, {label: 'Friends', icon: 'group'}),
914              m(
915                MenuItem,
916                {label: 'Specific people', icon: 'person_add'},
917                m(MenuItem, {label: 'Alice', icon: 'person'}),
918                m(MenuItem, {label: 'Bob', icon: 'person'}),
919              ),
920            ),
921            m(
922              MenuItem,
923              {label: 'More', icon: 'more_horiz'},
924              m(MenuItem, {label: 'Query', icon: 'database'}),
925              m(MenuItem, {label: 'Download', icon: 'download'}),
926              m(MenuItem, {label: 'Clone', icon: 'copy_all'}),
927            ),
928          ),
929      }),
930      m(WidgetShowcase, {
931        label: 'PopupMenu',
932        renderWidget: (opts) =>
933          m(
934            PopupMenu,
935            {
936              trigger: m(Button, {
937                label: 'Menu',
938                rightIcon: Icons.ContextMenu,
939              }),
940              ...opts,
941            },
942            m(MenuItem, {label: 'New', icon: 'add'}),
943            m(MenuItem, {label: 'Open', icon: 'folder_open'}),
944            m(MenuItem, {label: 'Save', icon: 'save', disabled: true}),
945            m(MenuDivider),
946            m(MenuItem, {label: 'Delete', icon: 'delete'}),
947            m(MenuDivider),
948            m(
949              MenuItem,
950              {label: 'Share', icon: 'share'},
951              m(MenuItem, {label: 'Everyone', icon: 'public'}),
952              m(MenuItem, {label: 'Friends', icon: 'group'}),
953              m(
954                MenuItem,
955                {label: 'Specific people', icon: 'person_add'},
956                m(MenuItem, {label: 'Alice', icon: 'person'}),
957                m(MenuItem, {label: 'Bob', icon: 'person'}),
958              ),
959            ),
960            m(
961              MenuItem,
962              {label: 'More', icon: 'more_horiz'},
963              m(MenuItem, {label: 'Query', icon: 'database'}),
964              m(MenuItem, {label: 'Download', icon: 'download'}),
965              m(MenuItem, {label: 'Clone', icon: 'copy_all'}),
966            ),
967          ),
968        initialOpts: {
969          popupPosition: new EnumOption(
970            PopupPosition.Bottom,
971            Object.values(PopupPosition),
972          ),
973        },
974      }),
975      m(WidgetShowcase, {
976        label: 'Spinner',
977        description: `Simple spinner, rotates forever.
978            Width and height match the font size.`,
979        renderWidget: ({fontSize, easing}) =>
980          m('', {style: {fontSize}}, m(Spinner, {easing})),
981        initialOpts: {
982          fontSize: new EnumOption('16px', [
983            '12px',
984            '16px',
985            '24px',
986            '32px',
987            '64px',
988            '128px',
989          ]),
990          easing: false,
991        },
992      }),
993      m(WidgetShowcase, {
994        label: 'Tree',
995        description: `Hierarchical tree with left and right values aligned to
996        a grid.`,
997        renderWidget: (opts) =>
998          m(
999            Tree,
1000            opts,
1001            m(TreeNode, {left: 'Name', right: 'my_event', icon: 'badge'}),
1002            m(TreeNode, {left: 'CPU', right: '2', icon: 'memory'}),
1003            m(TreeNode, {
1004              left: 'Start time',
1005              right: '1s 435ms',
1006              icon: 'schedule',
1007            }),
1008            m(TreeNode, {left: 'Duration', right: '86ms', icon: 'timer'}),
1009            m(TreeNode, {
1010              left: 'SQL',
1011              right: m(
1012                PopupMenu,
1013                {
1014                  popupPosition: PopupPosition.RightStart,
1015                  trigger: m(
1016                    Anchor,
1017                    {
1018                      icon: Icons.ContextMenu,
1019                    },
1020                    'SELECT * FROM ftrace_event WHERE id = 123',
1021                  ),
1022                },
1023                m(MenuItem, {
1024                  label: 'Copy SQL Query',
1025                  icon: 'content_copy',
1026                }),
1027                m(MenuItem, {
1028                  label: 'Execute Query in new tab',
1029                  icon: 'open_in_new',
1030                }),
1031              ),
1032            }),
1033            m(TreeNode, {
1034              icon: 'account_tree',
1035              left: 'Process',
1036              right: m(Anchor, {icon: 'open_in_new'}, '/bin/foo[789]'),
1037            }),
1038            m(TreeNode, {
1039              left: 'Thread',
1040              right: m(Anchor, {icon: 'open_in_new'}, 'my_thread[456]'),
1041            }),
1042            m(
1043              TreeNode,
1044              {
1045                left: 'Args',
1046                summary: 'foo: string, baz: string, quux: string[4]',
1047              },
1048              m(TreeNode, {left: 'foo', right: 'bar'}),
1049              m(TreeNode, {left: 'baz', right: 'qux'}),
1050              m(
1051                TreeNode,
1052                {left: 'quux', summary: 'string[4]'},
1053                m(TreeNode, {left: '[0]', right: 'corge'}),
1054                m(TreeNode, {left: '[1]', right: 'grault'}),
1055                m(TreeNode, {left: '[2]', right: 'garply'}),
1056                m(TreeNode, {left: '[3]', right: 'waldo'}),
1057              ),
1058            ),
1059            m(LazyTreeNode, {
1060              left: 'Lazy',
1061              icon: 'bedtime',
1062              fetchData: async () => {
1063                await new Promise((r) => setTimeout(r, 1000));
1064                return () => m(TreeNode, {left: 'foo'});
1065              },
1066            }),
1067            m(LazyTreeNode, {
1068              left: 'Dynamic',
1069              unloadOnCollapse: true,
1070              icon: 'bedtime',
1071              fetchData: async () => {
1072                await new Promise((r) => setTimeout(r, 1000));
1073                return () => m(TreeNode, {left: 'foo'});
1074              },
1075            }),
1076            recursiveTreeNode(),
1077          ),
1078        wide: true,
1079      }),
1080      m(WidgetShowcase, {
1081        label: 'Form',
1082        renderWidget: () => renderForm('form'),
1083      }),
1084      m(WidgetShowcase, {
1085        label: 'Nested Popups',
1086        renderWidget: () =>
1087          m(
1088            Popup,
1089            {
1090              trigger: m(Button, {label: 'Open the popup'}),
1091            },
1092            m(
1093              PopupMenu,
1094              {
1095                trigger: m(Button, {label: 'Select an option'}),
1096              },
1097              m(MenuItem, {label: 'Option 1'}),
1098              m(MenuItem, {label: 'Option 2'}),
1099            ),
1100            m(Button, {
1101              label: 'Done',
1102              dismissPopup: true,
1103            }),
1104          ),
1105      }),
1106      m(WidgetShowcase, {
1107        label: 'Callout',
1108        renderWidget: () =>
1109          m(
1110            Callout,
1111            {
1112              icon: 'info',
1113            },
1114            'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
1115              'Nulla rhoncus tempor neque, sed malesuada eros dapibus vel. ' +
1116              'Aliquam in ligula vitae tortor porttitor laoreet iaculis ' +
1117              'finibus est.',
1118          ),
1119      }),
1120      m(WidgetShowcase, {
1121        label: 'Editor',
1122        renderWidget: () =>
1123          m(Editor, {
1124            language: 'perfetto-sql',
1125            onUpdate: (text) => {
1126              parseAndPrintTree(text);
1127            },
1128          }),
1129      }),
1130      m(WidgetShowcase, {
1131        label: 'VegaView',
1132        renderWidget: (opt) =>
1133          m(VegaView, {
1134            spec: getExampleSpec(opt.exampleSpec),
1135            data: getExampleData(opt.exampleData),
1136          }),
1137        initialOpts: {
1138          exampleSpec: new EnumOption(
1139            SpecExample.BarChart,
1140            Object.values(SpecExample),
1141          ),
1142          exampleData: new EnumOption(
1143            DataExample.English,
1144            Object.values(DataExample),
1145          ),
1146        },
1147      }),
1148      m(WidgetShowcase, {
1149        label: 'Form within PopupMenu',
1150        description: `A form placed inside a popup menu works just fine,
1151              and the cancel/submit buttons also dismiss the popup. A bit more
1152              margin is added around it too, which improves the look and feel.`,
1153        renderWidget: () =>
1154          m(
1155            PopupMenu,
1156            {
1157              trigger: m(Button, {label: 'Popup!'}),
1158            },
1159            m(
1160              MenuItem,
1161              {
1162                label: 'Open form...',
1163              },
1164              renderForm('popup-form'),
1165            ),
1166          ),
1167      }),
1168      m(WidgetShowcase, {
1169        label: 'Hotkey',
1170        renderWidget: (opts) => {
1171          if (opts.platform === 'auto') {
1172            return m(HotkeyGlyphs, {hotkey: opts.hotkey as Hotkey});
1173          } else {
1174            const platform = opts.platform as Platform;
1175            return m(HotkeyGlyphs, {
1176              hotkey: opts.hotkey as Hotkey,
1177              spoof: platform,
1178            });
1179          }
1180        },
1181        initialOpts: {
1182          hotkey: 'Mod+Shift+P',
1183          platform: new EnumOption('auto', ['auto', 'Mac', 'PC']),
1184        },
1185      }),
1186      m(WidgetShowcase, {
1187        label: 'Text Paragraph',
1188        description: `A basic formatted text paragraph with wrapping. If
1189              it is desirable to preserve the original text format/line breaks,
1190              set the compressSpace attribute to false.`,
1191        renderWidget: (opts) => {
1192          return m(TextParagraph, {
1193            text: `Lorem ipsum dolor sit amet, consectetur adipiscing
1194                         elit. Nulla rhoncus tempor neque, sed malesuada eros
1195                         dapibus vel. Aliquam in ligula vitae tortor porttitor
1196                         laoreet iaculis finibus est.`,
1197            compressSpace: opts.compressSpace,
1198          });
1199        },
1200        initialOpts: {
1201          compressSpace: true,
1202        },
1203      }),
1204      m(WidgetShowcase, {
1205        label: 'Multi Paragraph Text',
1206        description: `A wrapper for multiple paragraph widgets.`,
1207        renderWidget: () => {
1208          return m(
1209            MultiParagraphText,
1210            m(TextParagraph, {
1211              text: `Lorem ipsum dolor sit amet, consectetur adipiscing
1212                         elit. Nulla rhoncus tempor neque, sed malesuada eros
1213                         dapibus vel. Aliquam in ligula vitae tortor porttitor
1214                         laoreet iaculis finibus est.`,
1215              compressSpace: true,
1216            }),
1217            m(TextParagraph, {
1218              text: `Sed ut perspiciatis unde omnis iste natus error sit
1219                         voluptatem accusantium doloremque laudantium, totam rem
1220                         aperiam, eaque ipsa quae ab illo inventore veritatis et
1221                         quasi architecto beatae vitae dicta sunt explicabo.
1222                         Nemo enim ipsam voluptatem quia voluptas sit aspernatur
1223                         aut odit aut fugit, sed quia consequuntur magni dolores
1224                         eos qui ratione voluptatem sequi nesciunt.`,
1225              compressSpace: true,
1226            }),
1227          );
1228        },
1229      }),
1230      m(WidgetShowcase, {
1231        label: 'Modal',
1232        description: `A helper for modal dialog.`,
1233        renderWidget: () => m(ModalShowcase),
1234      }),
1235      m(WidgetShowcase, {
1236        label: 'TreeTable',
1237        description: `Hierarchical tree with multiple columns`,
1238        renderWidget: () => {
1239          const attrs: TreeTableAttrs<File> = {
1240            rows: files,
1241            getChildren: (file) => file.children,
1242            columns: [
1243              {name: 'Name', getData: (file) => file.name},
1244              {name: 'Size', getData: (file) => file.size},
1245              {name: 'Date', getData: (file) => file.date},
1246            ],
1247          };
1248          return m(TreeTable<File>, attrs);
1249        },
1250      }),
1251      m(WidgetShowcase, {
1252        label: 'VirtualTable',
1253        description: `Virtualized table for efficient rendering of large datasets`,
1254        renderWidget: () => {
1255          const attrs: VirtualTableAttrs = {
1256            columns: [
1257              {header: 'x', width: '4em'},
1258              {header: 'x^2', width: '8em'},
1259            ],
1260            rows: virtualTableData.rows,
1261            firstRowOffset: virtualTableData.offset,
1262            rowHeight: 20,
1263            numRows: 500_000,
1264            style: {height: '200px'},
1265            onReload: (rowOffset, rowCount) => {
1266              const rows = [];
1267              for (let i = rowOffset; i < rowOffset + rowCount; i++) {
1268                rows.push({id: i, cells: [i, i ** 2]});
1269              }
1270              virtualTableData = {
1271                offset: rowOffset,
1272                rows,
1273              };
1274            },
1275          };
1276          return m(VirtualTable, attrs);
1277        },
1278      }),
1279      m(WidgetShowcase, {
1280        label: 'Tag Input',
1281        description: `
1282          TagInput displays Tag elements inside an input, followed by an
1283          interactive text input. The container is styled to look like a
1284          TextInput, but the actual editable element appears after the last tag.
1285          Clicking anywhere on the container will focus the text input.`,
1286        renderWidget: () => m(TagInputDemo),
1287      }),
1288      m(WidgetShowcase, {
1289        label: 'Middle Ellipsis',
1290        description: `
1291          Sometimes the start and end of a bit of text are more important than
1292          the middle. This element puts the ellipsis in the midde if the content
1293          is too wide for its container.`,
1294        renderWidget: (opts) =>
1295          m(
1296            'div',
1297            {style: {width: Boolean(opts.squeeze) ? '150px' : '450px'}},
1298            m(MiddleEllipsis, {
1299              text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
1300            }),
1301          ),
1302        initialOpts: {
1303          squeeze: false,
1304        },
1305      }),
1306      m(WidgetShowcase, {
1307        label: 'Chip',
1308        description: `A little chip or tag`,
1309        renderWidget: (opts) => {
1310          const {icon, ...rest} = opts;
1311          return m(
1312            ChipBar,
1313            m(Chip, {
1314              label: 'Foo',
1315              icon: icon === true ? 'info' : undefined,
1316              ...rest,
1317            }),
1318            m(Chip, {label: 'Bar', ...rest}),
1319            m(Chip, {label: 'Baz', ...rest}),
1320          );
1321        },
1322        initialOpts: {
1323          intent: new EnumOption(Intent.None, Object.values(Intent)),
1324          icon: true,
1325          compact: false,
1326          rounded: false,
1327        },
1328      }),
1329      m(WidgetShowcase, {
1330        label: 'TrackShell',
1331        description: `The Mithril parts of a track (the shell, mainly).`,
1332        renderWidget: (opts) => {
1333          const {buttons, chips, multipleTracks, error, ...rest} = opts;
1334          const dummyButtons = () => [
1335            m(Button, {icon: 'info', compact: true}),
1336            m(Button, {icon: 'settings', compact: true}),
1337          ];
1338          const dummyChips = () => ['foo', 'bar'];
1339
1340          const renderTrack = (children?: m.Children) =>
1341            m(
1342              TrackShell,
1343              {
1344                buttons: Boolean(buttons) ? dummyButtons() : undefined,
1345                chips: Boolean(chips) ? dummyChips() : undefined,
1346                error: Boolean(error)
1347                  ? new Error('An error has occurred')
1348                  : undefined,
1349                ...rest,
1350              },
1351              children,
1352            );
1353
1354          return m(
1355            '',
1356            {
1357              style: {width: '500px', boxShadow: '0px 0px 1px 1px lightgray'},
1358            },
1359            Boolean(multipleTracks)
1360              ? [renderTrack(), renderTrack(), renderTrack()]
1361              : renderTrack(),
1362          );
1363        },
1364        initialOpts: {
1365          title: 'This is the title of the track',
1366          subtitle: 'This is the subtitle of the track',
1367          buttons: true,
1368          chips: true,
1369          heightPx: 32,
1370          collapsible: true,
1371          collapsed: true,
1372          summary: false,
1373          highlight: false,
1374          error: false,
1375          multipleTracks: false,
1376          reorderable: false,
1377          depth: 0,
1378          lite: false,
1379        },
1380      }),
1381      m(WidgetShowcase, {
1382        label: 'Virtual Overlay Canvas',
1383        description: `A scrolling container that draws a virtual canvas over
1384          the top of it's content and keeps it in the viewport to make it appear
1385          like there is one big canvas over the top of the content.`,
1386        renderWidget: () => {
1387          const width = 200;
1388          const rowCount = 65536;
1389          const rowHeight = 20;
1390          return m(
1391            VirtualOverlayCanvas,
1392            {
1393              className: 'virtual-canvas',
1394              overflowY: 'auto',
1395              onCanvasRedraw({ctx, canvasRect}) {
1396                ctx.strokeStyle = 'red';
1397                ctx.lineWidth = 1;
1398
1399                ctx.font = '20px Arial';
1400                ctx.fillStyle = 'black';
1401                ctx.textAlign = 'left';
1402                ctx.textBaseline = 'top';
1403
1404                for (let i = 0; i < rowCount; i++) {
1405                  const rect = {
1406                    left: 0,
1407                    top: i * rowHeight,
1408                    right: width,
1409                    bottom: i * rowHeight + rowHeight,
1410                  };
1411                  if (canvasRect.overlaps(rect)) {
1412                    ctx.strokeRect(0, i * rowHeight, width, rowHeight);
1413                    ctx.fillText(`Row: ${i}`, 0, i * rowHeight);
1414                  }
1415                }
1416              },
1417            },
1418            m('', {
1419              style: {height: `${rowCount * rowHeight}px`, width: `${width}px`},
1420            }),
1421          );
1422        },
1423        initialOpts: {},
1424      }),
1425
1426      m(WidgetShowcase, {
1427        label: 'SplitPanel',
1428        description: `Horizontal split panel with draggable handle and controls.`,
1429        renderWidget: (opts) => {
1430          return m(
1431            '',
1432            {style: {height: '400px', width: '400px', border: 'solid 2px red'}},
1433            m(
1434              SplitPanel,
1435              {
1436                drawerContent: 'Drawer Content',
1437                handleContent: Boolean(opts.handleContent) && 'Handle Content',
1438              },
1439              'Main Content',
1440            ),
1441          );
1442        },
1443        initialOpts: {
1444          handleContent: false,
1445        },
1446      }),
1447
1448      m(WidgetShowcase, {
1449        label: 'TabbedSplitPanel',
1450        description: `SplitPanel + tabs.`,
1451        renderWidget: (opts) => {
1452          return m(
1453            '',
1454            {style: {height: '400px', width: '400px', border: 'solid 2px red'}},
1455            m(
1456              TabbedSplitPanel,
1457              {
1458                leftHandleContent:
1459                  Boolean(opts.leftContent) &&
1460                  m(Button, {icon: 'Menu', compact: true}),
1461                tabs: [
1462                  {
1463                    key: 'foo',
1464                    title: 'Foo',
1465                    content: 'Foo content',
1466                    hasCloseButton: opts.showCloseButtons,
1467                  },
1468                  {
1469                    key: 'bar',
1470                    title: 'Bar',
1471                    content: 'Bar content',
1472                    hasCloseButton: opts.showCloseButtons,
1473                  },
1474                ],
1475              },
1476              'Main Content',
1477            ),
1478          );
1479        },
1480        initialOpts: {
1481          leftContent: true,
1482          showCloseButtons: true,
1483        },
1484      }),
1485    );
1486  }
1487}
1488
1489class ModalShowcase implements m.ClassComponent {
1490  private static counter = 0;
1491
1492  private static log(txt: string) {
1493    const mwlogs = document.getElementById('mwlogs');
1494    if (!mwlogs || !(mwlogs instanceof HTMLTextAreaElement)) return;
1495    const time = new Date().toLocaleTimeString();
1496    mwlogs.value += `[${time}] ${txt}\n`;
1497    mwlogs.scrollTop = mwlogs.scrollHeight;
1498  }
1499
1500  private static showModalDialog(staticContent = false) {
1501    const id = `N=${++ModalShowcase.counter}`;
1502    ModalShowcase.log(`Open ${id}`);
1503    const logOnClose = () => ModalShowcase.log(`Close ${id}`);
1504
1505    let content;
1506    if (staticContent) {
1507      content = m('.modal-pre', 'Content of the modal dialog.\nEnd of content');
1508    } else {
1509      // The humble counter is basically the VDOM 'Hello world'!
1510      function CounterComponent() {
1511        let counter = 0;
1512        return {
1513          view: () => {
1514            return m(
1515              '',
1516              `Counter value: ${counter}`,
1517              m(Button, {
1518                intent: Intent.Primary,
1519                label: 'Increment Counter',
1520                onclick: () => ++counter,
1521              }),
1522            );
1523          },
1524        };
1525      }
1526      content = () => m(CounterComponent);
1527    }
1528    const closePromise = showModal({
1529      title: `Modal dialog ${id}`,
1530      buttons: [
1531        {text: 'OK', action: () => ModalShowcase.log(`OK ${id}`)},
1532        {text: 'Cancel', action: () => ModalShowcase.log(`Cancel ${id}`)},
1533        {
1534          text: 'Show another now',
1535          action: () => ModalShowcase.showModalDialog(),
1536        },
1537        {
1538          text: 'Show another in 2s',
1539          action: () => setTimeout(() => ModalShowcase.showModalDialog(), 2000),
1540        },
1541      ],
1542      content,
1543    });
1544    closePromise.then(logOnClose);
1545  }
1546
1547  view() {
1548    return m(
1549      'div',
1550      {
1551        style: {
1552          'display': 'flex',
1553          'flex-direction': 'column',
1554          'width': '100%',
1555        },
1556      },
1557      m('textarea', {
1558        id: 'mwlogs',
1559        readonly: 'readonly',
1560        rows: '8',
1561        placeholder: 'Logs will appear here',
1562      }),
1563      m('input[type=button]', {
1564        value: 'Show modal (static)',
1565        onclick: () => ModalShowcase.showModalDialog(true),
1566      }),
1567      m('input[type=button]', {
1568        value: 'Show modal (dynamic)',
1569        onclick: () => ModalShowcase.showModalDialog(false),
1570      }),
1571    );
1572  }
1573} // class ModalShowcase
1574
1575function renderForm(id: string) {
1576  return m(
1577    Form,
1578    {
1579      submitLabel: 'Submit',
1580      submitIcon: 'send',
1581      cancelLabel: 'Cancel',
1582      resetLabel: 'Reset',
1583      onSubmit: () => window.alert('Form submitted!'),
1584    },
1585    m(FormLabel, {for: `${id}-foo`}, 'Foo'),
1586    m(TextInput, {id: `${id}-foo`}),
1587    m(FormLabel, {for: `${id}-bar`}, 'Bar'),
1588    m(Select, {id: `${id}-bar`}, [
1589      m('option', {value: 'foo', label: 'Foo'}),
1590      m('option', {value: 'bar', label: 'Bar'}),
1591      m('option', {value: 'baz', label: 'Baz'}),
1592    ]),
1593  );
1594}
1595