blob: a5ff6d71e9c795533bab17e00784e981442f7522 [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import m from 'mithril';
import {classNames} from '../../base/classnames';
import {Hotkey, Platform} from '../../base/hotkeys';
import {isString} from '../../base/object_utils';
import {Icons} from '../../base/semantic_icons';
import {Anchor} from '../../widgets/anchor';
import {Button} from '../../widgets/button';
import {Callout} from '../../widgets/callout';
import {Checkbox} from '../../widgets/checkbox';
import {Editor} from '../../widgets/editor';
import {EmptyState} from '../../widgets/empty_state';
import {Form, FormLabel} from '../../widgets/form';
import {HotkeyGlyphs} from '../../widgets/hotkey_glyphs';
import {Icon} from '../../widgets/icon';
import {Menu, MenuDivider, MenuItem, PopupMenu2} from '../../widgets/menu';
import {showModal} from '../../widgets/modal';
import {
MultiSelect,
MultiSelectDiff,
PopupMultiSelect,
} from '../../widgets/multiselect';
import {Popup, PopupPosition} from '../../widgets/popup';
import {Portal} from '../../widgets/portal';
import {Select} from '../../widgets/select';
import {Spinner} from '../../widgets/spinner';
import {Switch} from '../../widgets/switch';
import {TextInput} from '../../widgets/text_input';
import {MultiParagraphText, TextParagraph} from '../../widgets/text_paragraph';
import {LazyTreeNode, Tree, TreeNode} from '../../widgets/tree';
import {VegaView} from '../../widgets/vega_view';
import {PageAttrs} from '../../public/page';
import {TableShowcase} from './table_showcase';
import {TreeTable, TreeTableAttrs} from '../../frontend/widgets/treetable';
import {Intent} from '../../widgets/common';
import {
VirtualTable,
VirtualTableAttrs,
VirtualTableRow,
} from '../../widgets/virtual_table';
import {TagInput} from '../../widgets/tag_input';
import {SegmentedButtons} from '../../widgets/segmented_buttons';
import {MiddleEllipsis} from '../../widgets/middle_ellipsis';
import {Chip, ChipBar} from '../../widgets/chip';
import {TrackWidget} from '../../widgets/track_widget';
import {scheduleFullRedraw} from '../../widgets/raf';
import {CopyableLink} from '../../widgets/copyable_link';
const DATA_ENGLISH_LETTER_FREQUENCY = {
table: [
{category: 'a', amount: 8.167},
{category: 'b', amount: 1.492},
{category: 'c', amount: 2.782},
{category: 'd', amount: 4.253},
{category: 'e', amount: 12.7},
{category: 'f', amount: 2.228},
{category: 'g', amount: 2.015},
{category: 'h', amount: 6.094},
{category: 'i', amount: 6.966},
{category: 'j', amount: 0.253},
{category: 'k', amount: 1.772},
{category: 'l', amount: 4.025},
{category: 'm', amount: 2.406},
{category: 'n', amount: 6.749},
{category: 'o', amount: 7.507},
{category: 'p', amount: 1.929},
{category: 'q', amount: 0.095},
{category: 'r', amount: 5.987},
{category: 's', amount: 6.327},
{category: 't', amount: 9.056},
{category: 'u', amount: 2.758},
{category: 'v', amount: 0.978},
{category: 'w', amount: 2.36},
{category: 'x', amount: 0.25},
{category: 'y', amount: 1.974},
{category: 'z', amount: 0.074},
],
};
const DATA_POLISH_LETTER_FREQUENCY = {
table: [
{category: 'a', amount: 8.965},
{category: 'b', amount: 1.482},
{category: 'c', amount: 3.988},
{category: 'd', amount: 3.293},
{category: 'e', amount: 7.921},
{category: 'f', amount: 0.312},
{category: 'g', amount: 1.377},
{category: 'h', amount: 1.072},
{category: 'i', amount: 8.286},
{category: 'j', amount: 2.343},
{category: 'k', amount: 3.411},
{category: 'l', amount: 2.136},
{category: 'm', amount: 2.911},
{category: 'n', amount: 5.6},
{category: 'o', amount: 7.59},
{category: 'p', amount: 3.101},
{category: 'q', amount: 0.003},
{category: 'r', amount: 4.571},
{category: 's', amount: 4.263},
{category: 't', amount: 3.966},
{category: 'u', amount: 2.347},
{category: 'v', amount: 0.034},
{category: 'w', amount: 4.549},
{category: 'x', amount: 0.019},
{category: 'y', amount: 3.857},
{category: 'z', amount: 5.62},
],
};
const DATA_EMPTY = {};
const SPEC_BAR_CHART = `
{
"$schema": "https://vega.github.io/schema/vega/v5.json",
"description": "A basic bar chart example, with value labels shown upon mouse hover.",
"width": 400,
"height": 200,
"padding": 5,
"data": [
{
"name": "table"
}
],
"signals": [
{
"name": "tooltip",
"value": {},
"on": [
{"events": "rect:mouseover", "update": "datum"},
{"events": "rect:mouseout", "update": "{}"}
]
}
],
"scales": [
{
"name": "xscale",
"type": "band",
"domain": {"data": "table", "field": "category"},
"range": "width",
"padding": 0.05,
"round": true
},
{
"name": "yscale",
"domain": {"data": "table", "field": "amount"},
"nice": true,
"range": "height"
}
],
"axes": [
{ "orient": "bottom", "scale": "xscale" },
{ "orient": "left", "scale": "yscale" }
],
"marks": [
{
"type": "rect",
"from": {"data":"table"},
"encode": {
"enter": {
"x": {"scale": "xscale", "field": "category"},
"width": {"scale": "xscale", "band": 1},
"y": {"scale": "yscale", "field": "amount"},
"y2": {"scale": "yscale", "value": 0}
},
"update": {
"fill": {"value": "steelblue"}
},
"hover": {
"fill": {"value": "red"}
}
}
},
{
"type": "text",
"encode": {
"enter": {
"align": {"value": "center"},
"baseline": {"value": "bottom"},
"fill": {"value": "#333"}
},
"update": {
"x": {"scale": "xscale", "signal": "tooltip.category", "band": 0.5},
"y": {"scale": "yscale", "signal": "tooltip.amount", "offset": -2},
"text": {"signal": "tooltip.amount"},
"fillOpacity": [
{"test": "datum === tooltip", "value": 0},
{"value": 1}
]
}
}
}
]
}
`;
const SPEC_BAR_CHART_LITE = `
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"description": "A simple bar chart with embedded data.",
"data": {
"name": "table"
},
"mark": "bar",
"encoding": {
"x": {"field": "category", "type": "nominal", "axis": {"labelAngle": 0}},
"y": {"field": "amount", "type": "quantitative"}
}
}
`;
const SPEC_BROKEN = `{
"description": 123
}
`;
enum SpecExample {
BarChart = 'Barchart',
BarChartLite = 'Barchart (Lite)',
Broken = 'Broken',
}
enum DataExample {
English = 'English',
Polish = 'Polish',
Empty = 'Empty',
}
function arg<T>(
anyArg: unknown,
valueIfTrue: T,
valueIfFalse: T | undefined = undefined,
): T | undefined {
return Boolean(anyArg) ? valueIfTrue : valueIfFalse;
}
function getExampleSpec(example: SpecExample): string {
switch (example) {
case SpecExample.BarChart:
return SPEC_BAR_CHART;
case SpecExample.BarChartLite:
return SPEC_BAR_CHART_LITE;
case SpecExample.Broken:
return SPEC_BROKEN;
default:
const exhaustiveCheck: never = example;
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
}
}
function getExampleData(example: DataExample) {
switch (example) {
case DataExample.English:
return DATA_ENGLISH_LETTER_FREQUENCY;
case DataExample.Polish:
return DATA_POLISH_LETTER_FREQUENCY;
case DataExample.Empty:
return DATA_EMPTY;
default:
const exhaustiveCheck: never = example;
throw new Error(`Unhandled case: ${exhaustiveCheck}`);
}
}
const options: {[key: string]: boolean} = {
foobar: false,
foo: false,
bar: false,
baz: false,
qux: false,
quux: false,
corge: false,
grault: false,
garply: false,
waldo: false,
fred: false,
plugh: false,
xyzzy: false,
thud: false,
};
function PortalButton() {
let portalOpen = false;
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
view: function ({attrs}: any) {
const {zIndex = true, absolute = true, top = true} = attrs;
return [
m(Button, {
label: 'Toggle Portal',
intent: Intent.Primary,
onclick: () => {
portalOpen = !portalOpen;
scheduleFullRedraw();
},
}),
portalOpen &&
m(
Portal,
{
style: {
position: arg(absolute, 'absolute'),
top: arg(top, '0'),
zIndex: arg(zIndex, '10', '0'),
background: 'white',
},
},
m(
'',
`A very simple portal - a div rendered outside of the normal
flow of the page`,
),
),
];
},
};
}
function lorem() {
const text = `Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim
veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
commodo consequat.Duis aute irure dolor in reprehenderit in voluptate
velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat
cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id
est laborum.`;
return m('', {style: {width: '200px'}}, text);
}
function ControlledPopup() {
let popupOpen = false;
return {
view: function () {
return m(
Popup,
{
trigger: m(Button, {label: `${popupOpen ? 'Close' : 'Open'} Popup`}),
isOpen: popupOpen,
onChange: (shouldOpen: boolean) => (popupOpen = shouldOpen),
},
m(Button, {
label: 'Close Popup',
onclick: () => {
popupOpen = !popupOpen;
scheduleFullRedraw();
},
}),
);
},
};
}
type Options = {
[key: string]: EnumOption | boolean | string | number;
};
class EnumOption {
constructor(
public initial: string,
public options: string[],
) {}
}
interface WidgetTitleAttrs {
label: string;
}
function recursiveTreeNode(): m.Children {
return m(LazyTreeNode, {
left: 'Recursive',
right: '...',
fetchData: async () => {
// await new Promise((r) => setTimeout(r, 1000));
return () => recursiveTreeNode();
},
});
}
class WidgetTitle implements m.ClassComponent<WidgetTitleAttrs> {
view({attrs}: m.CVnode<WidgetTitleAttrs>) {
const {label} = attrs;
const id = label.replaceAll(' ', '').toLowerCase();
const href = `#!/widgets#${id}`;
return m(Anchor, {id, href}, m('h2', label));
}
}
interface WidgetShowcaseAttrs {
label: string;
description?: string;
initialOpts?: Options;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderWidget: (options: any) => any;
wide?: boolean;
}
// A little helper class to render any vnode with a dynamic set of options
class WidgetShowcase implements m.ClassComponent<WidgetShowcaseAttrs> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private optValues: any = {};
private opts?: Options;
renderOptions(listItems: m.Child[]): m.Child {
if (listItems.length === 0) {
return null;
}
return m('.widget-controls', m('h3', 'Options'), m('ul', listItems));
}
oninit({attrs: {initialOpts: opts}}: m.Vnode<WidgetShowcaseAttrs, this>) {
this.opts = opts;
if (opts) {
// Make the initial options values
for (const key in opts) {
if (Object.prototype.hasOwnProperty.call(opts, key)) {
const option = opts[key];
if (option instanceof EnumOption) {
this.optValues[key] = option.initial;
} else {
this.optValues[key] = option;
}
}
}
}
}
view({attrs}: m.CVnode<WidgetShowcaseAttrs>) {
const {renderWidget, wide, label, description} = attrs;
const listItems = [];
if (this.opts) {
for (const key in this.opts) {
if (Object.prototype.hasOwnProperty.call(this.opts, key)) {
listItems.push(m('li', this.renderControlForOption(key)));
}
}
}
return [
m(WidgetTitle, {label}),
description && m('p', description),
m(
'.widget-block',
m(
'div',
{
class: classNames(
'widget-container',
wide && 'widget-container-wide',
),
},
renderWidget(this.optValues),
),
this.renderOptions(listItems),
),
];
}
private renderControlForOption(key: string) {
if (!this.opts) return null;
const value = this.opts[key];
if (value instanceof EnumOption) {
return this.renderEnumOption(key, value);
} else if (typeof value === 'boolean') {
return this.renderBooleanOption(key);
} else if (isString(value)) {
return this.renderStringOption(key);
} else if (typeof value === 'number') {
return this.renderNumberOption(key);
} else {
return null;
}
}
private renderBooleanOption(key: string) {
return m(Checkbox, {
checked: this.optValues[key],
label: key,
onchange: () => {
this.optValues[key] = !Boolean(this.optValues[key]);
scheduleFullRedraw();
},
});
}
private renderStringOption(key: string) {
return m(
'label',
`${key}:`,
m(TextInput, {
placeholder: key,
value: this.optValues[key],
oninput: (e: Event) => {
this.optValues[key] = (e.target as HTMLInputElement).value;
scheduleFullRedraw();
},
}),
);
}
private renderNumberOption(key: string) {
return m(
'label',
`${key}:`,
m(TextInput, {
type: 'number',
placeholder: key,
value: this.optValues[key],
oninput: (e: Event) => {
this.optValues[key] = Number.parseInt(
(e.target as HTMLInputElement).value,
);
scheduleFullRedraw();
},
}),
);
}
private renderEnumOption(key: string, opt: EnumOption) {
const optionElements = opt.options.map((option: string) => {
return m('option', {value: option}, option);
});
return m(
'label',
`${key}:`,
m(
Select,
{
value: this.optValues[key],
onchange: (e: Event) => {
const el = e.target as HTMLSelectElement;
this.optValues[key] = el.value;
scheduleFullRedraw();
},
},
optionElements,
),
);
}
}
interface File {
name: string;
size: string;
date: string;
children?: File[];
}
const files: File[] = [
{
name: 'foo',
size: '10MB',
date: '2023-04-02',
},
{
name: 'bar',
size: '123KB',
date: '2023-04-08',
children: [
{
name: 'baz',
size: '4KB',
date: '2023-05-07',
},
{
name: 'qux',
size: '18KB',
date: '2023-05-28',
children: [
{
name: 'quux',
size: '4KB',
date: '2023-05-07',
},
{
name: 'corge',
size: '18KB',
date: '2023-05-28',
children: [
{
name: 'grault',
size: '4KB',
date: '2023-05-07',
},
{
name: 'garply',
size: '18KB',
date: '2023-05-28',
},
{
name: 'waldo',
size: '87KB',
date: '2023-05-02',
},
],
},
],
},
],
},
{
name: 'fred',
size: '8KB',
date: '2022-12-27',
},
];
let virtualTableData: {offset: number; rows: VirtualTableRow[]} = {
offset: 0,
rows: [],
};
function TagInputDemo() {
const tags: string[] = ['foo', 'bar', 'baz'];
let tagInputValue: string = '';
return {
view: () => {
return m(TagInput, {
tags,
value: tagInputValue,
onTagAdd: (tag) => {
tags.push(tag);
tagInputValue = '';
scheduleFullRedraw();
},
onChange: (value) => {
tagInputValue = value;
},
onTagRemove: (index) => {
tags.splice(index, 1);
scheduleFullRedraw();
},
});
},
};
}
function SegmentedButtonsDemo({attrs}: {attrs: {}}) {
let selectedIdx = 0;
return {
view: () => {
return m(SegmentedButtons, {
...attrs,
options: [{label: 'Yes'}, {label: 'Maybe'}, {label: 'No'}],
selectedOption: selectedIdx,
onOptionSelected: (num) => {
selectedIdx = num;
scheduleFullRedraw();
},
});
},
};
}
export class WidgetsPage implements m.ClassComponent<PageAttrs> {
view() {
return m(
'.widgets-page',
m('h1', 'Widgets'),
m(WidgetShowcase, {
label: 'Button',
renderWidget: ({label, icon, rightIcon, ...rest}) =>
m(Button, {
icon: arg(icon, 'send'),
rightIcon: arg(rightIcon, 'arrow_forward'),
label: arg(label, 'Button', ''),
onclick: () => alert('button pressed'),
...rest,
}),
initialOpts: {
label: true,
icon: true,
rightIcon: false,
disabled: false,
intent: new EnumOption(Intent.None, Object.values(Intent)),
active: false,
compact: false,
loading: false,
},
}),
m(WidgetShowcase, {
label: 'Segmented Buttons',
description: `
Segmented buttons are a group of buttons where one of them is
'selected'; they act similar to a set of radio buttons.
`,
renderWidget: (opts) => m(SegmentedButtonsDemo, opts),
initialOpts: {
disabled: false,
},
}),
m(WidgetShowcase, {
label: 'Checkbox',
renderWidget: (opts) => m(Checkbox, {label: 'Checkbox', ...opts}),
initialOpts: {
disabled: false,
},
}),
m(WidgetShowcase, {
label: 'Switch',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
renderWidget: ({label, ...rest}: any) =>
m(Switch, {label: arg(label, 'Switch'), ...rest}),
initialOpts: {
label: true,
disabled: false,
},
}),
m(WidgetShowcase, {
label: 'Text Input',
renderWidget: ({placeholder, ...rest}) =>
m(TextInput, {
placeholder: arg(placeholder, 'Placeholder...', ''),
...rest,
}),
initialOpts: {
placeholder: true,
disabled: false,
},
}),
m(WidgetShowcase, {
label: 'Select',
renderWidget: (opts) =>
m(Select, opts, [
m('option', {value: 'foo', label: 'Foo'}),
m('option', {value: 'bar', label: 'Bar'}),
m('option', {value: 'baz', label: 'Baz'}),
]),
initialOpts: {
disabled: false,
},
}),
m(WidgetShowcase, {
label: 'Empty State',
renderWidget: ({header, content}) =>
m(
EmptyState,
{
title: arg(header, 'No search results found...'),
},
arg(content, m(Button, {label: 'Try again'})),
),
initialOpts: {
header: true,
content: true,
},
}),
m(WidgetShowcase, {
label: 'Anchor',
renderWidget: ({icon}) =>
m(
Anchor,
{
icon: arg(icon, 'open_in_new'),
href: 'https://perfetto.dev/docs/',
target: '_blank',
},
'This is some really long text and it will probably overflow the container',
),
initialOpts: {
icon: true,
},
}),
m(WidgetShowcase, {
label: 'CopyableLink',
renderWidget: ({noicon}) =>
m(CopyableLink, {
noicon: arg(noicon, true),
url: 'https://perfetto.dev/docs/',
}),
initialOpts: {
noicon: false,
},
}),
m(WidgetShowcase, {
label: 'Table',
renderWidget: () => m(TableShowcase),
initialOpts: {},
wide: true,
}),
m(WidgetShowcase, {
label: 'Portal',
description: `A portal is a div rendered out of normal flow
of the hierarchy.`,
renderWidget: (opts) => m(PortalButton, opts),
initialOpts: {
absolute: true,
zIndex: true,
top: true,
},
}),
m(WidgetShowcase, {
label: 'Popup',
description: `A popup is a nicely styled portal element whose position is
dynamically updated to appear to float alongside a specific element on
the page, even as the element is moved and scrolled around.`,
renderWidget: (opts) =>
m(
Popup,
{
trigger: m(Button, {label: 'Toggle Popup'}),
...opts,
},
lorem(),
),
initialOpts: {
position: new EnumOption(
PopupPosition.Auto,
Object.values(PopupPosition),
),
closeOnEscape: true,
closeOnOutsideClick: true,
},
}),
m(WidgetShowcase, {
label: 'Controlled Popup',
description: `The open/close state of a controlled popup is passed in via
the 'isOpen' attribute. This means we can get open or close the popup
from wherever we like. E.g. from a button inside the popup.
Keeping this state external also means we can modify other parts of the
page depending on whether the popup is open or not, such as the text
on this button.
Note, this is the same component as the popup above, but used in
controlled mode.`,
renderWidget: (opts) => m(ControlledPopup, opts),
initialOpts: {},
}),
m(WidgetShowcase, {
label: 'Icon',
renderWidget: (opts) => m(Icon, {icon: 'star', ...opts}),
initialOpts: {filled: false},
}),
m(WidgetShowcase, {
label: 'MultiSelect panel',
renderWidget: ({...rest}) =>
m(MultiSelect, {
options: Object.entries(options).map(([key, value]) => {
return {
id: key,
name: key,
checked: value,
};
}),
onChange: (diffs: MultiSelectDiff[]) => {
diffs.forEach(({id, checked}) => {
options[id] = checked;
});
scheduleFullRedraw();
},
...rest,
}),
initialOpts: {
repeatCheckedItemsAtTop: false,
fixedSize: false,
},
}),
m(WidgetShowcase, {
label: 'Popup with MultiSelect',
renderWidget: ({icon, ...rest}) =>
m(PopupMultiSelect, {
options: Object.entries(options).map(([key, value]) => {
return {
id: key,
name: key,
checked: value,
};
}),
popupPosition: PopupPosition.Top,
label: 'Multi Select',
icon: arg(icon, Icons.LibraryAddCheck),
onChange: (diffs: MultiSelectDiff[]) => {
diffs.forEach(({id, checked}) => {
options[id] = checked;
});
scheduleFullRedraw();
},
...rest,
}),
initialOpts: {
icon: true,
showNumSelected: true,
repeatCheckedItemsAtTop: false,
},
}),
m(WidgetShowcase, {
label: 'Menu',
renderWidget: () =>
m(
Menu,
m(MenuItem, {label: 'New', icon: 'add'}),
m(MenuItem, {label: 'Open', icon: 'folder_open'}),
m(MenuItem, {label: 'Save', icon: 'save', disabled: true}),
m(MenuDivider),
m(MenuItem, {label: 'Delete', icon: 'delete'}),
m(MenuDivider),
m(
MenuItem,
{label: 'Share', icon: 'share'},
m(MenuItem, {label: 'Everyone', icon: 'public'}),
m(MenuItem, {label: 'Friends', icon: 'group'}),
m(
MenuItem,
{label: 'Specific people', icon: 'person_add'},
m(MenuItem, {label: 'Alice', icon: 'person'}),
m(MenuItem, {label: 'Bob', icon: 'person'}),
),
),
m(
MenuItem,
{label: 'More', icon: 'more_horiz'},
m(MenuItem, {label: 'Query', icon: 'database'}),
m(MenuItem, {label: 'Download', icon: 'download'}),
m(MenuItem, {label: 'Clone', icon: 'copy_all'}),
),
),
}),
m(WidgetShowcase, {
label: 'PopupMenu2',
renderWidget: (opts) =>
m(
PopupMenu2,
{
trigger: m(Button, {
label: 'Menu',
rightIcon: Icons.ContextMenu,
}),
...opts,
},
m(MenuItem, {label: 'New', icon: 'add'}),
m(MenuItem, {label: 'Open', icon: 'folder_open'}),
m(MenuItem, {label: 'Save', icon: 'save', disabled: true}),
m(MenuDivider),
m(MenuItem, {label: 'Delete', icon: 'delete'}),
m(MenuDivider),
m(
MenuItem,
{label: 'Share', icon: 'share'},
m(MenuItem, {label: 'Everyone', icon: 'public'}),
m(MenuItem, {label: 'Friends', icon: 'group'}),
m(
MenuItem,
{label: 'Specific people', icon: 'person_add'},
m(MenuItem, {label: 'Alice', icon: 'person'}),
m(MenuItem, {label: 'Bob', icon: 'person'}),
),
),
m(
MenuItem,
{label: 'More', icon: 'more_horiz'},
m(MenuItem, {label: 'Query', icon: 'database'}),
m(MenuItem, {label: 'Download', icon: 'download'}),
m(MenuItem, {label: 'Clone', icon: 'copy_all'}),
),
),
initialOpts: {
popupPosition: new EnumOption(
PopupPosition.Bottom,
Object.values(PopupPosition),
),
},
}),
m(WidgetShowcase, {
label: 'Spinner',
description: `Simple spinner, rotates forever.
Width and height match the font size.`,
renderWidget: ({fontSize, easing}) =>
m('', {style: {fontSize}}, m(Spinner, {easing})),
initialOpts: {
fontSize: new EnumOption('16px', [
'12px',
'16px',
'24px',
'32px',
'64px',
'128px',
]),
easing: false,
},
}),
m(WidgetShowcase, {
label: 'Tree',
description: `Hierarchical tree with left and right values aligned to
a grid.`,
renderWidget: (opts) =>
m(
Tree,
opts,
m(TreeNode, {left: 'Name', right: 'my_event', icon: 'badge'}),
m(TreeNode, {left: 'CPU', right: '2', icon: 'memory'}),
m(TreeNode, {
left: 'Start time',
right: '1s 435ms',
icon: 'schedule',
}),
m(TreeNode, {left: 'Duration', right: '86ms', icon: 'timer'}),
m(TreeNode, {
left: 'SQL',
right: m(
PopupMenu2,
{
popupPosition: PopupPosition.RightStart,
trigger: m(
Anchor,
{
icon: Icons.ContextMenu,
},
'SELECT * FROM raw WHERE id = 123',
),
},
m(MenuItem, {
label: 'Copy SQL Query',
icon: 'content_copy',
}),
m(MenuItem, {
label: 'Execute Query in new tab',
icon: 'open_in_new',
}),
),
}),
m(TreeNode, {
icon: 'account_tree',
left: 'Process',
right: m(Anchor, {icon: 'open_in_new'}, '/bin/foo[789]'),
}),
m(TreeNode, {
left: 'Thread',
right: m(Anchor, {icon: 'open_in_new'}, 'my_thread[456]'),
}),
m(
TreeNode,
{
left: 'Args',
summary: 'foo: string, baz: string, quux: string[4]',
},
m(TreeNode, {left: 'foo', right: 'bar'}),
m(TreeNode, {left: 'baz', right: 'qux'}),
m(
TreeNode,
{left: 'quux', summary: 'string[4]'},
m(TreeNode, {left: '[0]', right: 'corge'}),
m(TreeNode, {left: '[1]', right: 'grault'}),
m(TreeNode, {left: '[2]', right: 'garply'}),
m(TreeNode, {left: '[3]', right: 'waldo'}),
),
),
m(LazyTreeNode, {
left: 'Lazy',
icon: 'bedtime',
fetchData: async () => {
await new Promise((r) => setTimeout(r, 1000));
return () => m(TreeNode, {left: 'foo'});
},
}),
m(LazyTreeNode, {
left: 'Dynamic',
unloadOnCollapse: true,
icon: 'bedtime',
fetchData: async () => {
await new Promise((r) => setTimeout(r, 1000));
return () => m(TreeNode, {left: 'foo'});
},
}),
recursiveTreeNode(),
),
wide: true,
}),
m(WidgetShowcase, {
label: 'Form',
renderWidget: () => renderForm('form'),
}),
m(WidgetShowcase, {
label: 'Nested Popups',
renderWidget: () =>
m(
Popup,
{
trigger: m(Button, {label: 'Open the popup'}),
},
m(
PopupMenu2,
{
trigger: m(Button, {label: 'Select an option'}),
},
m(MenuItem, {label: 'Option 1'}),
m(MenuItem, {label: 'Option 2'}),
),
m(Button, {
label: 'Done',
dismissPopup: true,
}),
),
}),
m(WidgetShowcase, {
label: 'Callout',
renderWidget: () =>
m(
Callout,
{
icon: 'info',
},
'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' +
'Nulla rhoncus tempor neque, sed malesuada eros dapibus vel. ' +
'Aliquam in ligula vitae tortor porttitor laoreet iaculis ' +
'finibus est.',
),
}),
m(WidgetShowcase, {
label: 'Editor',
renderWidget: () => m(Editor),
}),
m(WidgetShowcase, {
label: 'VegaView',
renderWidget: (opt) =>
m(VegaView, {
spec: getExampleSpec(opt.exampleSpec),
data: getExampleData(opt.exampleData),
}),
initialOpts: {
exampleSpec: new EnumOption(
SpecExample.BarChart,
Object.values(SpecExample),
),
exampleData: new EnumOption(
DataExample.English,
Object.values(DataExample),
),
},
}),
m(WidgetShowcase, {
label: 'Form within PopupMenu2',
description: `A form placed inside a popup menu works just fine,
and the cancel/submit buttons also dismiss the popup. A bit more
margin is added around it too, which improves the look and feel.`,
renderWidget: () =>
m(
PopupMenu2,
{
trigger: m(Button, {label: 'Popup!'}),
},
m(
MenuItem,
{
label: 'Open form...',
},
renderForm('popup-form'),
),
),
}),
m(WidgetShowcase, {
label: 'Hotkey',
renderWidget: (opts) => {
if (opts.platform === 'auto') {
return m(HotkeyGlyphs, {hotkey: opts.hotkey as Hotkey});
} else {
const platform = opts.platform as Platform;
return m(HotkeyGlyphs, {
hotkey: opts.hotkey as Hotkey,
spoof: platform,
});
}
},
initialOpts: {
hotkey: 'Mod+Shift+P',
platform: new EnumOption('auto', ['auto', 'Mac', 'PC']),
},
}),
m(WidgetShowcase, {
label: 'Text Paragraph',
description: `A basic formatted text paragraph with wrapping. If
it is desirable to preserve the original text format/line breaks,
set the compressSpace attribute to false.`,
renderWidget: (opts) => {
return m(TextParagraph, {
text: `Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Nulla rhoncus tempor neque, sed malesuada eros
dapibus vel. Aliquam in ligula vitae tortor porttitor
laoreet iaculis finibus est.`,
compressSpace: opts.compressSpace,
});
},
initialOpts: {
compressSpace: true,
},
}),
m(WidgetShowcase, {
label: 'Multi Paragraph Text',
description: `A wrapper for multiple paragraph widgets.`,
renderWidget: () => {
return m(
MultiParagraphText,
m(TextParagraph, {
text: `Lorem ipsum dolor sit amet, consectetur adipiscing
elit. Nulla rhoncus tempor neque, sed malesuada eros
dapibus vel. Aliquam in ligula vitae tortor porttitor
laoreet iaculis finibus est.`,
compressSpace: true,
}),
m(TextParagraph, {
text: `Sed ut perspiciatis unde omnis iste natus error sit
voluptatem accusantium doloremque laudantium, totam rem
aperiam, eaque ipsa quae ab illo inventore veritatis et
quasi architecto beatae vitae dicta sunt explicabo.
Nemo enim ipsam voluptatem quia voluptas sit aspernatur
aut odit aut fugit, sed quia consequuntur magni dolores
eos qui ratione voluptatem sequi nesciunt.`,
compressSpace: true,
}),
);
},
}),
m(WidgetShowcase, {
label: 'Modal',
description: `A helper for modal dialog.`,
renderWidget: () => m(ModalShowcase),
}),
m(WidgetShowcase, {
label: 'TreeTable',
description: `Hierarchical tree with multiple columns`,
renderWidget: () => {
const attrs: TreeTableAttrs<File> = {
rows: files,
getChildren: (file) => file.children,
columns: [
{name: 'Name', getData: (file) => file.name},
{name: 'Size', getData: (file) => file.size},
{name: 'Date', getData: (file) => file.date},
],
};
return m(TreeTable<File>, attrs);
},
}),
m(WidgetShowcase, {
label: 'VirtualTable',
description: `Virtualized table for efficient rendering of large datasets`,
renderWidget: () => {
const attrs: VirtualTableAttrs = {
columns: [
{header: 'x', width: '4em'},
{header: 'x^2', width: '8em'},
],
rows: virtualTableData.rows,
firstRowOffset: virtualTableData.offset,
rowHeight: 20,
numRows: 500_000,
style: {height: '200px'},
onReload: (rowOffset, rowCount) => {
const rows = [];
for (let i = rowOffset; i < rowOffset + rowCount; i++) {
rows.push({id: i, cells: [i, i ** 2]});
}
virtualTableData = {
offset: rowOffset,
rows,
};
scheduleFullRedraw();
},
};
return m(VirtualTable, attrs);
},
}),
m(WidgetShowcase, {
label: 'Tag Input',
description: `
TagInput displays Tag elements inside an input, followed by an
interactive text input. The container is styled to look like a
TextInput, but the actual editable element appears after the last tag.
Clicking anywhere on the container will focus the text input.`,
renderWidget: () => m(TagInputDemo),
}),
m(WidgetShowcase, {
label: 'Middle Ellipsis',
description: `
Sometimes the start and end of a bit of text are more important than
the middle. This element puts the ellipsis in the midde if the content
is too wide for its container.`,
renderWidget: (opts) =>
m(
'div',
{style: {width: Boolean(opts.squeeze) ? '150px' : '450px'}},
m(MiddleEllipsis, {
text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit',
}),
),
initialOpts: {
squeeze: false,
},
}),
m(WidgetShowcase, {
label: 'Chip',
description: `A little chip or tag`,
renderWidget: (opts) => {
const {icon, ...rest} = opts;
return m(
ChipBar,
m(Chip, {
label: 'Foo',
icon: icon === true ? 'info' : undefined,
...rest,
}),
m(Chip, {label: 'Bar', ...rest}),
m(Chip, {label: 'Baz', ...rest}),
);
},
initialOpts: {
intent: new EnumOption(Intent.None, Object.values(Intent)),
icon: true,
compact: false,
rounded: false,
},
}),
m(WidgetShowcase, {
label: 'Track',
description: `A track`,
renderWidget: (opts) => {
const {buttons, chips, multipleTracks, ...rest} = opts;
const dummyButtons = () => [
m(Button, {icon: 'info', compact: true}),
m(Button, {icon: 'settings', compact: true}),
];
const dummyChips = () => ['foo', 'bar'];
const renderTrack = () =>
m(TrackWidget, {
buttons: Boolean(buttons) ? dummyButtons() : undefined,
chips: Boolean(chips) ? dummyChips() : undefined,
...rest,
});
return m(
'',
{
style: {width: '500px', boxShadow: '0px 0px 1px 1px lightgray'},
},
Boolean(multipleTracks)
? [renderTrack(), renderTrack(), renderTrack()]
: renderTrack(),
);
},
initialOpts: {
title: 'This is the title of the track',
buttons: true,
chips: true,
heightPx: 32,
indentationLevel: 3,
collapsible: true,
collapsed: true,
isSummary: false,
highlight: false,
error: false,
multipleTracks: false,
reorderable: false,
},
}),
);
}
}
class ModalShowcase implements m.ClassComponent {
private static counter = 0;
private static log(txt: string) {
const mwlogs = document.getElementById('mwlogs');
if (!mwlogs || !(mwlogs instanceof HTMLTextAreaElement)) return;
const time = new Date().toLocaleTimeString();
mwlogs.value += `[${time}] ${txt}\n`;
mwlogs.scrollTop = mwlogs.scrollHeight;
}
private static showModalDialog(staticContent = false) {
const id = `N=${++ModalShowcase.counter}`;
ModalShowcase.log(`Open ${id}`);
const logOnClose = () => ModalShowcase.log(`Close ${id}`);
let content;
if (staticContent) {
content = m('.modal-pre', 'Content of the modal dialog.\nEnd of content');
} else {
const component = {
oninit: function (vnode: m.Vnode<{}, {progress: number}>) {
vnode.state.progress = ((vnode.state.progress as number) || 0) + 1;
},
view: function (vnode: m.Vnode<{}, {progress: number}>) {
vnode.state.progress = (vnode.state.progress + 1) % 100;
scheduleFullRedraw();
return m(
'div',
m('div', 'You should see an animating progress bar'),
m('progress', {value: vnode.state.progress, max: 100}),
);
},
} as m.Component<{}, {progress: number}>;
content = () => m(component);
}
const closePromise = showModal({
title: `Modal dialog ${id}`,
buttons: [
{text: 'OK', action: () => ModalShowcase.log(`OK ${id}`)},
{text: 'Cancel', action: () => ModalShowcase.log(`Cancel ${id}`)},
{
text: 'Show another now',
action: () => ModalShowcase.showModalDialog(),
},
{
text: 'Show another in 2s',
action: () => setTimeout(() => ModalShowcase.showModalDialog(), 2000),
},
],
content,
});
closePromise.then(logOnClose);
}
view() {
return m(
'div',
{
style: {
'display': 'flex',
'flex-direction': 'column',
'width': '100%',
},
},
m('textarea', {
id: 'mwlogs',
readonly: 'readonly',
rows: '8',
placeholder: 'Logs will appear here',
}),
m('input[type=button]', {
value: 'Show modal (static)',
onclick: () => ModalShowcase.showModalDialog(true),
}),
m('input[type=button]', {
value: 'Show modal (dynamic)',
onclick: () => ModalShowcase.showModalDialog(false),
}),
);
}
} // class ModalShowcase
function renderForm(id: string) {
return m(
Form,
{
submitLabel: 'Submit',
submitIcon: 'send',
cancelLabel: 'Cancel',
resetLabel: 'Reset',
onSubmit: () => window.alert('Form submitted!'),
},
m(FormLabel, {for: `${id}-foo`}, 'Foo'),
m(TextInput, {id: `${id}-foo`}),
m(FormLabel, {for: `${id}-bar`}, 'Bar'),
m(Select, {id: `${id}-bar`}, [
m('option', {value: 'foo', label: 'Foo'}),
m('option', {value: 'bar', label: 'Bar'}),
m('option', {value: 'baz', label: 'Baz'}),
]),
);
}