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