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