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