├── .gitignore ├── .prettierrc ├── .vscode ├── settings.json └── spellright.dict ├── LICENSE ├── README.md ├── docs ├── 0c35d18bf06992036b69.woff2 ├── 0f170ea0ecc81d3b9ccc.jpg ├── 4d73cb90e394b34b7670.woff ├── bundle.js ├── bundle.js.map ├── favicon.ico └── index.html ├── package.json ├── packages ├── example │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── assets │ │ │ ├── Vincent_van_Gogh_-_Landscape_at_twilight_-_Google_Art_Project.jpg │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ └── favicon.ico │ │ ├── components │ │ │ ├── about │ │ │ │ └── about-page.ts │ │ │ ├── buttons │ │ │ │ └── button-page.ts │ │ │ ├── collections │ │ │ │ └── collections-page.ts │ │ │ ├── home │ │ │ │ └── home-page.ts │ │ │ ├── inputs │ │ │ │ └── input-page.ts │ │ │ ├── layout.ts │ │ │ ├── map-editor │ │ │ │ └── map-editor-page.ts │ │ │ ├── misc │ │ │ │ └── misc-page.ts │ │ │ ├── modals │ │ │ │ └── modal-page.ts │ │ │ ├── pickers │ │ │ │ └── picker-page.ts │ │ │ ├── selections │ │ │ │ └── selection-page.ts │ │ │ └── timeline │ │ │ │ └── timeline-page.ts │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── models │ │ │ └── dashboard.ts │ │ ├── services │ │ │ └── dashboard-service.ts │ │ └── utils │ │ │ ├── jpg.d.ts │ │ │ ├── json.d.ts │ │ │ ├── png.d.ts │ │ │ ├── svg.d.ts │ │ │ └── utils.ts │ ├── tsconfig.json │ ├── tslint.json │ └── webpack.config.js └── lib │ ├── .gitignore │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── src │ ├── autocomplete.ts │ ├── button.ts │ ├── carousel.ts │ ├── chip.ts │ ├── code-block.ts │ ├── collapsible.ts │ ├── collection.ts │ ├── dropdown.ts │ ├── floating-action-button.ts │ ├── icon.ts │ ├── index.ts │ ├── input-options.ts │ ├── input.ts │ ├── label.ts │ ├── map-editor.ts │ ├── material-box.ts │ ├── modal.ts │ ├── option.ts │ ├── pagination.ts │ ├── parallax.ts │ ├── pickers.ts │ ├── radio.ts │ ├── search-select.ts │ ├── select.ts │ ├── styles │ │ ├── codeblock.css │ │ ├── input.css │ │ ├── map-editor.css │ │ ├── switch.css │ │ └── timeline.css │ ├── switch.ts │ ├── tabs.ts │ ├── timeline.ts │ └── utils.ts │ ├── tsconfig.json │ └── tslint.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | dist 63 | .rpt2_cache 64 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 2, 3 | "useTabs": false, 4 | "singleQuote": true, 5 | "printWidth": 120, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "spellright.language": [ 3 | "en" 4 | ], 5 | "spellright.documentTypes": [ 6 | "markdown", 7 | "latex", 8 | "plaintext" 9 | ], 10 | "typescript.tsdk": "./packages/lib/node_modules/typescript/lib" 11 | } -------------------------------------------------------------------------------- /.vscode/spellright.dict: -------------------------------------------------------------------------------- 1 | materialize-css 2 | Url 3 | Color 4 | materialize-css 5 | callback 6 | Vnode 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Erik Vullings 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mithril-materialized 2 | 3 | A [materialize-css](https://materializecss.com) library, [available on npm](https://www.npmjs.com/package/mithril-materialized), for the Mithril framework (tested with v2.0.0-rc7 and higher, but presumably, it should work with v1.1.6 too), making it easier to use a Materialize theme in your application. This library provides you with Mithril components, wrapping around the materialize-css functionality. 4 | 5 | Supported components: 6 | 7 | - [Buttons](https://erikvullings.github.io/mithril-materialized/#!/buttons) 8 | - Button 9 | - FlatButton 10 | - RoundButton 11 | - SubmitButton 12 | - [Inputs](https://erikvullings.github.io/mithril-materialized/#!/inputs) 13 | - TextInput 14 | - TextArea 15 | - AutoComplete 16 | - UrlInput 17 | - EmailInput 18 | - NumberInput 19 | - ColorInput 20 | - RangeInput 21 | - Chips 22 | - [Pickers](https://erikvullings.github.io/mithril-materialized/#!/pickers) 23 | - DatePicker 24 | - TimePicker 25 | - [Selections](https://erikvullings.github.io/mithril-materialized/#!/selections) 26 | - Select 27 | - Options 28 | - RadioButtons 29 | - Switch 30 | - Dropdown 31 | - [Collections](https://erikvullings.github.io/mithril-materialized/#!/collections) 32 | - Basic, Link and Avatar Collections 33 | - Collapsible or accordion 34 | - [Others](https://erikvullings.github.io/mithril-materialized/#!/modals) 35 | - ModalPanel 36 | - MaterialBox 37 | - Carousel 38 | - Pagination 39 | - Parallax 40 | - Additional 41 | - Label 42 | - HelperText 43 | - Not from Materialize-CSS 44 | - CodeBlock 45 | - SearchSelect, a searchable select dropdown 46 | - [MapEditor](https://erikvullings.github.io/mithril-materialized/#!/map_editor) 47 | - [Timeline](https://erikvullings.github.io/mithril-materialized/#!/timeline) 48 | 49 | ## Usage instructions 50 | 51 | See the [documentation](https://erikvullings.github.io/mithril-materialized/index.html) for examples on how to use this library in your own application. Please note that the library does not include mithril, nor the materialize-css JavaScript or CSS, so you have to include them yourself, as documented. 52 | 53 | ## Build instructions 54 | 55 | This repository consists of two packages, combined using `lerna`: the `lib` package that is published to `npm`, as well as an `example` project which uses this library to display the Mithril components that it contains. 56 | 57 | To install the dependencies, you can use `npm i`, or, alternatively, use `pnpm m i` (assuming you have installed `pnpm` as alternative package manager using `npm i -g pnpm`) to perform a multi-repository install. Next, build everything using `npm start` and visit the documentation page on [http://localhost:1234](http://localhost:1234) in case port 1234 is not occupied already. 58 | 59 | ## CSS 60 | 61 | Although I've tried to limit the CSS adaptations to a minimum, I needed to tweak certain parts to make it look better. You can either copy them manually, or import them, e.g. 62 | 63 | ```ts 64 | import 'mithril-materialized/dist/index.css'; 65 | ``` 66 | 67 | Here are the styles I've added. 68 | 69 | ```css 70 | /* For the switch */ 71 | .clear, 72 | .clear-10, 73 | .clear-15 { 74 | clear: both; 75 | /* overflow: hidden; Précaution pour IE 7 */ 76 | } 77 | .clear-10 { 78 | margin-bottom: 10px; 79 | } 80 | .clear-15 { 81 | margin-bottom: 15px; 82 | } 83 | 84 | span.mandatory { 85 | margin-left: 5px; 86 | color: red; 87 | } 88 | 89 | label+.switch { 90 | margin-top: 1rem; 91 | } 92 | 93 | /* For the color input */ 94 | input[type='color']:not(.browser-default) { 95 | margin: 0px 0 8px 0; 96 | /** Copied from input[type=number] */ 97 | background-color: transparent; 98 | border: none; 99 | border-bottom: 1px solid #9e9e9e; 100 | border-radius: 0; 101 | outline: none; 102 | height: 3rem; 103 | width: 100%; 104 | font-size: 16px; 105 | padding: 0; 106 | -webkit-box-shadow: none; 107 | box-shadow: none; 108 | -webkit-box-sizing: content-box; 109 | box-sizing: content-box; 110 | -webkit-transition: border 0.3s, -webkit-box-shadow 0.3s; 111 | transition: border 0.3s, -webkit-box-shadow 0.3s; 112 | transition: box-shadow 0.3s, border 0.3s; 113 | transition: box-shadow 0.3s, border 0.3s, -webkit-box-shadow 0.3s; 114 | } 115 | 116 | /* For the options' label */ 117 | .input-field.options > label { 118 | top: -2.5rem; 119 | } 120 | 121 | /* For the code block */ 122 | .codeblock { 123 | margin: 1.5rem 0 2.5rem 0; 124 | } 125 | .codeblock > div { 126 | margin-bottom: 1rem; 127 | } 128 | .codeblock > label { 129 | display: inline-block; 130 | } 131 | 132 | ``` 133 | -------------------------------------------------------------------------------- /docs/0c35d18bf06992036b69.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/docs/0c35d18bf06992036b69.woff2 -------------------------------------------------------------------------------- /docs/0f170ea0ecc81d3b9ccc.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/docs/0f170ea0ecc81d3b9ccc.jpg -------------------------------------------------------------------------------- /docs/4d73cb90e394b34b7670.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/docs/4d73cb90e394b34b7670.woff -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/docs/favicon.ico -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Mithril Materialized -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mithril-materialized", 3 | "version": "0.1.1", 4 | "private": true, 5 | "description": "A materialize library for mithril.", 6 | "scripts": { 7 | "clean:local": "rimraf -rf ./docs", 8 | "build": "cd packages/lib && pnpm run build", 9 | "build:example": "cd packages/example && pnpm run build", 10 | "build:domain": "pnpm run -r --parallel clean:docs && pnpm run -r --parallel build:domain", 11 | "start": "pnpm -r --parallel run dev", 12 | "clean": "npm run clean:local && pnpm clean", 13 | "dry-run": "pnpm dry-run", 14 | "patch-release": "npm run clean && npm run build && pnpm patch-release", 15 | "minor-release": "npm run clean && npm run build && pnpm minor-release" 16 | }, 17 | "keywords": [ 18 | "mithril", 19 | "materialize-css", 20 | "material design", 21 | "ui toolkit" 22 | ], 23 | "author": "Erik Vullings (http://www.tno.nl)", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "pnpm": "10.11.0", 27 | "rimraf": "^6.0.1" 28 | } 29 | } -------------------------------------------------------------------------------- /packages/example/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .cache 3 | .parcel-cache 4 | -------------------------------------------------------------------------------- /packages/example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "version": "0.1.4", 5 | "description": "Example project voor the mithril-materialized library.", 6 | "scripts": { 7 | "clean": "rimraf ./.cache ../docs", 8 | "dev": "npm start", 9 | "start": "webpack serve --env development --open", 10 | "build": "webpack --env production", 11 | "build:domain": "npm run clean && pnpm run build", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "keywords": [ 15 | "example", 16 | "mithril", 17 | "typescript", 18 | "materialize-css" 19 | ], 20 | "author": "Erik Vullings ", 21 | "license": "MIT", 22 | "target": "web", 23 | "dependencies": { 24 | "material-icons": "^1.13.14", 25 | "materialize-css": "^1.0.0", 26 | "mithril": "^2.3.0", 27 | "mithril-materialized": "workspace:*", 28 | "tslib": "^2.8.1" 29 | }, 30 | "devDependencies": { 31 | "@types/materialize-css": "^1.0.14", 32 | "@types/mithril": "^2.2.7", 33 | "css-loader": "^7.1.2", 34 | "css-minimizer-webpack-plugin": "^7.0.2", 35 | "dotenv-webpack": "^8.1.0", 36 | "html-webpack-plugin": "^5.6.3", 37 | "html-webpack-tags-plugin": "^3.0.2", 38 | "mini-css-extract-plugin": "^2.9.2", 39 | "rimraf": "^6.0.1", 40 | "style-loader": "^4.0.0", 41 | "ts-loader": "^9.5.2", 42 | "typescript": "^5.8.3", 43 | "webpack": "^5.99.8", 44 | "webpack-cli": "6.0.1", 45 | "webpack-dev-server": "^5.2.1" 46 | } 47 | } -------------------------------------------------------------------------------- /packages/example/src/app.ts: -------------------------------------------------------------------------------- 1 | import 'materialize-css/dist/css/materialize.min.css'; 2 | import 'materialize-css/dist/js/materialize.min.js'; 3 | import 'material-icons/iconfont/filled.css'; 4 | import 'mithril-materialized/index.css'; 5 | import m from 'mithril'; 6 | import { dashboardSvc } from './services/dashboard-service'; 7 | // import '@materializecss/materialize/dist/css/materialize.min.css'; 8 | // import '/home/erik/dev/mithril-materialized/node_modules/.pnpm/@materializecss+materialize@2.0.1-alpha/node_modules/@materializecss/materialize/dist/css/materialize.min.css'; 9 | 10 | document.documentElement.setAttribute('lang', 'en'); 11 | 12 | m.route(document.body, dashboardSvc.defaultRoute, dashboardSvc.routingTable); 13 | -------------------------------------------------------------------------------- /packages/example/src/assets/Vincent_van_Gogh_-_Landscape_at_twilight_-_Google_Art_Project.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/packages/example/src/assets/Vincent_van_Gogh_-_Landscape_at_twilight_-_Google_Art_Project.jpg -------------------------------------------------------------------------------- /packages/example/src/assets/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/packages/example/src/assets/favicon-16x16.png -------------------------------------------------------------------------------- /packages/example/src/assets/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/packages/example/src/assets/favicon-32x32.png -------------------------------------------------------------------------------- /packages/example/src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/packages/example/src/assets/favicon.ico -------------------------------------------------------------------------------- /packages/example/src/components/about/about-page.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | 3 | export const AboutPage = () => ({ 4 | view: () => 5 | m('.row', [ 6 | m('h1', 'About'), 7 | m('h1', 'Attribution'), 8 | m('ul.collection', [m('li.collection-item', 'Logo: ideation by Vytautas Alech from the Noun Project.')]), 9 | ]), 10 | }); 11 | -------------------------------------------------------------------------------- /packages/example/src/components/buttons/button-page.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RoundIconButton, 3 | SubmitButton, 4 | Button, 5 | FlatButton, 6 | FloatingActionButton, 7 | CodeBlock, 8 | } from 'mithril-materialized'; 9 | import m, { Component } from 'mithril'; 10 | 11 | export const ButtonPage = () => { 12 | const onclick = () => alert('Button clicked'); 13 | return { 14 | view: () => 15 | m('.col.s12', [ 16 | m('h2.header', 'Buttons'), 17 | 18 | m('h3.header[id=fab]', 'Floating Action Button (FAB)'), 19 | m(FloatingActionButton, { 20 | className: 'red', 21 | iconName: 'mode_edit', 22 | direction: 'left', 23 | position: 'inline-right', 24 | buttons: [ 25 | { iconName: 'insert_chart', className: 'red', onClick: () => console.log('Insert chart') }, 26 | { iconName: 'format_quote', className: 'yellow darken-1', onClick: () => console.log('Format quote') }, 27 | { iconName: 'publish', className: 'green', onClick: () => console.log('Publish') }, 28 | { iconName: 'attach_file', className: 'blue', onClick: () => console.log('Attach file') }, 29 | ], 30 | }), 31 | m(FloatingActionButton, { 32 | className: 'red', 33 | iconName: 'mode_edit', 34 | direction: 'left', 35 | buttons: [ 36 | { iconName: 'insert_chart', className: 'red', onClick: () => console.log('Insert chart') }, 37 | { iconName: 'format_quote', className: 'yellow darken-1', onClick: () => console.log('Format quote') }, 38 | { iconName: 'publish', className: 'green', onClick: () => console.log('Publish') }, 39 | { iconName: 'attach_file', className: 'blue', onClick: () => console.log('Attach file') }, 40 | ], 41 | }), 42 | m(CodeBlock, { 43 | code: [ 44 | `m(FloatingActionButton, { 45 | className: 'red', 46 | iconName: 'mode_edit', 47 | direction: 'left', 48 | position: 'inline-right', // Comment this out to get a FAB in the bottom-left of the page. 49 | buttons: [ 50 | { iconName: 'insert_chart', className: 'red', onClick: () => console.log('Insert chart') }, 51 | { iconName: 'format_quote', className: 'yellow darken-1', onClick: () => console.log('Format quote') }, 52 | { iconName: 'publish', className: 'green', onClick: () => console.log('Publish') }, 53 | { iconName: 'attach_file', className: 'blue', onClick: () => console.log('Attach file') }, 54 | ], 55 | }),`, 56 | ], 57 | }), 58 | 59 | m('h3.header[id=raised]', 'Raised'), 60 | m('div', [ 61 | m(Button, { label: 'First Button', onclick }), 62 | m(Button, { label: 'Second Button', iconName: 'cloud', onclick }), 63 | m(Button, { label: 'Third Button', iconName: 'cloud', iconClass: 'right', onclick }), 64 | m(Button, { 65 | label: 'Fourth Button', 66 | iconName: 'cloud', 67 | attr: { disabled: true }, 68 | onclick, 69 | }), 70 | ]), 71 | m(CodeBlock, { 72 | code: [ 73 | `const onclick = () => alert('Button clicked'); 74 | m('div', [ 75 | m(Button, { label: 'First Button', onclick }), 76 | m(Button, { label: 'Second Button', iconName: 'cloud', onclick }), 77 | m(Button, { label: 'Third Button', iconName: 'cloud', iconClass: 'right', onclick }), 78 | m(Button, { 79 | label: 'Fourth Button', 80 | iconName: 'cloud', 81 | attr: { disabled: true }, 82 | onclick, 83 | }), 84 | ])`, 85 | ], 86 | }), 87 | m('h3.header[id=flatbutton]', 'FlatButton'), 88 | m('div', m(FlatButton, { label: 'My Flat button', onclick })), 89 | m(CodeBlock, { code: 'm(FlatButton, { label: "My Flat button", onclick })' }), 90 | m('h3.header[id=roundiconbutton]', 'RoundIconButton'), 91 | m('div', m(RoundIconButton, { iconName: 'create', onclick })), 92 | m(CodeBlock, { code: 'm(RoundIconButton, { iconName: "create", onclick })' }), 93 | m('h3.header[id=submitbutton]', 'SubmitButton'), 94 | m( 95 | 'div', 96 | m(SubmitButton, { 97 | label: 'Submit', 98 | iconName: 'send', 99 | iconClass: 'right', 100 | onclick, 101 | }) 102 | ), 103 | m(CodeBlock, { 104 | code: `m(SubmitButton, { 105 | label: 'Submit', 106 | iconName: 'send', 107 | iconClass: 'right', 108 | onclick, 109 | })`, 110 | }), 111 | ]), 112 | } as Component; 113 | }; 114 | -------------------------------------------------------------------------------- /packages/example/src/components/collections/collections-page.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlock, Collapsible, Collection, ICollectionItem, CollectionMode } from 'mithril-materialized'; 2 | import m from 'mithril'; 3 | 4 | const onclick = (item: ICollectionItem) => alert(`You clicked ${item.title}.`); 5 | 6 | export const CollectionsPage = () => { 7 | return { 8 | view: () => 9 | m('.col.s12.m8.xl7', [ 10 | m('h2.header', 'Collections and collapsible'), 11 | m('p', [ 12 | 'For more information, see ', 13 | m('a[href=https://materializecss.com/collections.html][target=_blank]', 'Collections'), 14 | ' and ', 15 | m('a[href=https://materializecss.com/collapsible.html][target=_blank]', 'Collapsible'), 16 | '.', 17 | ]), 18 | 19 | m('h3.header', 'Secondary Content Collection'), 20 | m(Collection, { 21 | items: [ 22 | { id: '1', title: 'John', iconName: 'send', onclick }, 23 | { id: '2', title: 'Mary', iconName: 'send', onclick }, 24 | { id: '3', title: 'Pete', iconName: 'send', onclick }, 25 | ], 26 | }), 27 | m(CodeBlock, { 28 | code: ` m(Collection, { 29 | items: [ 30 | // id is used as key, e.g. when sorting or editing the collection. 31 | { id: '1', title: 'John', iconName: 'send', onclick }, 32 | { id: '2', title: 'Mary', iconName: 'send', onclick }, 33 | { id: '3', title: 'Pete', iconName: 'send', onclick }, 34 | ], 35 | })`, 36 | }), 37 | 38 | m('h3.header', 'Links collection'), 39 | m(Collection, { 40 | header: 'First names', 41 | mode: CollectionMode.LINKS, 42 | items: [ 43 | { title: 'John', onclick: console.log }, 44 | { title: 'Mary', onclick: console.log, href: '/timeline' }, 45 | { title: 'Pete', onclick: console.log, href: 'https://www.google.com' }, 46 | ], 47 | }), 48 | m(CodeBlock, { 49 | code: ` m(Collection, { 50 | header: 'First names', 51 | mode: CollectionMode.LINKS, 52 | items: [ 53 | { title: 'John', onclick: console.log }, 54 | { title: 'Mary', onclick: console.log, href: '/timeline' }, 55 | { title: 'Pete', onclick: console.log, href: 'https://www.google.com' }, 56 | ], 57 | })`, 58 | }), 59 | 60 | m('h3.header', 'Avatar collection'), 61 | m(Collection, { 62 | header: 'First names', 63 | mode: CollectionMode.AVATAR, 64 | items: [ 65 | { 66 | title: 'John', 67 | content: 'First line
Second line', 68 | avatar: 'folder', 69 | className: 'green', 70 | iconName: 'grade', 71 | onclick, 72 | }, 73 | { 74 | title: 'Mary', 75 | content: 'First line
Second line', 76 | avatar: 'https://pbs.twimg.com/profile_images/665673789112516608/v9itf6uk_400x400.jpg', 77 | iconName: 'grade', 78 | onclick, 79 | }, 80 | { 81 | title: 'Pete', 82 | content: 'First line
Second line', 83 | avatar: 'play_arrow', 84 | className: 'red', 85 | iconName: 'file_download', 86 | href: 'http://www.google.com', 87 | }, 88 | ], 89 | }), 90 | m(CodeBlock, { 91 | code: ` m(Collection, { 92 | header: 'First names', 93 | mode: CollectionMode.AVATAR, 94 | items: [ 95 | { 96 | title: 'John', 97 | content: 'First line
Second line', 98 | avatar: 'folder', 99 | className: 'green', 100 | iconName: 'grade', 101 | onclick, 102 | }, 103 | { 104 | title: 'Mary', 105 | content: 'First line
Second line', 106 | avatar: 'https://pbs.twimg.com/profile_images/665673789112516608/v9itf6uk_400x400.jpg', 107 | iconName: 'grade', 108 | onclick, 109 | }, 110 | { 111 | title: 'Pete', 112 | content: 'First line
Second line', 113 | avatar: 'play_arrow', 114 | className: 'red', 115 | iconName: 'file_download', 116 | href: 'http://www.google.com', 117 | }, 118 | ], 119 | })`, 120 | }), 121 | 122 | m('h3.header', 'Collapsible (accordion)'), 123 | m( 124 | '.row', 125 | m(Collapsible, { 126 | id: 'testme', 127 | className: 'first-second-third', 128 | items: [ 129 | { id: 1, header: 'First', body: 'Lorem ipsum dolor sit amet.', iconName: 'filter_drama' }, 130 | { id: 2, header: 'Second', body: 'Lorem ipsum dolor sit amet.', iconName: 'place', active: true }, 131 | { id: 3, header: 'Third', body: m('span', 'Third in a span.'), iconName: 'whatshot' }, 132 | ], 133 | }) 134 | ), 135 | m(CodeBlock, { 136 | code: ` m(Collapsible, { items: [ 137 | { header: 'First', body: 'Lorem ipsum dolor sit amet.', iconName: 'filter_drama' }, 138 | { header: 'Second', body: 'Lorem ipsum dolor sit amet.', iconName: 'place', active: true }, 139 | { header: 'Third', body: m('span', 'Third in a span.'), iconName: 'whatshot' }, 140 | ] })`, 141 | }), 142 | 143 | m('h3.header', 'Collapsible (no accordion)'), 144 | m( 145 | '.row', 146 | m(Collapsible, { 147 | accordion: false, 148 | items: [ 149 | { id: 1, header: 'First', body: 'Lorem ipsum dolor sit amet.', iconName: 'filter_drama', active: true }, 150 | { id: 2, header: 'Second', body: 'Lorem ipsum dolor sit amet.', iconName: 'place', active: true }, 151 | { id: 3, header: 'Third', body: m('span', 'Third in a span.'), iconName: 'whatshot' }, 152 | ], 153 | }) 154 | ), 155 | m(CodeBlock, { 156 | code: ` m(Collapsible, { 157 | accordion: false, 158 | items: [ 159 | { header: 'First', body: 'Lorem ipsum dolor sit amet.', iconName: 'filter_drama', active: true }, 160 | { header: 'Second', body: 'Lorem ipsum dolor sit amet.', iconName: 'place', active: true }, 161 | { header: 'Third', body: m('span', 'Third in a span.'), iconName: 'whatshot' }, 162 | ] })`, 163 | }), 164 | ]), 165 | }; 166 | }; 167 | -------------------------------------------------------------------------------- /packages/example/src/components/home/home-page.ts: -------------------------------------------------------------------------------- 1 | import { dashboardSvc } from '../../services/dashboard-service'; 2 | import m from 'mithril'; 3 | import { CodeBlock } from 'mithril-materialized'; 4 | 5 | export const HomePage = () => ({ 6 | view: () => 7 | m('.home-page', [ 8 | m( 9 | '.col.s12.m7.l8', 10 | m('.introduction', [ 11 | m('h2', 'About Mithril-Materialized'), 12 | m( 13 | 'p', 14 | `I like Mithril, and I also like materialize-css. However, to create some materialized components 15 | is a bit cumbersome as it requires a lot of HTML elements and a specific nesting which can easily go 16 | wrong. For that reason, the mithril-materialized library provides you with several ready-made 17 | Mithril components, so you can easily use them in your own application.` 18 | ), 19 | m('p', [ 20 | 'You can check out the API documentation ', 21 | m('a[href="https://erikvullings.github.io/mithril-materialized/typedoc/index.html"]', 'here'), 22 | '.', 23 | ]), 24 | m('h3', 'Installation'), 25 | m('p', 'First, you need to install the required packages:'), 26 | m(CodeBlock, { 27 | language: 'console', 28 | code: `npm i materialize-css material-icons mithril mithril-materialized 29 | # Also install the typings if you use TypeScript 30 | npm i --save-dev @types/materialize-css @types/mithril`, 31 | }), 32 | m('p', 'Next, you can use them inside your application:'), 33 | m(CodeBlock, { 34 | code: `import 'materialize-css/dist/css/materialize.min.css'; 35 | import 'material-icons/iconfont/material-icons.css'; 36 | import { TextArea } from 'mithril-materialized'; 37 | `, 38 | }), 39 | ]) 40 | ), 41 | m('.col.s12.m5.l4', [ 42 | m('h1', 'Contents'), 43 | m('ul.collection', [ 44 | dashboardSvc 45 | .getList() 46 | .filter((d) => d.visible && !d.default) 47 | .map((d) => m('li.collection-item', m('a', { href: `#!${d.route}` }, d.title))), 48 | ]), 49 | ]), 50 | ]), 51 | }); 52 | -------------------------------------------------------------------------------- /packages/example/src/components/layout.ts: -------------------------------------------------------------------------------- 1 | import m, { Vnode } from 'mithril'; 2 | import { dashboardSvc } from '../services/dashboard-service'; 3 | 4 | const isActive = (path: string) => (m.route.get().indexOf(path) >= 0 ? '.active' : ''); 5 | 6 | export const Layout = () => ({ 7 | view: (vnode: Vnode) => 8 | m('.main', [ 9 | m( 10 | 'nav', 11 | m('.nav-wrapper', [ 12 | m( 13 | // tslint:disable-next-line:max-line-length 14 | 'a.github-corner[aria-label=View source on GitHub][href=https://github.com/erikvullings/mithril-materialized]', 15 | m( 16 | 'svg[aria-hidden=true][height=80][viewBox=0 0 250 250][width=80]', 17 | { 18 | style: { 19 | fill: 'black', 20 | color: '#fff', 21 | position: 'absolute', 22 | top: '0', 23 | border: '0', 24 | left: '0', 25 | transform: 'scale(-1, 1)', 26 | }, 27 | }, 28 | [ 29 | m('path[d=M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z]'), 30 | m( 31 | // tslint:disable-next-line:max-line-length 32 | 'path.octo-arm[d=M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2][fill=currentColor]', 33 | { style: { 'transform-origin': '130px 106px' } } 34 | ), 35 | m( 36 | // tslint:disable-next-line:max-line-length 37 | 'path.octo-body[d=M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z][fill=currentColor]' 38 | ), 39 | ] 40 | ) 41 | ), 42 | m( 43 | 'style', 44 | // tslint:disable-next-line:max-line-length 45 | '.github-corner:hover .octo-arm{animation:octocat-wave 560ms ease-in-out}@keyframes octocat-wave{0%,100%{transform:rotate(0)}20%,60%{transform:rotate(-25deg)}40%,80%{transform:rotate(10deg)}}@media (max-width:500px){.github-corner:hover .octo-arm{animation:none}.github-corner .octo-arm{animation:octocat-wave 560ms ease-in-out}}' 46 | ), 47 | m( 48 | 'ul.right', 49 | dashboardSvc 50 | .getList() 51 | .filter(d => d.visible) 52 | .map(d => 53 | m( 54 | `li${isActive(d.route)}`, 55 | m( 56 | m.route.Link, 57 | { href: d.route }, 58 | m('i.material-icons.right', d.icon ? m('i.material-icons', d.icon) : d.title) 59 | ) 60 | ) 61 | ) 62 | ), 63 | ]) 64 | ), 65 | m('.container', m('.row', vnode.children)), 66 | ]), 67 | }); 68 | -------------------------------------------------------------------------------- /packages/example/src/components/map-editor/map-editor-page.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlock, MapEditor } from 'mithril-materialized'; 2 | import m from 'mithril'; 3 | 4 | export const MapEditorPage = () => { 5 | const state = { 6 | properties: { 7 | stringArray: ['a', 'b', 'c'], 8 | numberArray: [1, 2, 3], 9 | aNumber: 42, 10 | aString: 'Hello world', 11 | truthy: true, 12 | falsy: false, 13 | }, 14 | }; 15 | 16 | return { 17 | view: () => 18 | m('.col.s12', [ 19 | m('h2.header', 'Key-value pairs editor'), 20 | m('p', [ 21 | 'As materializecss.com did not offer a useful editor for a map of key-value pairs, ', 22 | 'I have created one myself. It allows you to edit (or just view, when it is disabled), ', 23 | 'booleans, numbers, strings and arrays of numbers and strings.', 24 | ]), 25 | 26 | // m(EditableTable, { 27 | // headers: ['title', 'description', 'priority'], 28 | // cells, 29 | // addRows: true, 30 | // }), 31 | 32 | m('h3.header', 'MapEditor'), 33 | m( 34 | '.row', 35 | m(MapEditor, { 36 | label: 'Properties', 37 | isMandatory: true, 38 | properties: state.properties, 39 | onchange: (props: { [key: string]: number | string | boolean | Array }) => 40 | console.table(props), 41 | labelKey: 'Unique key', // Override the default label for keys i.e. 'key' 42 | labelValue: 'My value', // Overrule the default label for values i.e. 'key' 43 | disable: false, // If true, the map cannot be edited 44 | disallowArrays: false, // If true, do not convert [1, 2, 3] to a number[] 45 | keyValueConverter: undefined, // Allows you to overrule the view of each key-value pair 46 | iconName: 'dns', 47 | keyClass: '.col.s4', // Optionally override the default key element 48 | valueClass: '.col.s8', // Optionally override the default value element 49 | truthy: ['true', 'yes', 'ja', 'oui', 'si', 'da'], 50 | falsy: ['false', 'no', 'nee', 'nein', 'non', 'nu', 'njet'], 51 | }) 52 | ), 53 | m(CodeBlock, { 54 | code: ` m(MapEditor, { 55 | label: 'Properties', 56 | isMandatory: true, 57 | properties: state.properties, 58 | onchange: (props: any) => console.table(props), 59 | labelKey: 'Unique key', // Override the default label for keys i.e. 'key' 60 | labelValue: 'My value', // Overrule the default label for values i.e. 'key' 61 | disable: false, // If true, the map cannot be edited 62 | disallowArrays: false, // If true, do not convert [1, 2, 3] to a number[] 63 | keyValueConverter: undefined, // Allows you to overrule the view of each key-value pair 64 | iconName: 'dns', 65 | keyClass: '.col.s4', // Optionally override the default key element 66 | valueClass: '.col.s8', // Optionally override the default value element 67 | truthy: ['true', 'yes', 'ja', 'oui', 'si', 'da'], // Any truthy value generates a boolean 68 | falsy: ['false', 'no', 'nee', 'nein', 'non', 'nu', 'njet'], 69 | })`, 70 | }), 71 | ]), 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /packages/example/src/components/misc/misc-page.ts: -------------------------------------------------------------------------------- 1 | import { MaterialBox, CodeBlock, Carousel, Parallax, Pagination, Tabs, Button } from 'mithril-materialized'; 2 | import m from 'mithril'; 3 | import gogh from '../../assets/Vincent_van_Gogh_-_Landscape_at_twilight_-_Google_Art_Project.jpg'; 4 | 5 | export const MiscPage = () => { 6 | const state = { 7 | activeTabId: '', 8 | disabled: true, 9 | activeTab: 3, 10 | tabWidthId: 2, 11 | tabWidths: ['auto', 'fixed', 'fill'] as Array<'auto' | 'fixed' | 'fill'>, 12 | }; 13 | const curPage = () => (m.route.param('page') ? +m.route.param('page') : 1); 14 | 15 | return { 16 | view: () => 17 | m('.col.s12', [ 18 | m('h2.header', 'Miscellaneous'), 19 | m('p', [ 20 | 'Some miscellaneous components, like the ', 21 | m('a[href=https://materializecss.com/tabs.html][target=_blank]', 'Tabs'), 22 | ', ', 23 | m('a[href=https://materializecss.com/media.html][target=_blank]', 'Material box'), 24 | ', ', 25 | m('a[href=https://materializecss.com/collection.html][target=_blank]', 'Collection'), 26 | ', ', 27 | m('a[href=https://materializecss.com/collapsible.html][target=_blank]', 'Collapsible'), 28 | ', ', 29 | m('a[href=https://materializecss.com/carousel.html][target=_blank]', 'Carousel'), 30 | ', ', 31 | m('a[href=https://materializecss.com/parallax.html][target=_blank]', 'Pagination'), 32 | ' and the ', 33 | m('a[href=https://materializecss.com/pagination.html][target=_blank]', 'Parallax'), 34 | '.', 35 | ]), 36 | 37 | m('h3.header', 'Tabs'), 38 | m(Tabs, { 39 | selectedTabId: state.activeTabId, 40 | tabWidth: state.tabWidths[state.tabWidthId % 3], 41 | onShow: console.log, 42 | tabs: [ 43 | { 44 | title: 'Test 1', 45 | active: state.activeTab === 1, 46 | vnode: m('', 'Show content of tab 1'), 47 | }, 48 | { 49 | title: 'Test 2', 50 | disabled: state.disabled, 51 | active: state.activeTab === 2, 52 | vnode: m('', 'Show content of tab 2'), 53 | }, 54 | { 55 | title: 'Test 3', 56 | active: state.activeTab === 3, 57 | vnode: m('', 'Show content of tab 3'), 58 | }, 59 | { 60 | title: 'Test 4', 61 | active: state.activeTab === 4, 62 | vnode: m('', 'Show content of tab 4'), 63 | }, 64 | { 65 | title: 'Visit Google', 66 | target: '_blank', 67 | href: 'http://www.google.com', 68 | // vnode: m('', 'Nothing to show'), 69 | }, 70 | ], 71 | }), 72 | m(Button, { 73 | label: 'Switch to tab 1', 74 | onclick: () => { 75 | state.activeTab = 1; 76 | state.activeTabId = ''; 77 | }, 78 | }), 79 | m(Button, { 80 | label: 'Switch to tab 4', 81 | onclick: () => { 82 | state.activeTab = 0; 83 | state.activeTabId = 'test4'; 84 | }, 85 | }), 86 | m(Button, { 87 | label: `${state.disabled ? 'Enable' : 'Disable'} tab 2`, 88 | onclick: () => { 89 | state.disabled = !state.disabled; 90 | }, 91 | }), 92 | m(Button, { 93 | label: `Switch tab width from ${state.tabWidths[state.tabWidthId % 3]} to ${ 94 | state.tabWidths[(state.tabWidthId + 1) % 3] 95 | }`, 96 | onclick: () => state.tabWidthId++, 97 | }), 98 | m(CodeBlock, { 99 | code: ` m(Tabs, { 100 | onShow: console.log, 101 | tabs: [ 102 | { 103 | title: 'Test 1', 104 | vnode: m('', 'Show content of tab 1'), 105 | }, 106 | { 107 | title: 'Test 2', 108 | disabled: true, 109 | vnode: m('', 'Show content of tab 2'), 110 | }, 111 | { 112 | title: 'Test 3', 113 | active: true, 114 | vnode: m('', 'Show content of tab 3'), 115 | }, 116 | { 117 | title: 'Test 4', 118 | vnode: m('', 'Show content of tab 4'), 119 | }, 120 | { 121 | title: 'Visit Google', 122 | target: '_blank', 123 | href: 'http://www.google.com', 124 | }, 125 | ], 126 | })`, 127 | }), 128 | 129 | m('h3.header', 'Parallax'), 130 | m(Parallax, { src: gogh }), 131 | m(CodeBlock, { 132 | code: ` m(Parallax, { src: gogh }) // should be embedded in layout so the width is not limited`, 133 | }), 134 | 135 | m('h3.header', 'Material box (click on image)'), 136 | m('.row', m(MaterialBox, { src: gogh, width: 600 })), 137 | m(CodeBlock, { 138 | code: ` m(MaterialBox, { src: gogh, width: 600 })`, 139 | }), 140 | 141 | m('h3.header', 'Carousel'), 142 | m( 143 | '.row', 144 | m(Carousel, { 145 | items: [ 146 | { href: '#!/one!', src: 'https://picsum.photos/id/301/200/300' }, 147 | { href: '#!/two!', src: 'https://picsum.photos/id/302/200/300' }, 148 | { href: '#!/three!', src: 'https://picsum.photos/id/306/200/300' }, 149 | { href: '#!/four!', src: 'https://picsum.photos/id/304/200/300' }, 150 | { href: '#!/five!', src: 'https://picsum.photos/id/305/200/300' }, 151 | ], 152 | }) 153 | ), 154 | m(CodeBlock, { 155 | code: ` m(Carousel, { items: [ 156 | { href: '#!/one!', src: 'https://picsum.photos/id/301/200/300' }, 157 | { href: '#!/two!', src: 'https://picsum.photos/id/302/200/300' }, 158 | { href: '#!/three!', src: 'https://picsum.photos/id/306/200/300' }, 159 | { href: '#!/four!', src: 'https://picsum.photos/id/304/200/300' }, 160 | { href: '#!/five!', src: 'https://picsum.photos/id/305/200/300' }, 161 | ] })`, 162 | }), 163 | 164 | m('h3.header', 'Pagination'), 165 | m( 166 | '.row', 167 | m(Pagination, { 168 | size: 5, 169 | curPage: curPage(), 170 | items: [ 171 | { href: '/misc?page=1' }, 172 | { href: '/misc?page=2' }, 173 | { href: '/misc?page=3' }, 174 | { href: '/misc?page=4' }, 175 | { href: '/misc?page=5' }, 176 | { href: '/misc?page=6' }, 177 | { href: '/misc?page=7' }, 178 | { href: '/misc?page=8' }, 179 | { href: '/misc?page=9' }, 180 | { href: '/misc?page=10' }, 181 | { href: '/misc?page=11' }, 182 | { href: '/misc?page=12' }, 183 | ], 184 | }) 185 | ), 186 | m(CodeBlock, { 187 | code: `m(Pagination, { 188 | size: 5, 189 | items: [ 190 | { href: '/misc?page=1' }, 191 | { href: '/misc?page=2' }, 192 | { href: '/misc?page=3' }, 193 | { href: '/misc?page=4' }, 194 | { href: '/misc?page=5' }, 195 | { href: '/misc?page=6' }, 196 | { href: '/misc?page=7' }, 197 | { href: '/misc?page=8' }, 198 | { href: '/misc?page=9' }, 199 | { href: '/misc?page=10' }, 200 | { href: '/misc?page=11' }, 201 | { href: '/misc?page=12' }, 202 | ], 203 | })`, 204 | }), 205 | ]), 206 | }; 207 | }; 208 | -------------------------------------------------------------------------------- /packages/example/src/components/modals/modal-page.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { 3 | ModalPanel, 4 | CodeBlock, 5 | Button, 6 | MaterialBox, 7 | Select, 8 | ISelectOptions, 9 | Dropdown, 10 | IDropdownOptions, 11 | } from 'mithril-materialized'; 12 | import gogh from '../../assets/Vincent_van_Gogh_-_Landscape_at_twilight_-_Google_Art_Project.jpg'; 13 | 14 | export const ModalPage = () => { 15 | const onchange = (v: unknown) => alert(v); 16 | return { 17 | view: () => 18 | m('.col.s12', [ 19 | m('h2.header', 'Modals'), 20 | m('p', [ 21 | 'The library supports all three modals types that are defined on the ', 22 | m('a[href=https://materializecss.com/modals.html#!][target=_blank]', 'materialize-css website'), 23 | '.', 24 | ]), 25 | 26 | m('h3.header', 'Normal Modal'), 27 | m( 28 | '.row', 29 | m(Button, { label: 'Open modal', modalId: 'modal1' }), 30 | m(ModalPanel, { 31 | id: 'modal1', 32 | title: 'Do you like this library?', 33 | description: 'This is some content.', 34 | options: { opacity: 0.7 }, 35 | buttons: [ 36 | { 37 | label: 'Disagree', 38 | onclick: () => onchange('You make me sad...'), 39 | }, 40 | { 41 | label: 'Agree', 42 | onclick: () => onchange('Thank you for the compliment!'), 43 | }, 44 | ], 45 | }) 46 | ), 47 | m(CodeBlock, { 48 | code: ` m(Button, { label: 'Open modal', modalId: 'modal1' }), 49 | m(ModalPanel, { 50 | id: 'modal1', 51 | title: 'Do you like this library?', 52 | description: 'This is some content.', 53 | options: { opacity: 0.7 }, 54 | buttons: [ 55 | { 56 | label: 'Disagree', 57 | onclick: () => onchange('You make me sad...'), 58 | }, 59 | { 60 | label: 'Agree', 61 | onclick: () => onchange('Thank you for the compliment!'), 62 | }, 63 | ], 64 | })`, 65 | }), 66 | 67 | m('h3.header', 'Normal Modal with Select and Dropdown'), 68 | m( 69 | '.row', 70 | m(Button, { label: 'Open modal', modalId: 'modal1b' }), 71 | m(ModalPanel, { 72 | id: 'modal1b', 73 | title: 'Tell me about yourself', 74 | description: m( 75 | '.row', // So the content has enough vertical space 76 | [ 77 | m(Select, { 78 | dropdownOptions: { container: document.body }, // So the select is not hidden 79 | iconName: 'person', 80 | label: 'What is your favorite hobby?', 81 | placeholder: 'Pick one', 82 | isMandatory: true, 83 | options: [ 84 | { label: 'Pick one', disabled: true }, 85 | { id: 'movies', label: 'Watching movies' }, 86 | { id: 'out', label: 'Going out' }, 87 | { id: 'sex', label: 'Having sex' }, 88 | { id: 'fitness', label: 'Fitness' }, 89 | { id: 'sleep', label: 'Sleeping' }, 90 | ], 91 | onchange: (v) => console.log(v), 92 | } as ISelectOptions), 93 | m(Dropdown, { 94 | container: document.body, // So the dropdown is not hidden 95 | id: 'hobby', 96 | iconName: 'my_location', 97 | label: 'Pick a hobby', 98 | helperText: 'Help me', 99 | className: 'col s6', 100 | items: [ 101 | { label: 'Movies', id: 'movies', iconName: 'local_movies' }, 102 | { label: 'Reading', id: 'reading', iconName: 'import_contacts' }, 103 | { label: 'Eating', id: 'eating', iconName: 'restaurant' }, 104 | { label: '', divider: true }, 105 | { label: 'Sex', id: 'sex', iconName: 'group' }, 106 | ], 107 | onchange: (v) => console.log(v), 108 | } as IDropdownOptions), 109 | ] 110 | ), 111 | 112 | options: { opacity: 0.7 }, 113 | buttons: [ 114 | { 115 | label: 'Disagree', 116 | }, 117 | { 118 | label: 'Agree', 119 | }, 120 | ], 121 | }) 122 | ), 123 | m(CodeBlock, { 124 | code: ` m(Button, { label: 'Open modal', modalId: 'modal1b' }), 125 | m(ModalPanel, { 126 | id: 'modal1b', 127 | title: 'Tell me about yourself', 128 | description: m( 129 | '.row', // So the content has enough vertical space 130 | [ 131 | m(Select, { 132 | dropdownOptions: { container: document.body }, // So the select is not hidden 133 | iconName: 'person', 134 | label: 'What is your favorite hobby?', 135 | placeholder: 'Pick one', 136 | isMandatory: true, 137 | options: [ 138 | { label: 'Pick one', disabled: true }, 139 | { id: 'movies', label: 'Watching movies' }, 140 | { id: 'out', label: 'Going out' }, 141 | { id: 'sex', label: 'Having sex' }, 142 | { id: 'fitness', label: 'Fitness' }, 143 | { id: 'sleep', label: 'Sleeping' }, 144 | ], 145 | onchange: v => console.log(v), 146 | } as ISelectOptions), 147 | m(Dropdown, { 148 | container: document.body, // So the dropdown is not hidden 149 | id: 'hobby', 150 | iconName: 'my_location', 151 | label: 'Pick a hobby', 152 | helperText: 'Help me', 153 | className: 'col s6', 154 | items: [ 155 | { label: 'Movies', id: 'movies', iconName: 'local_movies' }, 156 | { label: 'Reading', id: 'reading', iconName: 'import_contacts' }, 157 | { label: 'Eating', id: 'eating', iconName: 'restaurant' }, 158 | { label: '', divider: true }, 159 | { label: 'Sex', id: 'sex', iconName: 'group' }, 160 | ], 161 | onchange: v => console.log(v), 162 | } as IDropdownOptions), 163 | ] 164 | ), 165 | options: { opacity: 0.7 }, 166 | buttons: [ 167 | { 168 | label: 'Disagree', 169 | }, 170 | { 171 | label: 'Agree', 172 | }, 173 | ], 174 | })`, 175 | }), 176 | 177 | m('h3.header', 'Fixed Footer Modal'), 178 | m( 179 | '.row', 180 | m(Button, { label: 'Fixed footer modal', modalId: 'modal2' }), 181 | m(ModalPanel, { 182 | id: 'modal2', 183 | title: 'Do you like this library?', 184 | fixedFooter: true, 185 | richContent: true, 186 | description: `This is some content.

187 | This is some content.
188 | This is some content.
189 | This is some content.
190 | This is some content.
191 | This is some content.
192 | This is some content.
193 | This is some content.
194 | This is some content.
195 | This is some content.
196 | This is some content.
197 | This is some content.
198 | This is some content.
199 | This is some content.
200 | This is some content.
201 | This is some content.
202 | This is some content.
203 | This is some content.
204 | This is some content.
205 | This is some content.
206 | This is some content.
207 | This is some content.
208 | This is some content.
209 | This is some content.
210 | This is some content.
211 | This is some content.
212 | This is some content.
213 | This is some content.
214 | This is some content.
215 | This is some content.
216 | This is some content.
217 | `, 218 | buttons: [ 219 | { 220 | label: 'Disagree', 221 | onclick: () => onchange('You make me sad...'), 222 | }, 223 | { 224 | label: 'Agree', 225 | onclick: () => onchange('Thank you for the compliment!'), 226 | }, 227 | ], 228 | }) 229 | ), 230 | m(CodeBlock, { 231 | code: ` m(Button, { label: 'Bottom modal', modalId: 'modal3' }), 232 | m(ModalPanel, { 233 | id: 'modal3', 234 | title: 'Do you like this library?', 235 | description: 'This is some content. ... and much more', 236 | fixedFooter: true, 237 | richContent: true, // If richContent is true, it means that the description may contain HTML. 238 | buttons: [ 239 | { 240 | label: 'Disagree', 241 | onclick: () => onchange('You make me sad...'), 242 | }, 243 | { 244 | label: 'Agree', 245 | onclick: () => onchange('Thank you for the compliment!'), 246 | }, 247 | ], 248 | })`, 249 | }), 250 | 251 | m('h3.header', 'Bottom Modal'), 252 | m( 253 | '.row', 254 | m(Button, { label: 'Open bottom modal', modalId: 'modal3' }), 255 | m(ModalPanel, { 256 | id: 'modal3', 257 | title: 'Do you like this library?', 258 | description: 'This is some content.', 259 | bottomSheet: true, 260 | buttons: [ 261 | { 262 | label: 'Disagree', 263 | onclick: () => onchange('You make me sad...'), 264 | }, 265 | { 266 | label: 'Agree', 267 | onclick: () => onchange('Thank you for the compliment!'), 268 | }, 269 | ], 270 | }) 271 | ), 272 | m(CodeBlock, { 273 | code: ` m(Button, { label: 'Open modal', modalId: 'modal1' }), 274 | m(ModalPanel, { 275 | id: 'modal1', 276 | title: 'Do you like this library?', 277 | description: 'This is some content.', 278 | bottomSheet: true, 279 | buttons: [ 280 | { 281 | label: 'Disagree', 282 | onclick: () => onchange('You make me sad...'), 283 | }, 284 | { 285 | label: 'Agree', 286 | onclick: () => onchange('Thank you for the compliment!'), 287 | }, 288 | ], 289 | })`, 290 | }), 291 | 292 | m('h3.header', 'Vnode as content'), 293 | m( 294 | '.row', 295 | m(Button, { label: 'Open bottom content modal', modalId: 'modal4' }), 296 | m(ModalPanel, { 297 | id: 'modal4', 298 | title: 'Content modal', 299 | description: m(MaterialBox, { src: gogh, width: 400 }), 300 | bottomSheet: true, 301 | }) 302 | ), 303 | m(CodeBlock, { 304 | code: ` m(Button, { label: 'Open bottom content modal', modalId: 'modal4' }), 305 | m(ModalPanel, { 306 | id: 'modal4', 307 | title: 'Content modal', 308 | description: m(MaterialBox, { src: gogh, width: 400 }), 309 | bottomSheet: true, 310 | })`, 311 | }), 312 | ]), 313 | }; 314 | }; 315 | -------------------------------------------------------------------------------- /packages/example/src/components/pickers/picker-page.ts: -------------------------------------------------------------------------------- 1 | import { DatePicker, TimePicker, CodeBlock, Switch } from 'mithril-materialized'; 2 | import m from 'mithril'; 3 | 4 | export const PickerPage = () => { 5 | const state = { disabled: false }; 6 | 7 | const onchange = (v: unknown) => alert(`Input changed. New value: ${v}`); 8 | return { 9 | view: () => 10 | m('.col.s12', [ 11 | m('h2.header', 'Pickers'), 12 | m( 13 | '.row', 14 | m(Switch, { 15 | label: 'Disable pickers', 16 | left: 'enable', 17 | right: 'disable', 18 | onchange: (v) => (state.disabled = v), 19 | }) 20 | ), 21 | m('h3.header', 'DatePicker'), 22 | m( 23 | '.row', 24 | m(DatePicker, { 25 | disabled: state.disabled, 26 | format: 'mmmm d, yyyy', 27 | label: 'What is your birthday?', 28 | yearRange: [1970, new Date().getFullYear() + 20], 29 | initialValue: new Date(), 30 | onchange, 31 | }) 32 | ), 33 | m(CodeBlock, { 34 | code: ` m(DatePicker, { 35 | format: 'mmmm d, yyyy', 36 | label: 'What is your birthday?', 37 | yearRange: [1970, new Date().getFullYear() + 20], 38 | initialValue: new Date().toDateString(), 39 | onchange, 40 | })`, 41 | }), 42 | 43 | m('h3.header', 'TimePicker'), 44 | m( 45 | '.row', 46 | m(TimePicker, { 47 | disabled: state.disabled, 48 | label: 'When do you normally get up?', 49 | twelveHour: false, 50 | initialValue: '09:00', 51 | onchange, 52 | }) 53 | ), 54 | m(CodeBlock, { 55 | code: ` m(TimePicker, { 56 | label: 'What is your birthday?', 57 | twelveHour: false, 58 | initialValue: '09:00', 59 | onchange, 60 | })`, 61 | }), 62 | ]), 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /packages/example/src/components/timeline/timeline-page.ts: -------------------------------------------------------------------------------- 1 | import { CodeBlock, Timeline, padLeft, Collection, Icon } from 'mithril-materialized'; 2 | import m from 'mithril'; 3 | 4 | export const TimelinePage = () => { 5 | const timeFormatter = (d: Date) => 6 | `${padLeft(d.getHours())}:${padLeft(d.getMinutes())}:${padLeft(d.getSeconds())}`; 7 | 8 | return { 9 | view: () => 10 | m('.col.s12', [ 11 | m('h2.header', 'Timeline'), 12 | m('p', [ 13 | 'A simple timeline component based on ', 14 | m( 15 | 'a[href=https://tympanus.net/codrops/2013/05/02/vertical-timeline/][target=_blank]', 16 | `Codrops\' Vertical Timeline` 17 | ), 18 | '.', 19 | ]), 20 | 21 | m('h3.header', 'Timeline'), 22 | m(Timeline, { 23 | onSelect: (item: unknown) => console.table(item), 24 | timeFormatter, 25 | items: [ 26 | { 27 | id: '1', 28 | title: 'Test a string', 29 | iconName: 'play_arrow', 30 | datetime: new Date(2019, 2, 3, 9, 0, 0), 31 | content: 'Hello world', 32 | }, 33 | { 34 | id: '2', 35 | title: 'Test a long text', 36 | iconName: 'play_arrow', 37 | datetime: new Date(2019, 2, 3, 9, 30, 0), 38 | content: `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec luctus maximus erat, 39 | vitae placerat nisl blandit tincidunt. Vestibulum libero turpis, bibendum sit amet rutrum a, 40 | malesuada at diam. Praesent id dignissim ligula. Donec nec finibus lectus. Curabitur in 41 | sollicitudin sem. Nulla neque est, elementum et lectus ut, luctus elementum metus.`, 42 | }, 43 | { 44 | id: '3', 45 | title: 'Test an active item', 46 | iconName: 'play_arrow', 47 | datetime: new Date(2019, 2, 3, 9, 45, 0), 48 | content: 'Hello world', 49 | active: true, 50 | }, 51 | { 52 | id: '4', 53 | title: 'Test Vnode content', 54 | iconName: 'play_arrow', 55 | datetime: new Date(2019, 2, 3, 10, 5, 0), 56 | content: m(Collection, { 57 | style: 'color: black;', 58 | items: [ 59 | { title: 'John', iconName: 'send' }, 60 | { title: 'Mary', iconName: 'send' }, 61 | { title: 'Pete', iconName: 'send' }, 62 | ], 63 | }), 64 | }, 65 | { 66 | id: '5', 67 | title: 'Test other icon', 68 | iconName: 'visibility', 69 | datetime: new Date(2019, 2, 3, 10, 11, 0), 70 | content: 'Hello world', 71 | }, 72 | { 73 | id: '6', 74 | iconName: 'visibility_off', 75 | datetime: new Date(2019, 2, 3, 10, 15, 0), 76 | content: 'No title, only content', 77 | }, 78 | { 79 | id: '7', 80 | title: m('i', [ 81 | 'Test a Vnode', 82 | m(Icon, { className: 'small', style: 'float: right;', iconName: 'directions_run' }), 83 | ]), 84 | iconName: 'visibility', 85 | datetime: new Date(2019, 2, 3, 10, 21, 0), 86 | }, 87 | ], 88 | }), 89 | m(CodeBlock, { 90 | code: ` m(Timeline, { 91 | onSelect: (item: ITimelineItem) => console.table(item), 92 | timeFormatter, // Adds seconds to time format 93 | items: [ 94 | { 95 | title: 'Test a string', 96 | iconName: 'play_arrow', 97 | datetime: new Date(2019, 2, 3, 9, 0, 0), 98 | content: 'Hello world', 99 | }, 100 | { 101 | title: 'Test a long text', 102 | iconName: 'play_arrow', 103 | datetime: new Date(2019, 2, 3, 9, 30, 0), 104 | content: 'Lorem ipsum ...', 105 | }, 106 | { 107 | title: 'Test an active item', 108 | iconName: 'play_arrow', 109 | datetime: new Date(2019, 2, 3, 9, 45, 0), 110 | content: 'Hello world', 111 | active: true, 112 | }, 113 | { 114 | title: 'Test Vnode content', 115 | iconName: 'play_arrow', 116 | datetime: new Date(2019, 2, 3, 10, 5, 0), 117 | content: m(Collection, { 118 | style: 'color: black;', // otherwise the titles are in white 119 | items: [ 120 | { title: 'John', iconName: 'send' }, 121 | { title: 'Mary', iconName: 'send' }, 122 | { title: 'Pete', iconName: 'send' }, 123 | ], 124 | }), 125 | }, 126 | { 127 | title: 'Test other icon', 128 | iconName: 'visibility', 129 | datetime: new Date(2019, 2, 3, 10, 11, 0), 130 | content: 'Hello world', 131 | }, 132 | { 133 | id: '6', 134 | iconName: 'visibility_off', 135 | datetime: new Date(2019, 2, 3, 10, 15, 0), 136 | content: 'No title, only content', 137 | }, 138 | { 139 | id: '7', 140 | title: m('i', [ 141 | 'Test a Vnode', 142 | m(Icon, { className: 'small', style: 'float: right;', iconName: 'directions_run' }), 143 | ]), 144 | iconName: 'visibility', 145 | datetime: new Date(2019, 2, 3, 10, 21, 0), 146 | }, 147 | ], 148 | })`, 149 | }), 150 | ]), 151 | }; 152 | }; 153 | -------------------------------------------------------------------------------- /packages/example/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erikvullings/mithril-materialized/f08c3fa6784bc8f2042df97570ef525e11f68a54/packages/example/src/favicon.ico -------------------------------------------------------------------------------- /packages/example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mithril-Materialized 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/example/src/models/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { ComponentTypes } from 'mithril'; 2 | 3 | export interface IDashboard { 4 | id: string; 5 | default?: boolean; 6 | title: string; 7 | icon?: string; 8 | route: string; 9 | visible: boolean; 10 | component: ComponentTypes; 11 | } 12 | -------------------------------------------------------------------------------- /packages/example/src/services/dashboard-service.ts: -------------------------------------------------------------------------------- 1 | import m, { ComponentTypes, RouteDefs } from 'mithril'; 2 | import { IDashboard } from '../models/dashboard'; 3 | import { Layout } from '../components/layout'; 4 | import { HomePage } from '../components/home/home-page'; 5 | import { AboutPage } from '../components/about/about-page'; 6 | import { ButtonPage } from '../components/buttons/button-page'; 7 | import { InputPage } from '../components/inputs/input-page'; 8 | import { PickerPage } from '../components/pickers/picker-page'; 9 | import { SelectionPage } from '../components/selections/selection-page'; 10 | import { ModalPage } from '../components/modals/modal-page'; 11 | import { MiscPage } from '../components/misc/misc-page'; 12 | import { CollectionsPage } from '../components/collections/collections-page'; 13 | import { MapEditorPage } from './../components/map-editor/map-editor-page'; 14 | import { TimelinePage } from '../components/timeline/timeline-page'; 15 | 16 | export enum Dashboards { 17 | HOME = 'HOME', 18 | BUTTONS = 'BUTTONS', 19 | INPUTS = 'INPUTS', 20 | PICKERS = 'PICKERS', 21 | SELECTIONS = 'SELECTIONS', 22 | COLLECTIONS = 'COLLECTIONS', 23 | MAP_EDITOR = 'MAP_EDITOR', 24 | MODALS = 'MODALS', 25 | TIMELINE = 'TIMELINE', 26 | KANBAN = 'KANBAN', 27 | MISC = 'MISC', 28 | ABOUT = 'ABOUT', 29 | } 30 | 31 | class DashboardService { 32 | private dashboards!: ReadonlyArray; 33 | 34 | constructor(private layout: ComponentTypes, dashboards: IDashboard[]) { 35 | this.setList(dashboards); 36 | } 37 | 38 | public getList() { 39 | return this.dashboards; 40 | } 41 | 42 | public setList(list: IDashboard[]) { 43 | this.dashboards = Object.freeze(list); 44 | } 45 | 46 | public get defaultRoute() { 47 | const dashboard = this.dashboards.filter((d) => d.default).shift(); 48 | return dashboard ? dashboard.route : this.dashboards[0].route; 49 | } 50 | 51 | public switchTo(dashboardId: Dashboards, fragment = '') { 52 | const dashboard = this.dashboards.filter((d) => d.id === dashboardId).shift(); 53 | if (dashboard) { 54 | m.route.set(dashboard.route); 55 | } 56 | } 57 | 58 | public get routingTable() { 59 | return this.dashboards.reduce((p, c) => { 60 | p[c.route] = { render: () => m(this.layout, m(c.component)) }; 61 | return p; 62 | }, {} as RouteDefs); 63 | } 64 | } 65 | 66 | export const dashboardSvc: DashboardService = new DashboardService(Layout, [ 67 | { 68 | id: Dashboards.HOME, 69 | default: true, 70 | title: 'HOME', 71 | icon: 'home', 72 | route: '/home', 73 | visible: true, 74 | component: HomePage, 75 | }, 76 | { 77 | id: Dashboards.BUTTONS, 78 | title: 'BUTTONS', 79 | icon: 'crop_16_9', 80 | route: '/buttons', 81 | visible: true, 82 | component: ButtonPage, 83 | }, 84 | { 85 | id: Dashboards.INPUTS, 86 | title: 'INPUTS', 87 | icon: 'create', 88 | route: '/inputs', 89 | visible: true, 90 | component: InputPage, 91 | }, 92 | { 93 | id: Dashboards.PICKERS, 94 | title: 'PICKERS', 95 | icon: 'access_time', 96 | route: '/pickers', 97 | visible: true, 98 | component: PickerPage, 99 | }, 100 | { 101 | id: Dashboards.SELECTIONS, 102 | title: 'SELECTIONS', 103 | icon: 'check', 104 | route: '/selections', 105 | visible: true, 106 | component: SelectionPage, 107 | }, 108 | { 109 | id: Dashboards.MODALS, 110 | title: 'MODALS', 111 | icon: 'all_out', 112 | route: '/modals', 113 | visible: true, 114 | component: ModalPage, 115 | }, 116 | { 117 | id: Dashboards.COLLECTIONS, 118 | title: 'COLLECTIONS', 119 | icon: 'collections', 120 | route: '/collections', 121 | visible: true, 122 | component: CollectionsPage, 123 | }, 124 | { 125 | id: Dashboards.MAP_EDITOR, 126 | title: 'MAP-EDITOR', 127 | icon: 'playlist_add', 128 | route: '/map_editor', 129 | visible: true, 130 | component: MapEditorPage, 131 | }, 132 | { 133 | id: Dashboards.TIMELINE, 134 | title: 'TIMELINE', 135 | icon: 'timeline', 136 | route: '/timeline', 137 | visible: true, 138 | component: TimelinePage, 139 | }, 140 | { 141 | id: Dashboards.MISC, 142 | title: 'MISCELLANEOUS', 143 | icon: 'image', 144 | route: '/misc', 145 | visible: true, 146 | component: MiscPage, 147 | }, 148 | { 149 | id: Dashboards.ABOUT, 150 | title: 'ABOUT', 151 | icon: 'info', 152 | route: '/about', 153 | visible: true, 154 | component: AboutPage, 155 | }, 156 | ]); 157 | -------------------------------------------------------------------------------- /packages/example/src/utils/jpg.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.jpg" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/example/src/utils/json.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.json" { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/example/src/utils/png.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const content: any; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/example/src/utils/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.svg" { 2 | const content: string; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /packages/example/src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a GUID 3 | * @see https://stackoverflow.com/a/2117523/319711 4 | * 5 | * @returns RFC4122 version 4 compliant GUID 6 | */ 7 | export const uuid4 = () => { 8 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 9 | // tslint:disable-next-line:no-bitwise 10 | const r = (Math.random() * 16) | 0; 11 | // tslint:disable-next-line:no-bitwise 12 | const v = c === 'x' ? r : (r & 0x3) | 0x8; 13 | return v.toString(16); 14 | }); 15 | }; 16 | 17 | /** 18 | * Create a unique ID 19 | * @see https://stackoverflow.com/a/2117523/319711 20 | * 21 | * @returns RFC4122 version 4 compliant GUID 22 | */ 23 | export const uniqueId = () => { 24 | return 'idxxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 25 | // tslint:disable-next-line:no-bitwise 26 | const r = (Math.random() * 16) | 0; 27 | // tslint:disable-next-line:no-bitwise 28 | const v = c === 'x' ? r : (r & 0x3) | 0x8; 29 | return v.toString(16); 30 | }); 31 | }; 32 | 33 | export const toLetters = (num: number): string => { 34 | const mod = num % 26; 35 | // tslint:disable-next-line:no-bitwise 36 | let pow = (num / 26) | 0; 37 | const out = mod ? String.fromCharCode(64 + mod) : (--pow, 'Z'); 38 | return pow ? toLetters(pow) + out : out; 39 | }; 40 | 41 | /** 42 | * Generate a sequence of numbers between from and to with step size: [from, to]. 43 | * 44 | * @static 45 | * @param {number} from 46 | * @param {number} to : inclusive 47 | * @param {number} [count=to-from+1] 48 | * @param {number} [step=1] 49 | * @returns 50 | */ 51 | export const range = (from: number, to: number, count: number = to - from + 1, step: number = 1) => { 52 | // See here: http://stackoverflow.com/questions/3746725/create-a-javascript-array-containing-1-n 53 | // let a = Array.apply(null, {length: n}).map(Function.call, Math.random); 54 | const a: number[] = new Array(count); 55 | const min = from; 56 | const max = to - (count - 1) * step; 57 | const theRange = max - min; 58 | const x0 = Math.round(from + theRange * Math.random()); 59 | for (let i = 0; i < count; i++) { 60 | a[i] = x0 + i * step; 61 | } 62 | return a; 63 | }; 64 | 65 | /** 66 | * Deep copy function for TypeScript. 67 | * @param T Generic type of target/copied value. 68 | * @param target Target value to be copied. 69 | * @see Source project, ts-deepcopy https://github.com/ykdr2017/ts-deepcopy 70 | * @see Code pen https://codepen.io/erikvullings/pen/ejyBYg 71 | */ 72 | export const deepCopy = (target: T): T => { 73 | if (target === null) { 74 | return target; 75 | } 76 | if (target instanceof Date) { 77 | return new Date(target.getTime()) as any; 78 | } 79 | if (target instanceof Array) { 80 | const cpy = [] as any[]; 81 | (target as any[]).forEach((v) => { 82 | cpy.push(v); 83 | }); 84 | return cpy.map((n: any) => deepCopy(n)) as any; 85 | } 86 | if (typeof target === 'object') { 87 | const cpy = { ...(target as { [key: string]: any }) } as { 88 | [key: string]: any; 89 | }; 90 | Object.keys(cpy).forEach((k) => { 91 | cpy[k] = deepCopy(cpy[k]); 92 | }); 93 | return cpy as T; 94 | } 95 | return target; 96 | }; 97 | 98 | /** 99 | * Function to filter case-insensitive title and description. 100 | * @param filterValue Filter text 101 | */ 102 | export const titleAndDescriptionFilter = (filterValue: string) => { 103 | filterValue = filterValue.toLowerCase(); 104 | return (content: { title: string; description: string }) => 105 | !filterValue || 106 | !content.title || 107 | content.title.toLowerCase().indexOf(filterValue) >= 0 || 108 | (content.description && content.description.toLowerCase().indexOf(filterValue) >= 0); 109 | }; 110 | 111 | /** 112 | * Convert strings like XmlHTTPRequest to Xml HTTP Request 113 | * @see https://stackoverflow.com/a/6229124/319711 114 | */ 115 | export const unCamelCase = (str?: string) => 116 | str 117 | ? str 118 | .replace(/([a-z])([A-Z])/g, '$1 $2') // insert a space between lower & upper 119 | .replace(/\b([A-Z]+)([A-Z])([a-z])/, '$1 $2$3') // space before last upper in a sequence followed by lower 120 | .replace(/^./, (char) => char.toUpperCase()) // uppercase the first character 121 | : ''; 122 | 123 | export const deepEqual = (x?: T, y?: T): boolean => { 124 | const tx = typeof x; 125 | const ty = typeof y; 126 | return x && y && tx === 'object' && tx === ty 127 | ? Object.keys(x).length === Object.keys(y).length && Object.keys(x).every((key) => deepEqual(x[key], y[key])) 128 | : x === y; 129 | }; 130 | 131 | // let i = 0; 132 | // console.log(`${++i}: ${deepEqual([1, 2, 3], [1, 2, 3])}`); 133 | // console.log(`${++i}: ${deepEqual([1, 2, 3], [1, 2, 3, 4])}`); 134 | // console.log(`${++i}: ${deepEqual({ a: 'foo', b: 'bar' }, { a: 'foo', b: 'bar' })}`); 135 | // console.log(`${++i}: ${deepEqual({ a: 'foo', b: 'bar' }, { b: 'bar', a: 'foo' })}`); 136 | 137 | /** Remove paragraphs

and

and the beginning and end of a string. */ 138 | export const removeParagraphs = (s: string) => s.replace(/<\/?p>/g, ''); 139 | 140 | export const removeHtml = (s: string) => s.replace(/<\/?[0-9a-zA-Z=\[\]_ \-"]+>/gm, '').replace(/"/gi, '"'); 141 | -------------------------------------------------------------------------------- /packages/example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "NodeNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "lib": [ 7 | "dom", 8 | "es5", 9 | "es2015.promise", 10 | "es2017" 11 | ] /* Specify library files to be included in the compilation. */, 12 | "forceConsistentCasingInFileNames": true, 13 | // "allowJs": true, /* Allow javascript files to be compiled. */ 14 | // "checkJs": true, /* Report errors in .js files. */ 15 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 16 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 17 | "sourceMap": true /* Generates corresponding '.map' file. */, 18 | // "outFile": "./", /* Concatenate and emit output to single file. */ 19 | "outDir": "./dist" /* Redirect output structure to the directory. */, 20 | // "rootDir": 21 | // "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 22 | // "removeComments": true, /* Do not emit comments to output. */ 23 | // "noEmit": true, /* Do not emit outputs. */ 24 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 25 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 26 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 27 | /* Strict Type-Checking Options */ 28 | "strict": true /* Enable all strict type-checking options. */, 29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 30 | // "strictNullChecks": true, /* Enable strict null checks. */ 31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 32 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 33 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 34 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 35 | /* Additional Checks */ 36 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 37 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 38 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 39 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 40 | /* Module Resolution Options */ 41 | "moduleResolution": "nodenext", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | /* Source Map Options */ 51 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | /* Experimental Options */ 56 | "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 57 | "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */ 58 | } 59 | } -------------------------------------------------------------------------------- /packages/example/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "no-debugger": false, 8 | "quotemark": [true, "single"], 9 | "trailing-comma": [ 10 | true, 11 | { 12 | "multiline": { 13 | "objects": "always", 14 | "arrays": "always", 15 | "functions": "never", 16 | "typeLiterals": "ignore" 17 | }, 18 | "esSpecCompliant": true 19 | } 20 | ], 21 | "object-literal-sort-keys": false, 22 | "ordered-imports": false, 23 | "arrow-parens": [false, "ban-single-arg-parens"] 24 | }, 25 | "rulesDirectory": [] 26 | } 27 | -------------------------------------------------------------------------------- /packages/example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | const Dotenv = require('dotenv-webpack'); 4 | const HtmlWebpackTagsPlugin = require('html-webpack-tags-plugin'); 5 | // const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 7 | 8 | module.exports = (env) => { 9 | const isProduction = env.production; 10 | const outputPath = path.resolve(__dirname, isProduction ? '../../docs' : 'dist'); 11 | const publicPath = isProduction ? 'https://erikvullings.github.io/mithril-materialized/' : '/'; 12 | 13 | console.log(`Running in ${isProduction ? 'production' : 'development'} mode, output directed to ${outputPath}.`); 14 | 15 | return { 16 | mode: isProduction ? 'production' : 'development', 17 | entry: './src/app.ts', 18 | devtool: isProduction ? 'source-map' : 'inline-source-map', 19 | devServer: { 20 | liveReload: true, 21 | port: 1235, 22 | }, 23 | plugins: [ 24 | new Dotenv(), 25 | new HtmlWebpackPlugin({ 26 | title: 'Mithril Materialized', 27 | favicon: './src/favicon.ico', 28 | meta: { viewport: 'width=device-width, initial-scale=1' }, 29 | }), 30 | new HtmlWebpackTagsPlugin({ 31 | metas: [ 32 | { 33 | attributes: { property: 'og:title', content: 'Mithril Materialized' }, 34 | }, 35 | { 36 | attributes: { 37 | property: 'og:description', 38 | content: 'A materialize-css library for Mithril.', 39 | }, 40 | }, 41 | { 42 | attributes: { 43 | property: 'og:url', 44 | content: 'https://erikvullings.github.io/mithril-materialized', 45 | }, 46 | }, 47 | { 48 | path: './src/assets/favicon-32x32.png', 49 | attributes: { 50 | property: 'og:image', 51 | }, 52 | }, 53 | { 54 | attributes: { property: 'og:locale', content: 'en_UK' }, 55 | }, 56 | { 57 | attributes: { property: 'og:site_name', content: 'Mithril Materialized' }, 58 | }, 59 | { 60 | attributes: { property: 'og:image:alt', content: 'Mithril Materialized' }, 61 | }, 62 | { 63 | attributes: { 64 | property: 'og:image:type', 65 | content: 'image/png', 66 | }, 67 | }, 68 | { 69 | attributes: { 70 | property: 'og:image:width', 71 | content: '32', 72 | }, 73 | }, 74 | { 75 | attributes: { 76 | property: 'og:image:height', 77 | content: '32', 78 | }, 79 | }, 80 | ], 81 | }), 82 | // new MiniCssExtractPlugin({ 83 | // filename: isProduction ? '[name].[contenthash].css' : '[name].css', 84 | // chunkFilename: isProduction ? '[id].[contenthash].css' : '[id].css', 85 | // }), 86 | ], 87 | module: { 88 | rules: [ 89 | { 90 | test: /\.ts$/, 91 | use: 'ts-loader', 92 | exclude: /node_modules/, 93 | }, 94 | { 95 | test: /\.css$/i, 96 | use: ['style-loader', 'css-loader'], 97 | }, 98 | { 99 | test: /\.(png|svg|jpg|jpeg|gif)$/i, 100 | type: 'asset/resource', 101 | }, 102 | { 103 | test: /\.(woff|woff2|eot|ttf|otf)$/i, 104 | type: 'asset/resource', 105 | }, 106 | // { 107 | // test: /\.css$/, 108 | // use: [MiniCssExtractPlugin.loader, 'css-loader'], 109 | // }, 110 | ], 111 | }, 112 | resolve: { 113 | alias: { 114 | // 'materialize-css': path.resolve(__dirname, 'node_modules/materialize-css'), 115 | mithril: path.resolve(__dirname, 'node_modules/mithril'), 116 | }, 117 | extensions: ['.ts', '.js'], 118 | }, 119 | optimization: { 120 | minimizer: [ 121 | // For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line 122 | // `...`, 123 | new CssMinimizerPlugin(), 124 | ], 125 | }, 126 | output: { 127 | filename: 'bundle.js', 128 | path: outputPath, 129 | publicPath, 130 | }, 131 | }; 132 | }; 133 | -------------------------------------------------------------------------------- /packages/lib/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .rpt2_cache -------------------------------------------------------------------------------- /packages/lib/.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tsconfig.json 3 | tslint.json 4 | shrinkwrap.yaml 5 | pnpm-lock.yaml 6 | src 7 | .rpt2_cache 8 | rollup.config.js -------------------------------------------------------------------------------- /packages/lib/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mithril-materialized", 3 | "version": "1.4.2", 4 | "description": "A materialize library for mithril.", 5 | "main": "dist/index.js", 6 | "module": "dist/index.esm.js", 7 | "browser": "dist/index.umd.js", 8 | "unpkg": "dist/index.umd.js", 9 | "jsdelivr": "dist/index.umd.js", 10 | "types": "dist/index.d.ts", 11 | "exports": { 12 | ".": { 13 | "types": "./dist/index.d.ts", 14 | "import": "./dist/index.modern.js", 15 | "require": "./dist/index.js", 16 | "default": "./dist/index.modern.js" 17 | }, 18 | "./modern": "./dist/index.modern.js", 19 | "./esm": "./dist/index.esm.js", 20 | "./umd": "./dist/index.umd.js", 21 | "./index.css": "./dist/index.css" 22 | }, 23 | "sideEffects": false, 24 | "files": [ 25 | "dist" 26 | ], 27 | "scripts": { 28 | "build": "microbundle build ./src/index.ts --external mithril,materialize-css", 29 | "dev": "microbundle watch ./src/index.ts --external mithril,materialize-css", 30 | "start": "npm run dev", 31 | "clean": "rimraf dist node_modules/.cache", 32 | "link:old": "pnpm link", 33 | "typedoc": "typedoc --out ../../docs/typedoc src", 34 | "build:domain": "npm run clean && npm run build && typedoc --out ../../docs/typedoc src", 35 | "dry-run": "npm publish --dry-run", 36 | "patch-release": "npm run clean && npm run build && npm version patch --force -m \"Patch release\" && npm publish && git push --follow-tags", 37 | "minor-release": "npm run clean && npm run build && npm version minor --force -m \"Minor release\" && npm publish && git push --follow-tags", 38 | "major-release": "npm run clean && npm run build && npm version major --force -m \"Major release\" && npm publish && git push --follow-tags" 39 | }, 40 | "repository": { 41 | "type": "git", 42 | "url": "git://github.com/erikvullings/mithril-materialized.git" 43 | }, 44 | "keywords": [ 45 | "mithril", 46 | "materialize-css" 47 | ], 48 | "author": "Erik Vullings (http://www.tno.nl)", 49 | "license": "MIT", 50 | "dependencies": { 51 | "materialize-css": "^1.0.0", 52 | "mithril": "^2.3.0" 53 | }, 54 | "devDependencies": { 55 | "@types/materialize-css": "^1.0.14", 56 | "@types/mithril": "^2.2.7", 57 | "js-yaml": "^4.1.0", 58 | "microbundle": "^0.15.1", 59 | "rimraf": "^6.0.1", 60 | "tslib": "^2.8.1", 61 | "typedoc": "^0.28.4", 62 | "typescript": "^5.8.3" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/lib/src/autocomplete.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent } from 'mithril'; 2 | import { uniqueId } from './utils'; 3 | import { IInputOptions } from './input-options'; 4 | import { Label, HelperText } from './label'; 5 | 6 | export interface IAutoCompleteOptions extends Partial, IInputOptions {} 7 | 8 | /** Component to auto complete your text input */ 9 | export const Autocomplete: FactoryComponent = () => { 10 | const state = { id: uniqueId() }; 11 | return { 12 | view: ({ attrs }) => { 13 | const id = attrs.id || state.id; 14 | // const attributes = toAttrs(attrs); 15 | const { 16 | label, 17 | helperText, 18 | initialValue, 19 | onchange, 20 | newRow, 21 | className = 'col s12', 22 | style, 23 | iconName, 24 | isMandatory, 25 | ...params 26 | } = attrs; 27 | const cn = newRow ? className + ' clear' : className; 28 | return m(`.input-field${newRow ? '.clear' : ''}`, { className: cn, style }, [ 29 | iconName ? m('i.material-icons.prefix', iconName) : '', 30 | m('input', { 31 | ...params, 32 | className: 'autocomplete', 33 | type: 'text', 34 | tabindex: 0, 35 | id, 36 | oncreate: ({ dom }) => { 37 | M.Autocomplete.init(dom, attrs); 38 | }, 39 | onchange: onchange 40 | ? (e: Event) => { 41 | if (e.target && (e.target as HTMLInputElement).value) { 42 | onchange((e.target as HTMLInputElement).value); 43 | } 44 | } 45 | : undefined, 46 | value: initialValue, 47 | }), 48 | m(Label, { label, id, isMandatory, isActive: initialValue }), 49 | m(HelperText, { helperText }), 50 | ]); 51 | }, 52 | }; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/lib/src/button.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | import { Icon } from './icon'; 3 | 4 | export interface IHtmlAttributes { 5 | id?: string; 6 | for?: string; 7 | placeholder?: string; 8 | autofocus?: boolean; 9 | disabled?: boolean; 10 | type?: 'submit' | 'button' | 'text' | 'textarea' | 'number'; 11 | } 12 | 13 | export interface IMaterialButton extends Attributes { 14 | /** Optional (e.g. in case you only want to use an icon) button label */ 15 | label?: string; 16 | /** Optional icon material-icons name, @see https://materializecss.com/icons.html */ 17 | iconName?: string; 18 | /** Optional icon class, e.g. tiny (1em), small (2em), medium (4em), large (6em), or 'tiny right' */ 19 | iconClass?: string; 20 | /** 21 | * If the button is intended to open a modal, specify its modal id so we can trigger it, 22 | * @see https://materializecss.com/modals.html 23 | */ 24 | modalId?: string; 25 | /** Some additional HTML attributes that can be attached to the button */ 26 | attr?: IHtmlAttributes; 27 | /** Optional text-based tooltip, @see https://materializecss.com/tooltips.html */ 28 | tooltip?: string; 29 | /** Optional location for the tooltip */ 30 | tooltipPostion?: 'top' | 'bottom' | 'left' | 'right'; 31 | } 32 | 33 | /** 34 | * A factory to create new buttons. 35 | * 36 | * @example FlatButton = ButtonFactory('a.waves-effect.waves-teal.btn-flat'); 37 | */ 38 | export const ButtonFactory = ( 39 | element: string, 40 | defaultClassNames: string, 41 | type: string = '' 42 | ): FactoryComponent => { 43 | return () => { 44 | return { 45 | view: ({ attrs }) => { 46 | const { modalId, tooltip, tooltipPostion, iconName, iconClass, label, className, attr, ...params } = attrs; 47 | const cn = [modalId ? 'modal-trigger' : '', tooltip ? 'tooltipped' : '', defaultClassNames, className] 48 | .filter(Boolean) 49 | .join(' ') 50 | .trim(); 51 | return m( 52 | element, 53 | { 54 | ...params, 55 | ...attr, 56 | className: cn, 57 | href: modalId ? `#${modalId}` : undefined, 58 | 'data-position': tooltip ? tooltipPostion || 'top' : undefined, 59 | 'data-tooltip': tooltip || undefined, 60 | type, 61 | }, 62 | // `${dca}${modalId ? `.modal-trigger[href=#${modalId}]` : ''}${ 63 | // tooltip ? `.tooltipped[data-position=${tooltipPostion || 'top'}][data-tooltip=${tooltip}]` : '' 64 | // }${toAttributeString(attr)}`, {} 65 | iconName ? m(Icon, { iconName, className: iconClass || 'left' }) : undefined, 66 | label ? label : undefined 67 | ); 68 | }, 69 | }; 70 | }; 71 | }; 72 | 73 | export const Button = ButtonFactory('a', 'waves-effect waves-light btn', 'button'); 74 | export const LargeButton = ButtonFactory('a', 'waves-effect waves-light btn-large', 'button'); 75 | export const SmallButton = ButtonFactory('a', 'waves-effect waves-light btn-small', 'button'); 76 | export const FlatButton = ButtonFactory('a', 'waves-effect waves-teal btn-flat', 'button'); 77 | export const RoundIconButton = ButtonFactory('button', 'btn-floating btn-large waves-effect waves-light', 'button'); 78 | export const SubmitButton = ButtonFactory('button', 'btn waves-effect waves-light', 'submit'); 79 | -------------------------------------------------------------------------------- /packages/lib/src/carousel.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | 3 | export interface ICarouselItem extends Attributes { 4 | /** Relative page link, e.g. '#one' */ 5 | href: string; 6 | /** Image source */ 7 | src: string; 8 | /** Alternative name */ 9 | alt?: string; 10 | } 11 | 12 | export interface ICarousel extends Partial, Attributes { 13 | /** The list of images */ 14 | items: ICarouselItem[]; 15 | } 16 | 17 | export const CarouselItem: FactoryComponent = () => { 18 | return { 19 | view: ({ attrs: { href, src, alt, ...params } }) => { 20 | return m('a.carousel-item', { ...params, href }, m('img', { src, alt })); 21 | }, 22 | }; 23 | }; 24 | 25 | /** 26 | * Creates a collabsible or accordion (via the accordion option, default true) component. 27 | * @see https://materializecss.com/carousel.html 28 | */ 29 | export const Carousel: FactoryComponent = () => { 30 | return { 31 | view: ({ attrs }) => { 32 | const { items } = attrs; 33 | return items && items.length > 0 34 | ? m( 35 | '.carousel', 36 | { 37 | oncreate: ({ dom }) => { 38 | M.Carousel.init(dom, attrs); 39 | }, 40 | }, 41 | items.map((item) => m(CarouselItem, item)) 42 | ) 43 | : undefined; 44 | }, 45 | }; 46 | }; 47 | -------------------------------------------------------------------------------- /packages/lib/src/chip.ts: -------------------------------------------------------------------------------- 1 | import m from 'mithril'; 2 | import { uniqueId } from './utils'; 3 | import { HelperText, Label } from './label'; 4 | 5 | export interface ChipData { 6 | tag: string; 7 | image?: string; 8 | alt?: string; 9 | } 10 | 11 | export interface AutocompleteOption extends ChipData { 12 | value?: string; 13 | } 14 | 15 | export interface IChipsOptions { 16 | id?: string; 17 | data?: ChipData[]; 18 | placeholder?: string; 19 | secondaryPlaceholder?: string; 20 | autocompleteOptions?: { 21 | data: Record | AutocompleteOption[]; 22 | limit?: number; 23 | minLength?: number; 24 | }; 25 | limit?: number; 26 | required?: boolean; 27 | isMandatory?: boolean; 28 | className?: string; 29 | label?: string; 30 | helperText?: string; 31 | onchange?: (data: ChipData[]) => void; 32 | onChipAdd?: (chip: ChipData) => void; 33 | onChipDelete?: (chip: ChipData) => void; 34 | onChipSelect?: (chip: ChipData) => void; 35 | } 36 | 37 | export const Chips: m.FactoryComponent = () => { 38 | interface ChipsState { 39 | chipsData: ChipData[]; 40 | selectedChip: number | null; 41 | focused: boolean; 42 | inputValue: string; 43 | inputId: string; 44 | autocompleteItems: AutocompleteOption[]; 45 | selectedAutocompleteIndex: number; 46 | showAutocomplete: boolean; 47 | } 48 | 49 | const state: ChipsState = { 50 | chipsData: [], 51 | selectedChip: null, 52 | focused: false, 53 | inputValue: '', 54 | inputId: uniqueId(), 55 | autocompleteItems: [], 56 | selectedAutocompleteIndex: -1, 57 | showAutocomplete: false, 58 | }; 59 | 60 | let currentVnode: m.VnodeDOM | null = null; 61 | 62 | const processAutocompleteData = ( 63 | data: Record | AutocompleteOption[] 64 | ): AutocompleteOption[] => { 65 | if (Array.isArray(data)) { 66 | return data.map((item) => { 67 | if (typeof item === 'string') { 68 | return { tag: item }; 69 | } 70 | return item; 71 | }); 72 | } 73 | return Object.entries(data).map(([text, value]) => ({ 74 | tag: text, 75 | value: value || text, 76 | })); 77 | }; 78 | 79 | const updateAutocomplete = () => { 80 | if (!currentVnode?.attrs.autocompleteOptions?.data) { 81 | state.autocompleteItems = []; 82 | return; 83 | } 84 | 85 | const { data, minLength = 1 } = currentVnode.attrs.autocompleteOptions; 86 | const input = state.inputValue.toLowerCase(); 87 | 88 | if (input.length < minLength) { 89 | state.autocompleteItems = []; 90 | state.showAutocomplete = false; 91 | return; 92 | } 93 | 94 | const allOptions = processAutocompleteData(data); 95 | const filtered = allOptions.filter((option) => option.tag.toLowerCase().includes(input)); 96 | 97 | const limit = currentVnode.attrs.autocompleteOptions.limit || Infinity; 98 | state.autocompleteItems = filtered.slice(0, limit); 99 | state.showAutocomplete = state.autocompleteItems.length > 0; 100 | state.selectedAutocompleteIndex = -1; 101 | }; 102 | 103 | const selectAutocompleteItem = (item: AutocompleteOption) => { 104 | addChip({ 105 | tag: item.tag, 106 | image: item.image, 107 | alt: item.alt, // Preserve alt text when converting to chip 108 | }); 109 | state.inputValue = ''; 110 | state.showAutocomplete = false; 111 | state.selectedAutocompleteIndex = -1; 112 | }; 113 | 114 | const isValid = (chip: ChipData, currentChips: ChipData[]): boolean => { 115 | if (!chip.tag || chip.tag.trim() === '') return false; 116 | return !currentChips.some((c) => c.tag === chip.tag); 117 | }; 118 | 119 | const addChip = (chip: ChipData) => { 120 | if (!currentVnode) return; 121 | 122 | const { limit = Infinity, onChipAdd, onchange } = currentVnode.attrs; 123 | 124 | if (!isValid(chip, state.chipsData) || state.chipsData.length >= limit) { 125 | return; 126 | } 127 | 128 | state.chipsData = [...state.chipsData, chip]; 129 | state.inputValue = ''; 130 | 131 | if (onChipAdd) onChipAdd(chip); 132 | if (onchange) onchange(state.chipsData); 133 | }; 134 | 135 | const deleteChip = (index: number) => { 136 | if (!currentVnode) return; 137 | 138 | const { onChipDelete, onchange } = currentVnode.attrs; 139 | const chip = state.chipsData[index]; 140 | 141 | state.chipsData = state.chipsData.filter((_, i) => i !== index); 142 | state.selectedChip = null; 143 | 144 | if (onChipDelete) onChipDelete(chip); 145 | if (onchange) onchange(state.chipsData); 146 | }; 147 | 148 | const selectChip = (index: number) => { 149 | if (!currentVnode) return; 150 | 151 | const { onChipSelect } = currentVnode.attrs; 152 | state.selectedChip = index; 153 | 154 | if (onChipSelect && state.chipsData[index]) { 155 | onChipSelect(state.chipsData[index]); 156 | } 157 | }; 158 | 159 | const handleKeydown = (e: KeyboardEvent) => { 160 | const target = e.target as HTMLInputElement; 161 | 162 | if (state.showAutocomplete) { 163 | if (e.key === 'ArrowDown') { 164 | e.preventDefault(); 165 | state.selectedAutocompleteIndex = Math.min( 166 | state.selectedAutocompleteIndex + 1, 167 | state.autocompleteItems.length - 1 168 | ); 169 | const selectedItem = currentVnode?.dom.querySelector('.autocomplete-item.selected') as HTMLElement; 170 | if (selectedItem) { 171 | selectedItem.scrollIntoView({ block: 'nearest' }); 172 | } 173 | m.redraw(); 174 | return; 175 | } 176 | 177 | if (e.key === 'ArrowUp') { 178 | e.preventDefault(); 179 | state.selectedAutocompleteIndex = Math.max(state.selectedAutocompleteIndex - 1, -1); 180 | const selectedItem = currentVnode?.dom.querySelector('.autocomplete-item.selected') as HTMLElement; 181 | if (selectedItem) { 182 | selectedItem.scrollIntoView({ block: 'nearest' }); 183 | } 184 | m.redraw(); 185 | return; 186 | } 187 | 188 | if (e.key === 'Enter' && state.selectedAutocompleteIndex >= 0) { 189 | e.preventDefault(); 190 | selectAutocompleteItem(state.autocompleteItems[state.selectedAutocompleteIndex]); 191 | return; 192 | } 193 | } 194 | 195 | if (e.key === 'Enter' && target.value.trim()) { 196 | e.preventDefault(); 197 | addChip({ tag: target.value.trim() }); 198 | } else if (e.key === 'Backspace' && !target.value && state.chipsData.length > 0) { 199 | e.preventDefault(); 200 | // Delete the last chip immediately when backspace is pressed in an empty input 201 | deleteChip(state.chipsData.length - 1); 202 | } else if (e.key === 'ArrowLeft' && !target.value && state.chipsData.length) { 203 | e.preventDefault(); 204 | selectChip(state.chipsData.length - 1); 205 | } 206 | }; 207 | 208 | const handleChipKeydown = (e: KeyboardEvent, index: number) => { 209 | if (e.key === 'Backspace' || e.key === 'Delete') { 210 | e.preventDefault(); 211 | deleteChip(index); 212 | const newIndex = Math.max(index - 1, 0); 213 | if (state.chipsData.length) selectChip(newIndex); 214 | } else if (e.key === 'ArrowLeft' && index > 0) { 215 | selectChip(index - 1); 216 | } else if (e.key === 'ArrowRight') { 217 | if (index < state.chipsData.length - 1) { 218 | selectChip(index + 1); 219 | } else { 220 | const input = currentVnode?.dom.querySelector('.chips-input') as HTMLInputElement; 221 | if (input) input.focus(); 222 | } 223 | } 224 | }; 225 | 226 | return { 227 | oninit: ({ attrs }) => { 228 | state.chipsData = attrs.data || []; 229 | }, 230 | 231 | oncreate: (vnode) => { 232 | currentVnode = vnode; 233 | }, 234 | 235 | onremove: () => { 236 | currentVnode = null; 237 | }, 238 | 239 | view: ({ attrs }) => { 240 | const { 241 | id, 242 | required, 243 | isMandatory = required, 244 | className = 'col s12', 245 | label, 246 | helperText, 247 | placeholder, 248 | secondaryPlaceholder, 249 | } = attrs; 250 | 251 | const getPlaceholder = () => { 252 | if (!state.chipsData.length && placeholder) { 253 | return placeholder; 254 | } 255 | if (state.chipsData.length && secondaryPlaceholder) { 256 | return secondaryPlaceholder; 257 | } 258 | return ''; 259 | }; 260 | 261 | return m('.input-field', { id, className }, [ 262 | m( 263 | '.chips.chips-initial', 264 | { 265 | class: `chips-container ${state.focused ? 'focused' : ''} ${placeholder ? 'chips-placeholder' : ''}`, 266 | }, 267 | [ 268 | // Chips 269 | state.chipsData.map((chip, index) => 270 | m( 271 | '.chip', 272 | { 273 | key: `${chip.tag}-${index}`, 274 | tabindex: 0, 275 | class: state.selectedChip === index ? 'selected' : '', 276 | onkeydown: (e: KeyboardEvent) => handleChipKeydown(e, index), 277 | }, 278 | [ 279 | chip.image && 280 | m('img', { 281 | src: chip.image, 282 | alt: chip.alt || chip.tag, 283 | }), 284 | chip.tag, 285 | m( 286 | 'i.material-icons.close', 287 | { 288 | onclick: (e: MouseEvent) => { 289 | e.stopPropagation(); 290 | deleteChip(index); 291 | }, 292 | }, 293 | 'close' 294 | ), 295 | ] 296 | ) 297 | ), 298 | 299 | // Input 300 | m('input.chips-input.input', { 301 | id: state.inputId, 302 | title: 'label', 303 | value: state.inputValue, 304 | placeholder: getPlaceholder(), 305 | oninput: (e: InputEvent) => { 306 | state.inputValue = (e.target as HTMLInputElement).value; 307 | updateAutocomplete(); 308 | }, 309 | onfocus: () => { 310 | state.focused = true; 311 | state.selectedChip = null; 312 | updateAutocomplete(); 313 | }, 314 | onblur: () => { 315 | state.focused = false; 316 | setTimeout(() => { 317 | state.showAutocomplete = false; 318 | state.selectedChip = null; 319 | m.redraw(); 320 | }, 150); 321 | }, 322 | onkeydown: handleKeydown, 323 | }), 324 | 325 | state.showAutocomplete && 326 | m( 327 | 'ul.autocomplete-content.dropdown-content', 328 | { 329 | style: { 330 | display: 'block', 331 | opacity: 1, 332 | transform: 'scaleX(1) scaleY(1)', 333 | position: 'absolute', 334 | width: '100%', 335 | left: 0, 336 | top: '100%', 337 | maxHeight: '200px', 338 | overflow: 'auto', 339 | zIndex: 1000, 340 | backgroundColor: '#fff', 341 | boxShadow: 342 | '0 2px 2px 0 rgba(0,0,0,0.14), 0 3px 1px -2px rgba(0,0,0,0.12), 0 1px 5px 0 rgba(0,0,0,0.2)', 343 | }, 344 | }, 345 | state.autocompleteItems.map((item, index) => 346 | m( 347 | 'li.autocomplete-item', 348 | { 349 | key: item.tag, 350 | class: state.selectedAutocompleteIndex === index ? 'selected' : '', 351 | style: { 352 | padding: '12px 16px', 353 | cursor: 'pointer', 354 | backgroundColor: state.selectedAutocompleteIndex === index ? '#eee' : 'transparent', 355 | }, 356 | onmousedown: (e: MouseEvent) => { 357 | e.preventDefault(); 358 | selectAutocompleteItem(item); 359 | }, 360 | onmouseover: () => { 361 | state.selectedAutocompleteIndex = index; 362 | }, 363 | }, 364 | [ 365 | item.image && 366 | m('img.autocomplete-item-image', { 367 | src: item.image, 368 | alt: item.alt || item.tag, 369 | style: { 370 | width: '24px', 371 | height: '24px', 372 | marginRight: '8px', 373 | verticalAlign: 'middle', 374 | }, 375 | }), 376 | m('span.autocomplete-item-text', item.tag), 377 | ] 378 | ) 379 | ) 380 | ), 381 | ] 382 | ), 383 | label && 384 | m(Label, { 385 | label, 386 | id: state.inputId, 387 | isMandatory, 388 | isActive: state.focused || state.chipsData.length || placeholder ? true : false, 389 | }), 390 | helperText && m(HelperText, { helperText }), 391 | ]); 392 | }, 393 | }; 394 | }; 395 | -------------------------------------------------------------------------------- /packages/lib/src/code-block.ts: -------------------------------------------------------------------------------- 1 | import './styles/codeblock.css'; 2 | import m, { FactoryComponent, Attributes } from 'mithril'; 3 | 4 | export interface ICodeBlock extends Attributes { 5 | language?: string; 6 | code: string | string[]; 7 | newRow?: boolean; 8 | } 9 | 10 | /** A simple code block without syntax high-lighting */ 11 | export const CodeBlock: FactoryComponent = () => ({ 12 | view: ({ attrs }) => { 13 | const { newRow, code, language, className, ...params } = attrs; 14 | const lang = language || 'lang-TypeScript'; 15 | const label = lang.replace('lang-', ''); 16 | const cb = code instanceof Array ? code.join('\n') : code; 17 | const cn = [newRow ? 'clear' : '', lang, className].filter(Boolean).join(' ').trim(); 18 | return m(`pre.codeblock${newRow ? '.clear' : ''}`, attrs, [ 19 | m('div', m('label', label)), 20 | m( 21 | 'code', 22 | { 23 | ...params, 24 | className: cn, 25 | }, 26 | cb 27 | ), 28 | ]); 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /packages/lib/src/collapsible.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes, Vnode } from 'mithril'; 2 | 3 | export interface ICollapsibleItem extends Attributes { 4 | /** Header of the collabsible item, may contain HTML or may be a Vnode */ 5 | header?: string | Vnode; 6 | /** Body of the collabsible item, may contain HTML or may be a Vnode */ 7 | body?: string | Vnode; 8 | /** If active, preselect the collabsible item. */ 9 | active?: boolean; 10 | /** Add an material icon in front of the header. */ 11 | iconName?: string; 12 | } 13 | 14 | export interface ICollapsible extends Partial, Attributes { 15 | /** The list of accordeon/collabsible items */ 16 | items: ICollapsibleItem[]; 17 | } 18 | 19 | export const CollapsibleItem: FactoryComponent = () => { 20 | return { 21 | view: ({ attrs: { header, body, active, iconName } }) => { 22 | return m(active ? 'li.active' : 'li', [ 23 | header || iconName 24 | ? m('.collapsible-header', [ 25 | iconName ? m('i.material-icons', iconName) : undefined, 26 | header ? (typeof header === 'string' ? m('span', header) : header) : undefined, 27 | ]) 28 | : undefined, 29 | body ? m('.collapsible-body', typeof body === 'string' ? body : body) : undefined, 30 | ]); 31 | }, 32 | }; 33 | }; 34 | 35 | /** 36 | * Creates a collabsible or accordion (via the accordion option, default true) component. 37 | * @see https://materializecss.com/collapsible.html 38 | */ 39 | export const Collapsible: FactoryComponent = () => { 40 | return { 41 | oncreate: ({ dom, attrs }) => { 42 | M.Collapsible.init(dom, attrs); 43 | }, 44 | view: ({ attrs }) => { 45 | const { items, class: c, className, style, id } = attrs; 46 | return items && items.length > 0 47 | ? m( 48 | 'ul.collapsible', 49 | { 50 | class: c || className, 51 | style, 52 | id, 53 | }, 54 | items.map((item) => m(CollapsibleItem, item)) 55 | ) 56 | : undefined; 57 | }, 58 | }; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/lib/src/collection.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes, Vnode } from 'mithril'; 2 | import { Icon } from './icon'; 3 | 4 | export enum CollectionMode { 5 | BASIC, 6 | LINKS, 7 | AVATAR, 8 | } 9 | 10 | export interface ICollectionItem { 11 | /** If available, will be used as the key, so all items need an id. */ 12 | id?: string | number; 13 | /** Title of the collection item */ 14 | title: string | Vnode; 15 | /** For links, may contain a URL reference */ 16 | href?: string; 17 | /** For Avatar mode, may contain a URL reference to an image or a material icons class name */ 18 | avatar?: string; 19 | /** Add a class to the avatar image or icon, e.g. a color 'red'. */ 20 | className?: string; 21 | /** For Avatar mode, may contain a two-line trusted HTML content */ 22 | content?: string; 23 | /** If active, preselect the collection item. */ 24 | active?: boolean; 25 | /** Add a material icon as secondary content. */ 26 | iconName?: string; 27 | /** Onclick event handler */ 28 | onclick?: (item: ICollectionItem) => void; 29 | /** Any other virtual element properties, including attributes and event handlers. */ 30 | [property: string]: any; 31 | } 32 | 33 | export interface ICollection extends Attributes { 34 | /** Optional header */ 35 | header?: string; 36 | /** The list of items */ 37 | items: ICollectionItem[]; 38 | /** Mode of operation */ 39 | mode?: CollectionMode; 40 | } 41 | 42 | const isNonLocalRoute = (url?: string) => url && /https?:\/\//.test(url); 43 | 44 | export const SecondaryContent: FactoryComponent = () => { 45 | return { 46 | view: ({ attrs }) => { 47 | const { href, iconName = 'send', onclick, style = { cursor: 'pointer' } } = attrs; 48 | const props = { 49 | href, 50 | style, 51 | className: 'secondary-content', 52 | onclick: onclick ? () => onclick(attrs) : undefined, 53 | }; 54 | return isNonLocalRoute(href) || !href 55 | ? m('a[target=_]', props, m(Icon, { iconName })) 56 | : m(m.route.Link, props as { href: string }, m(Icon, { iconName })); 57 | }, 58 | }; 59 | }; 60 | 61 | const avatarIsImage = (avatar = '') => /\./.test(avatar); 62 | 63 | export const ListItem: FactoryComponent<{ item: ICollectionItem; mode: CollectionMode }> = () => { 64 | return { 65 | view: ({ attrs: { item, mode } }) => { 66 | const { title, content = '', active, iconName, avatar, className, onclick } = item; 67 | return mode === CollectionMode.AVATAR 68 | ? m( 69 | 'li.collection-item.avatar', 70 | { 71 | className: active ? 'active' : '', 72 | onclick: onclick ? () => onclick(item) : undefined, 73 | }, 74 | [ 75 | avatarIsImage(avatar) 76 | ? m('img.circle', { src: avatar }) 77 | : m('i.material-icons.circle', { className }, avatar), 78 | m('span.title', title), 79 | m('p', m.trust(content)), 80 | m(SecondaryContent, item), 81 | ] 82 | ) 83 | : m( 84 | 'li.collection-item', 85 | { 86 | className: active ? 'active' : '', 87 | }, 88 | iconName ? m('div', [title, m(SecondaryContent, item)]) : title 89 | ); 90 | }, 91 | }; 92 | }; 93 | 94 | const BasicCollection: FactoryComponent = () => { 95 | return { 96 | view: ({ attrs: { header, items, mode = CollectionMode.BASIC, ...params } }) => { 97 | const collectionItems = items.map((item) => m(ListItem, { key: item.id, item, mode })); 98 | return header 99 | ? m('ul.collection.with-header', params, [m('li.collection-header', m('h4', header)), collectionItems]) 100 | : m('ul.collection', params, collectionItems); 101 | }, 102 | }; 103 | }; 104 | 105 | export const AnchorItem: FactoryComponent<{ item: ICollectionItem }> = () => { 106 | return { 107 | view: ({ attrs: { item } }) => { 108 | const { title, active, href, ...params } = item; 109 | const props = { 110 | ...params, 111 | className: `collection-item ${active ? 'active' : ''}`, 112 | href, 113 | }; 114 | return isNonLocalRoute(href) || !href 115 | ? m('a[target=_]', props, title) 116 | : m(m.route.Link, props as { href: string }, title); 117 | }, 118 | }; 119 | }; 120 | 121 | const LinksCollection: FactoryComponent = () => { 122 | return { 123 | view: ({ attrs: { items, header, ...params } }) => { 124 | return header 125 | ? m('.collection.with-header', params, [ 126 | m('.collection-header', m('h4', header)), 127 | items.map((item) => m(AnchorItem, { key: item.id, item })), 128 | ]) 129 | : m( 130 | '.collection', 131 | params, 132 | items.map((item) => m(AnchorItem, { key: item.id, item })) 133 | ); 134 | }, 135 | }; 136 | }; 137 | 138 | /** 139 | * Creates a Collection of items, optionally containing links, headers, secondary content or avatars. 140 | * @see https://materializecss.com/collections.html 141 | */ 142 | export const Collection: FactoryComponent = () => { 143 | return { 144 | view: ({ attrs: { items, header, mode = CollectionMode.BASIC, ...params } }) => { 145 | return header || (items && items.length > 0) 146 | ? mode === CollectionMode.LINKS 147 | ? m(LinksCollection, { header, items, ...params }) 148 | : m(BasicCollection, { header, items, mode, ...params }) 149 | : undefined; 150 | }, 151 | }; 152 | }; 153 | -------------------------------------------------------------------------------- /packages/lib/src/dropdown.ts: -------------------------------------------------------------------------------- 1 | import m, { Component, Attributes } from 'mithril'; 2 | import { HelperText } from './label'; 3 | import { uniqueId } from './utils'; 4 | 5 | export interface IDropdownOption { 6 | /** ID property of the selected item */ 7 | id?: T; 8 | /** Label to show in the dropdown */ 9 | label: string; 10 | /** Can we select the item */ 11 | disabled?: boolean; 12 | /** Display a Materials Icon in front of the label */ 13 | iconName?: string; 14 | /** Add a divider */ 15 | divider?: boolean; 16 | } 17 | 18 | export interface IDropdownOptions extends Partial, Attributes { 19 | /** 20 | * Optional id of the dropdown element 21 | * @default 'dropdown' 22 | */ 23 | id?: T; 24 | /** 25 | * Optional label when no item is selected 26 | * @default 'Select' 27 | */ 28 | label?: string; 29 | key?: string | number; 30 | /** If true, disable the selection */ 31 | disabled?: boolean; 32 | /** Item array to show in the dropdown. If the value is not supplied, uses he name. */ 33 | items: IDropdownOption[]; 34 | /** 35 | * Selected value or name 36 | * @deprecated Use initialValue instead 37 | */ 38 | checkedId?: T; 39 | /** Selected value or name */ 40 | initialValue?: T; 41 | /** When a value or name is selected */ 42 | onchange?: (value: T) => void; 43 | /** Uses Materialize icons as a prefix or postfix. */ 44 | iconName?: string; 45 | /** Add a description underneath the input field. */ 46 | helperText?: string; 47 | } 48 | 49 | /** Dropdown component */ 50 | export const Dropdown = (): Component> => { 51 | // export const Dropdown: FactoryComponent = () => { 52 | const state = {} as { 53 | initialValue?: T; 54 | id: T; 55 | }; 56 | return { 57 | oninit: ({ attrs: { id = uniqueId(), initialValue, checkedId } }) => { 58 | state.id = id as T; 59 | state.initialValue = initialValue || checkedId; 60 | }, 61 | view: ({ 62 | attrs: { 63 | key, 64 | label, 65 | onchange, 66 | disabled = false, 67 | items, 68 | iconName, 69 | helperText, 70 | style, 71 | className = 'col s12', 72 | ...props 73 | }, 74 | }) => { 75 | const { id, initialValue } = state; 76 | const selectedItem = initialValue 77 | ? items.filter((i: IDropdownOption) => (i.id ? i.id === initialValue : i.label === initialValue)).shift() 78 | : undefined; 79 | const title = selectedItem ? selectedItem.label : label || 'Select'; 80 | return m('.input-field', { className, key, style }, [ 81 | iconName ? m('i.material-icons.prefix', iconName) : undefined, 82 | m(HelperText, { helperText }), 83 | m( 84 | 'a.dropdown-trigger.btn.truncate[href=#]', 85 | { 86 | 'data-target': id, 87 | disabled, 88 | className: 'col s12', 89 | style: style || (iconName ? 'margin: 0.2em 0 0 3em;' : undefined), 90 | oncreate: ({ dom }) => { 91 | M.Dropdown.init(dom, props); 92 | }, 93 | }, 94 | title 95 | ), 96 | m( 97 | 'ul.dropdown-content', 98 | { id }, 99 | items.map((i) => 100 | m( 101 | 'li[tabindex=-1]', 102 | { 103 | className: i.divider ? 'divider' : '', 104 | }, 105 | i.divider 106 | ? undefined 107 | : m( 108 | 'a', 109 | { 110 | onclick: onchange 111 | ? () => { 112 | state.initialValue = (i.id || i.label) as T; 113 | onchange(state.initialValue); 114 | } 115 | : undefined, 116 | }, 117 | [i.iconName ? m('i.material-icons', i.iconName) : undefined, i.label] 118 | ) 119 | ) 120 | ) 121 | ), 122 | ]); 123 | }, 124 | }; 125 | }; 126 | -------------------------------------------------------------------------------- /packages/lib/src/floating-action-button.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent } from 'mithril'; 2 | 3 | export interface IFloatingActionButton extends Partial { 4 | /** Optional classes to add to the top element */ 5 | className?: string; 6 | /** Optional style to add to the top element, e.g. for positioning it inline */ 7 | style?: string; 8 | /** Material-icons name for the main FAB, @see https://materializecss.com/icons.html */ 9 | iconName: string; 10 | /** Helper option to place the FAB inline instead of the bottom right of the display */ 11 | position?: 'left' | 'right' | 'inline-left' | 'inline-right'; 12 | /** 13 | * Optional icon class, e.g. tiny (1em), small (2em), medium (4em), large (6em), or 'tiny right'. 14 | * @default large 15 | */ 16 | iconClass?: string; 17 | /** The buttons you want to show */ 18 | buttons?: Array<{ 19 | /** Optional classes you want to add to the main element */ 20 | className?: string; 21 | /** Name of the icon */ 22 | iconName: string; 23 | /** Classes of the icon */ 24 | iconClass?: string; 25 | /** On click function */ 26 | onClick?: (e: UIEvent) => void; 27 | }>; 28 | } 29 | 30 | /** 31 | * A Floating Action Button. 32 | * 33 | * @example FlatButton = ButtonFactory('a.waves-effect.waves-teal.btn-flat'); 34 | */ 35 | export const FloatingActionButton: FactoryComponent = () => { 36 | return { 37 | view: ({ 38 | attrs: { 39 | className, 40 | iconName, 41 | iconClass = 'large', 42 | position, 43 | style = position === 'left' || position === 'inline-left' 44 | ? 'position: absolute; display: inline-block; left: 24px;' 45 | : position === 'right' || position === 'inline-right' 46 | ? 'position: absolute; display: inline-block; right: 24px;' 47 | : undefined, 48 | buttons, 49 | ...options 50 | }, 51 | }) => { 52 | const fab = m( 53 | '.fixed-action-btn', 54 | { 55 | style, 56 | oncreate: ({ dom }) => M.FloatingActionButton.init(dom, options), 57 | }, 58 | [ 59 | m('a.btn-floating.btn-large', { className }, m('i.material-icons', { classNames: iconClass }, iconName)), 60 | buttons 61 | ? m( 62 | 'ul', 63 | buttons.map(b => 64 | m( 65 | 'li', 66 | m( 67 | 'a.btn-floating', 68 | { className: b.className, onclick: (e: UIEvent) => b.onClick && b.onClick(e) }, 69 | m('i.material-icons', { className: b.iconClass }, b.iconName) 70 | ) 71 | ) 72 | ) 73 | ) 74 | : undefined, 75 | ] 76 | ); 77 | return position === 'inline-right' || position === 'inline-left' 78 | ? m('div', { style: 'position: relative; height: 70px;' }, fab) 79 | : fab; 80 | }, 81 | }; 82 | }; 83 | -------------------------------------------------------------------------------- /packages/lib/src/icon.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | 3 | export interface IMaterialIcon extends Attributes { 4 | iconName: string; 5 | } 6 | 7 | /** 8 | * A simple material icon, defined by its icon name. 9 | * 10 | * @example m(Icon, { className: 'small' }, 'create') renders a small 'create' icon 11 | * @example m(Icon, { className: 'prefix' }, iconName) renders the icon as a prefix 12 | */ 13 | export const Icon: FactoryComponent = () => ({ 14 | view: ({ attrs: { iconName, ...passThrough } }) => m('i.material-icons', passThrough, iconName), 15 | }); 16 | -------------------------------------------------------------------------------- /packages/lib/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './autocomplete'; 2 | export * from './button'; 3 | export * from './carousel'; 4 | export * from './chip'; 5 | export * from './code-block'; 6 | export * from './collapsible'; 7 | export * from './collection'; 8 | export * from './dropdown'; 9 | export * from './floating-action-button'; 10 | export * from './icon'; 11 | export * from './input-options'; 12 | export * from './input'; 13 | export * from './label'; 14 | export * from './map-editor'; 15 | export * from './material-box'; 16 | export * from './modal'; 17 | export * from './option'; 18 | export * from './pagination'; 19 | export * from './parallax'; 20 | export * from './pickers'; 21 | export * from './radio'; 22 | export * from './select'; 23 | export * from './switch'; 24 | export * from './tabs'; 25 | export * from './timeline'; 26 | export * from './search-select'; 27 | export * from './utils'; 28 | -------------------------------------------------------------------------------- /packages/lib/src/input-options.ts: -------------------------------------------------------------------------------- 1 | import { Attributes } from 'mithril'; 2 | 3 | export interface IInputOptions extends Attributes { 4 | /** Optional label. */ 5 | label?: string; 6 | /** Optional ID. */ 7 | id?: string; 8 | /** Unique key for use of the element in an array. */ 9 | key?: string | number; 10 | /** Initial value of the input field. */ 11 | initialValue?: T; 12 | /** 13 | * The autocomplete property sets or returns the value of the autocomplete 14 | * attribute in a text field. When autocomplete is on, the browser automatically 15 | * complete values based on values that the user has entered before. 16 | * @default 'on' 17 | */ 18 | autocomplete?: 'on' | 'off'; 19 | /** 20 | * The pattern property sets or returns the value of the pattern attribute of 21 | * a text field. The pattern attribute specifies a regular expression that the 22 | * text field's value is checked against. 23 | */ 24 | pattern?: RegExp; 25 | /** 26 | * The readOnly property sets or returns whether a text field is read-only, or not. 27 | * A read-only field cannot be modified. However, a user can tab to it, highlight it, 28 | * and copy the text from it. 29 | */ 30 | readOnly?: boolean; 31 | /** When true, add the autofocus attribute to the input field. */ 32 | autofocus?: (() => boolean) | boolean; 33 | /** Key down event */ 34 | onkeydown?: (ev: KeyboardEvent, value?: T) => void; 35 | /** Key press event */ 36 | onkeypress?: (ev: KeyboardEvent, value?: T) => void; 37 | /** Key up event */ 38 | onkeyup?: (ev: KeyboardEvent, value?: T) => void; 39 | /** Invoked when the element looses focus */ 40 | onblur?: (ev: FocusEvent) => void; 41 | /** Invoked when the value changes. */ 42 | onchange?: (value: T) => void; 43 | /** Add a a placeholder to the input field. */ 44 | placeholder?: string; 45 | /** Add a description underneath the input field. */ 46 | helperText?: string; 47 | /** 48 | * When returning true or an empty string, clear the custom validity (= valid). 49 | * When returning false, set the custom validity message to a default string string. 50 | * When returning a non-empty string, set the custom validity message to this string. 51 | */ 52 | validate?: (v: T, target?: HTMLInputElement) => boolean | string; 53 | /** Will replace the helperText, if any, when the input is invalid. */ 54 | dataError?: string; 55 | /** Will replace the helperText, if any, when the input is valid. */ 56 | dataSuccess?: string; 57 | /** Uses Materialize icons as a prefix or postfix. */ 58 | iconName?: string; 59 | /** Sets the input field to disabled. */ 60 | disabled?: boolean; 61 | /** Optional style information. */ 62 | style?: string; 63 | /** When input type is a number, optionally specify the minimum value. */ 64 | min?: number; 65 | /** When input type is a number, optionally specify the maximum value. */ 66 | max?: number; 67 | /** When input type is a text or text area, optionally specify the minimum length. */ 68 | minLength?: number; 69 | /** When input type is a text or text area, optionally specify the maximum length. */ 70 | maxLength?: number; 71 | /** Number of rows of a textarea */ 72 | rows?: number; 73 | /** Number of cols of a textarea */ 74 | cols?: number; 75 | /** If true, break to a new row */ 76 | newRow?: boolean; 77 | /** 78 | * If true, add a mandatory * after the label (if any), 79 | * and add the required and aria-required attributes to the input element. 80 | */ 81 | isMandatory?: boolean; 82 | /** Add the required and aria-required attributes to the input element */ 83 | required?: boolean; 84 | } 85 | -------------------------------------------------------------------------------- /packages/lib/src/input.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | import { uniqueId } from './utils'; 3 | import { IInputOptions } from './input-options'; 4 | import { Label, HelperText } from './label'; 5 | import './styles/input.css'; 6 | 7 | /** Create a TextArea */ 8 | export const TextArea: FactoryComponent> = () => { 9 | const state = { id: uniqueId() }; 10 | return { 11 | view: ({ attrs }) => { 12 | const { 13 | className = 'col s12', 14 | helperText, 15 | iconName, 16 | id = state.id, 17 | initialValue, 18 | isMandatory, 19 | label, 20 | onchange, 21 | onkeydown, 22 | onkeypress, 23 | onkeyup, 24 | onblur, 25 | style, 26 | ...params 27 | } = attrs; 28 | // const attributes = toAttrs(params); 29 | return m('.input-field', { className, style }, [ 30 | iconName ? m('i.material-icons.prefix', iconName) : '', 31 | m('textarea.materialize-textarea', { 32 | ...params, 33 | id, 34 | tabindex: 0, 35 | oncreate: ({ dom }) => { 36 | M.textareaAutoResize(dom); 37 | if (attrs.maxLength) { 38 | M.CharacterCounter.init(dom); 39 | } 40 | }, 41 | onchange: onchange 42 | ? (e: Event) => { 43 | const target = e.target as HTMLInputElement; 44 | const value = target && typeof target.value === 'string' ? target.value : ''; 45 | onchange(value); 46 | } 47 | : undefined, 48 | value: initialValue, 49 | onkeyup: onkeyup 50 | ? (ev: KeyboardEvent) => { 51 | onkeyup(ev, (ev.target as HTMLTextAreaElement).value); 52 | } 53 | : undefined, 54 | onkeydown: onkeydown 55 | ? (ev: KeyboardEvent) => { 56 | onkeydown(ev, (ev.target as HTMLTextAreaElement).value); 57 | } 58 | : undefined, 59 | onkeypress: onkeypress 60 | ? (ev: KeyboardEvent) => { 61 | onkeypress(ev, (ev.target as HTMLTextAreaElement).value); 62 | } 63 | : undefined, 64 | onblur, 65 | }), 66 | m(Label, { label, id, isMandatory, isActive: initialValue || attrs.placeholder }), 67 | m(HelperText, { helperText }), 68 | ]); 69 | }, 70 | }; 71 | }; 72 | 73 | export type InputType = 'url' | 'color' | 'text' | 'number' | 'email' | 'range' | 'password'; 74 | 75 | /** Default component for all kinds of input fields. */ 76 | const InputField = 77 | (type: InputType, defaultClass = ''): FactoryComponent> => 78 | () => { 79 | const state = { id: uniqueId() }; 80 | const getValue = (target: HTMLInputElement) => { 81 | const val = target.value as any as T; 82 | return (val ? (type === 'number' || type === 'range' ? +val : val) : val) as T; 83 | }; 84 | const setValidity = (target: HTMLInputElement, validationResult: string | boolean) => { 85 | if (typeof validationResult === 'boolean') { 86 | target.setCustomValidity(validationResult ? '' : 'Custom validation failed'); 87 | } else { 88 | target.setCustomValidity(validationResult); 89 | } 90 | }; 91 | const focus = ({ autofocus }: IInputOptions) => 92 | autofocus ? (typeof autofocus === 'boolean' ? autofocus : autofocus()) : false; 93 | 94 | return { 95 | view: ({ attrs }) => { 96 | const { 97 | className = 'col s12', 98 | dataError, 99 | dataSuccess, 100 | helperText, 101 | iconName, 102 | id = state.id, 103 | initialValue, 104 | isMandatory, 105 | label, 106 | maxLength, 107 | newRow, 108 | onchange, 109 | onkeydown, 110 | onkeypress, 111 | onkeyup, 112 | onblur, 113 | style, 114 | validate, 115 | ...params 116 | } = attrs; 117 | // const attributes = toAttrs(params); 118 | const cn = [newRow ? 'clear' : '', defaultClass, className].filter(Boolean).join(' ').trim(); 119 | return m('.input-field', { className: cn, style }, [ 120 | iconName ? m('i.material-icons.prefix', iconName) : undefined, 121 | m('input.validate', { 122 | ...params, 123 | type, 124 | tabindex: 0, 125 | id, 126 | // attributes, 127 | oncreate: ({ dom }) => { 128 | if (focus(attrs)) { 129 | (dom as HTMLElement).focus(); 130 | } 131 | if (maxLength) { 132 | M.CharacterCounter.init(dom); 133 | } 134 | if (type === 'range') { 135 | M.Range.init(dom); 136 | } 137 | }, 138 | onkeyup: onkeyup 139 | ? (ev: KeyboardEvent) => { 140 | onkeyup(ev, getValue(ev.target as HTMLInputElement)); 141 | } 142 | : undefined, 143 | onkeydown: onkeydown 144 | ? (ev: KeyboardEvent) => { 145 | onkeydown(ev, getValue(ev.target as HTMLInputElement)); 146 | } 147 | : undefined, 148 | onkeypress: onkeypress 149 | ? (ev: KeyboardEvent) => { 150 | onkeypress(ev, getValue(ev.target as HTMLInputElement)); 151 | } 152 | : undefined, 153 | onblur, 154 | onupdate: validate 155 | ? ({ dom }) => { 156 | const target = dom as HTMLInputElement; 157 | setValidity(target, validate(getValue(target), target)); 158 | } 159 | : undefined, 160 | onchange: (e: UIEvent) => { 161 | const target = e.target as HTMLInputElement; 162 | if (target) { 163 | const value = getValue(target); 164 | if (onchange) { 165 | onchange(value); 166 | } 167 | if (validate) { 168 | setValidity(target, validate(value, target)); 169 | } 170 | } 171 | }, 172 | value: initialValue, 173 | }), 174 | m(Label, { 175 | label, 176 | id, 177 | isMandatory, 178 | isActive: 179 | typeof initialValue !== 'undefined' || 180 | attrs.placeholder || 181 | type === 'number' || 182 | type === 'color' || 183 | type === 'range' 184 | ? true 185 | : false, 186 | }), 187 | m(HelperText, { helperText, dataError, dataSuccess }), 188 | ]); 189 | }, 190 | }; 191 | }; 192 | 193 | /** Component for entering some text */ 194 | export const TextInput = InputField('text'); 195 | /** Component for entering a password */ 196 | export const PasswordInput = InputField('password'); 197 | /** Component for entering a number */ 198 | export const NumberInput = InputField('number'); 199 | /** Component for entering a URL */ 200 | export const UrlInput = InputField('url'); 201 | /** Component for entering a color */ 202 | export const ColorInput = InputField('color'); 203 | /** Component for entering a range */ 204 | export const RangeInput = InputField('range', '.range-field'); 205 | /** Component for entering an email */ 206 | export const EmailInput = InputField('email'); 207 | 208 | export interface IFileInputOptions extends Attributes { 209 | /** Displayed on the button, @default File */ 210 | label?: string; 211 | /** Current value of the file input, write only */ 212 | initialValue?: string; 213 | /** Adds a placeholder message */ 214 | placeholder?: string; 215 | /** If true, upload multiple files */ 216 | multiple?: boolean; 217 | /** Called when the file input is changed */ 218 | onchange?: (files: FileList) => void; 219 | /** If true, disable the box */ 220 | disabled?: boolean; 221 | /** 222 | * Accepted file types, e.g. image/png, image/jpeg, 223 | * any image/*, video/*. audio/*, .pdf, a valid MIME type string, with no extensions, etc. 224 | * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#Unique_file_type_specifiers 225 | */ 226 | accept?: string | string[]; 227 | } 228 | 229 | /** Component for uploading a file */ 230 | export const FileInput: FactoryComponent = () => { 231 | let canClear = false; 232 | let i: HTMLInputElement; 233 | return { 234 | view: ({ attrs }) => { 235 | const { 236 | multiple, 237 | disabled, 238 | initialValue, 239 | placeholder, 240 | onchange, 241 | className = 'col s12', 242 | accept: acceptedFiles, 243 | label = 'File', 244 | } = attrs; 245 | const accept = acceptedFiles 246 | ? acceptedFiles instanceof Array 247 | ? acceptedFiles.join(', ') 248 | : acceptedFiles 249 | : undefined; 250 | return m( 251 | '.file-field.input-field', 252 | { 253 | className: attrs.class || className, 254 | }, 255 | [ 256 | m('.btn', [ 257 | m('span', label), 258 | m('input[type=file]', { 259 | title: label, 260 | accept, 261 | multiple, 262 | disabled, 263 | onchange: onchange 264 | ? (e: UIEvent) => { 265 | const i = e.target as HTMLInputElement; 266 | if (i && i.files && onchange) { 267 | canClear = true; 268 | onchange(i.files); 269 | } 270 | } 271 | : undefined, 272 | }), 273 | ]), 274 | m( 275 | '.file-path-wrapper', 276 | m('input.file-path.validate[type=text]', { 277 | placeholder, 278 | oncreate: ({ dom }) => { 279 | i = dom as HTMLInputElement; 280 | if (initialValue) i.value = initialValue; 281 | }, 282 | }) 283 | ), 284 | (canClear || initialValue) && 285 | m( 286 | 'a.waves-effect.waves-teal.btn-flat', 287 | { 288 | style: { 289 | float: 'right', 290 | position: 'relative', 291 | top: '-3rem', 292 | padding: 0, 293 | }, 294 | onclick: () => { 295 | canClear = false; 296 | i.value = ''; 297 | onchange && onchange({} as FileList); 298 | }, 299 | }, 300 | m('i.material-icons', 'clear') 301 | ), 302 | ] 303 | ); 304 | }, 305 | }; 306 | }; 307 | -------------------------------------------------------------------------------- /packages/lib/src/label.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Component, Attributes } from 'mithril'; 2 | import './styles/input.css'; 3 | 4 | export const Mandatory: Component = { view: ({ attrs }) => m('span.mandatory', attrs, '*') }; 5 | 6 | export interface IMaterialLabel extends Attributes { 7 | /** Optional title/label */ 8 | label?: string; 9 | /** Optional ID */ 10 | id?: string; 11 | /** If true, add a mandatory '*' after the label */ 12 | isMandatory?: boolean; 13 | /** Add the active class to the label */ 14 | isActive?: boolean | string; 15 | } 16 | 17 | /** Simple label element, used for most components. */ 18 | export const Label: FactoryComponent = () => { 19 | return { 20 | view: ({ attrs: { label, id, isMandatory, isActive, className, ...params } }) => 21 | label 22 | ? m( 23 | 'label', 24 | { 25 | ...params, 26 | className: [className, isActive ? 'active' : ''].filter(Boolean).join(' ').trim(), 27 | for: id, 28 | }, 29 | [m.trust(label), isMandatory ? m(Mandatory) : undefined] 30 | ) 31 | : undefined, 32 | }; 33 | }; 34 | 35 | export interface IHelperTextOptions extends Attributes { 36 | helperText?: string; 37 | dataError?: string; 38 | dataSuccess?: string; 39 | } 40 | 41 | /** Create a helper text, often used for displaying a small help text. May be replaced by the validation message. */ 42 | export const HelperText: FactoryComponent = () => { 43 | return { 44 | view: ({ attrs: { helperText, dataError, dataSuccess, className } }) => { 45 | return helperText || dataError || dataSuccess 46 | ? m( 47 | 'span.helper-text.left', 48 | { className, 'data-error': dataError, 'data-success': dataSuccess }, 49 | helperText ? m.trust(helperText) : '' 50 | ) 51 | : undefined; 52 | }, 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /packages/lib/src/map-editor.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | import './styles/map-editor.css'; 3 | import { ICollectionItem, Collection, CollectionMode } from './collection'; 4 | import { InputCheckbox } from './option'; 5 | import { uniqueId } from './utils'; 6 | import { Label } from './label'; 7 | import { TextArea, TextInput, NumberInput } from './input'; 8 | import { FlatButton } from './button'; 9 | 10 | export interface IMapEditor extends Attributes { 11 | /** Optional ID of the element */ 12 | id?: string; 13 | /** If true, displays a header over the map */ 14 | header?: string; 15 | /** Instead of a header, use a label */ 16 | label?: string; 17 | /** Places a required * after the label */ 18 | isMandatory?: boolean; 19 | /** 20 | * Optional value for the key label 21 | * @default: "Key" 22 | */ 23 | labelKey?: string; 24 | /** 25 | * Optional value for the value label 26 | * @default: "Value" 27 | */ 28 | labelValue?: string; 29 | /** If true, the item cannot be edited */ 30 | disabled?: boolean; 31 | /** Icon for the properties' collection */ 32 | iconName?: string; 33 | /** Icon for the key editor: if none provided, and the iconName is set, uses 'label' */ 34 | iconNameKey?: string; 35 | /** If true, do not parse arrays like [1, 2, 3] into number[] or [a, b, c] into a string[] */ 36 | disallowArrays?: boolean; 37 | /** The actual map of key-value pairs supports numbers, strings, booleans and arrays of strings and numbers. */ 38 | properties: { [key: string]: number | string | boolean | Array }; 39 | /** 40 | * Called when the properties collection has changed. Not needed if you are performing a direct edit on the 41 | * properties object, but in case you have created a mapping, this allows you to convert the object back again. 42 | */ 43 | onchange?: (properties: { [key: string]: number | string | boolean | Array }) => void; 44 | /** 45 | * In order to create a boolean, you first have to enter a truthy or falsy value. 46 | * Default 'true' and 'false', but you can add more options. 47 | */ 48 | truthy?: string[]; 49 | /** 50 | * In order to create a boolean, you first have to enter a truthy or falsy value. 51 | * Default 'true' and 'false', but you can add more options. 52 | */ 53 | falsy?: string[]; 54 | /** 55 | * Optional function to replace the render function of a key-value pair. 56 | * The ICollectionItems's title may be a Vnode. 57 | */ 58 | keyValueConverter?: (key: string, value: number | string | boolean | Array) => ICollectionItem; 59 | /** Optional class to apply to the key column, @default .col.s4 */ 60 | keyClass?: string; 61 | /** Optional class to apply to the value column, @default .col.s8 */ 62 | valueClass?: string; 63 | } 64 | 65 | /** A simple viewer and/or editor for a map of key - value pairs */ 66 | export const MapEditor: FactoryComponent = () => { 67 | const parseArray = (v?: string, disallowArrays = false) => { 68 | if (disallowArrays) { 69 | return v; 70 | } 71 | const extractArrayData = /\s*\[(.*)\]\s*/gi; 72 | if (!v) { 73 | return undefined; 74 | } 75 | const match = extractArrayData.exec(v); 76 | if (!match || match.length !== 2) { 77 | return undefined; 78 | } 79 | return match[1] 80 | .split(',') 81 | .map(i => i.trim()) 82 | .map(i => (/^\d+$/g.test(i) ? +i : i)); 83 | }; 84 | 85 | const kvc = ( 86 | key: string, 87 | value: number | string | boolean | Array, 88 | options: { keyClass?: string; valueClass?: string } 89 | ) => { 90 | const { keyClass = '.col.s4', valueClass = '.col.s8' } = options; 91 | const displayValue = 92 | value instanceof Array 93 | ? value.join(', ') 94 | : typeof value === 'boolean' 95 | ? m(InputCheckbox, { label: ' ', checked: value, disabled: true, className: 'checkbox-in-collection' }) 96 | : value.toString(); 97 | const title = m('.row', { style: 'margin-bottom: 0' }, [m(keyClass, m('b', key)), m(valueClass, displayValue)]); 98 | return { 99 | title, 100 | } as ICollectionItem; 101 | }; 102 | 103 | const onclick = (key: string) => (state.curKey = state.id = key); 104 | 105 | const kvcWrapper = (key: string, item: ICollectionItem) => { 106 | const clickHandler = item.onclick; 107 | item.id = item.id || key; 108 | item.active = key === state.curKey; 109 | item.onclick = clickHandler ? () => onclick(key) && clickHandler(item) : () => onclick(key); 110 | return item; 111 | }; 112 | 113 | const toCollectionArray = ( 114 | properties: { [key: string]: number | string | boolean | Array }, 115 | options: { keyClass?: string; valueClass?: string } 116 | ) => 117 | Object.keys(properties) 118 | .map(key => ({ key, value: properties[key] })) 119 | .map(item => 120 | kvcWrapper( 121 | item.key, 122 | state.kvc(item.key, item.value, { keyClass: options.keyClass, valueClass: options.valueClass }) 123 | ) 124 | ); 125 | 126 | const isTruthy = (i: string, truthy: string[], falsy: string[]) => 127 | truthy.indexOf(i) >= 0 ? true : falsy.indexOf(i) >= 0 ? false : undefined; 128 | 129 | const state = { 130 | elementId: uniqueId(), 131 | id: '', 132 | curKey: '', 133 | kvc, 134 | }; 135 | 136 | const resetInputs = () => { 137 | state.id = ''; 138 | state.curKey = ''; 139 | }; 140 | 141 | return { 142 | oninit: ({ attrs: { keyValueConverter, id } }) => { 143 | if (keyValueConverter) { 144 | state.kvc = keyValueConverter; 145 | } 146 | if (id) { 147 | state.elementId = id; 148 | } 149 | }, 150 | view: ({ 151 | attrs: { 152 | className = 'col s12', 153 | disabled, 154 | disallowArrays, 155 | header, 156 | iconName, 157 | iconNameKey = iconName ? 'label' : undefined, 158 | isMandatory, 159 | label, 160 | labelKey = 'Key', 161 | labelValue = 'Value', 162 | properties, 163 | keyClass, 164 | valueClass, 165 | onchange, 166 | falsy = ['false'], 167 | truthy = ['true'], 168 | }, 169 | }) => { 170 | const notify = () => (onchange ? onchange(properties) : undefined); 171 | const items = toCollectionArray(properties, { keyClass, valueClass }); 172 | const key = state.curKey; 173 | const prop = properties[key]; 174 | const value = 175 | typeof prop === 'boolean' || typeof prop === 'number' 176 | ? prop 177 | : prop 178 | ? prop instanceof Array 179 | ? `[${prop.join(', ')}]` 180 | : prop 181 | : ''; 182 | const id = state.elementId; 183 | return [ 184 | m( 185 | '.map-editor', 186 | m('.input-field', { className, style: 'min-height: 1.5em;' }, [ 187 | iconName ? m('i.material-icons.prefix', iconName) : '', 188 | m(Label, { label, isMandatory, isActive: items.length > 0 }), 189 | m(Collection, { id, items, mode: CollectionMode.LINKS, header }), 190 | ]) 191 | ), 192 | disabled 193 | ? undefined 194 | : [ 195 | m(TextInput, { 196 | label: labelKey, 197 | iconName: iconNameKey, 198 | className: 'col s5', 199 | initialValue: key, 200 | onchange: (v: string) => { 201 | state.curKey = v; 202 | if (state.id) { 203 | delete properties[state.id]; 204 | properties[v] = prop; 205 | state.id = v; 206 | } 207 | notify(); 208 | }, 209 | }), 210 | typeof value === 'string' 211 | ? m(TextArea, { 212 | label: labelValue, 213 | initialValue: value, 214 | className: 'col s7', 215 | onchange: (v: string) => { 216 | const b = isTruthy(v, truthy, falsy); 217 | const n = typeof b === 'undefined' ? (/^\s*\d+\s*$/i.test(v) ? +v : undefined) : undefined; 218 | properties[key] = 219 | typeof b === 'boolean' ? b : typeof n === 'number' ? n : parseArray(v, disallowArrays) || v; 220 | notify(); 221 | }, 222 | }) 223 | : typeof value === 'number' 224 | ? m(NumberInput, { 225 | label: labelValue, 226 | initialValue: value, 227 | className: 'col s7', 228 | onchange: (v: number) => { 229 | properties[key] = v; 230 | notify(); 231 | }, 232 | }) 233 | : m(InputCheckbox, { 234 | label: labelValue, 235 | checked: value, 236 | className: 'input-field col s7', 237 | onchange: (v: boolean) => { 238 | properties[key] = v; 239 | notify(); 240 | }, 241 | }), 242 | m('.col.s12.right-align', [ 243 | m(FlatButton, { 244 | iconName: 'add', 245 | onclick: resetInputs, 246 | }), 247 | m(FlatButton, { 248 | iconName: 'delete', 249 | disabled: !key, 250 | onclick: () => { 251 | delete properties[key]; 252 | resetInputs(); 253 | notify(); 254 | }, 255 | }), 256 | ]), 257 | ], 258 | ]; 259 | }, 260 | }; 261 | }; 262 | -------------------------------------------------------------------------------- /packages/lib/src/material-box.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | 3 | export interface IMaterialBox extends Partial, Attributes { 4 | /** Source image path */ 5 | src: string; 6 | /** 7 | * Width of the image 8 | * @default undefined 9 | */ 10 | width?: number; 11 | /** 12 | * Height of the image 13 | * @default undefined 14 | */ 15 | height?: number; 16 | } 17 | 18 | /** 19 | * Create an image box, that, when clicked upon, fills the screen. 20 | * @see https://materializecss.com/media.html 21 | */ 22 | export const MaterialBox: FactoryComponent = () => { 23 | return { 24 | oncreate: ({ dom, attrs }) => { 25 | M.Materialbox.init(dom, attrs); 26 | }, 27 | view: ({ attrs }) => m('img.materialboxed', attrs), 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/lib/src/modal.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Vnode, Attributes } from 'mithril'; 2 | import { FlatButton } from './button'; 3 | 4 | export interface IMaterialModal extends Attributes { 5 | id: string; 6 | title: string; 7 | description?: string | Vnode; 8 | /** Set to true when the description contains HTML */ 9 | richContent?: boolean; 10 | /** Fixate the footer, so you can show more content. */ 11 | fixedFooter?: boolean; 12 | /** Display on the bottom */ 13 | bottomSheet?: boolean; 14 | /** Materialize css' modal options */ 15 | options?: Partial; 16 | /** Menu buttons, from left to right */ 17 | buttons?: Array<{ label: string; iconName?: string; disabled?: boolean; onclick?: (e: UIEvent) => void }>; 18 | /** Get the modal instance, so you can control it programmatically */ 19 | onCreate?: (modal: M.Modal) => void; 20 | } 21 | 22 | /** Builds a modal panel, which can be triggered using its id */ 23 | export const ModalPanel: FactoryComponent = () => ({ 24 | oncreate: ({ dom, attrs: { options, onCreate } }) => { 25 | const modal = M.Modal.init(dom, options); 26 | if (onCreate) { 27 | onCreate(modal); 28 | } 29 | }, 30 | view: ({ attrs: { id, title, description, fixedFooter, bottomSheet, buttons, richContent, className } }) => { 31 | const cn = [className, fixedFooter ? 'modal-fixed-footer' : '', bottomSheet ? 'bottom-sheet' : ''] 32 | .filter(Boolean) 33 | .join(' ') 34 | .trim(); 35 | return m( 36 | '.modal', 37 | { 38 | id, 39 | className: cn, 40 | }, 41 | [ 42 | m('.modal-content', [ 43 | m('h4', title), 44 | richContent && typeof description === 'string' 45 | ? m.trust(description || '') 46 | : typeof description === 'string' 47 | ? m('p', description) 48 | : description, 49 | ]), 50 | buttons 51 | ? m( 52 | '.modal-footer', 53 | buttons.map((props) => m(FlatButton, { ...props, className: 'modal-close' })) 54 | ) 55 | : undefined, 56 | ] 57 | ); 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /packages/lib/src/option.ts: -------------------------------------------------------------------------------- 1 | import m, { Vnode, FactoryComponent, Attributes, Component } from 'mithril'; 2 | import { Label, HelperText } from './label'; 3 | 4 | export interface IInputCheckbox extends Attributes { 5 | /** Optional event handler when a checkbox is clicked */ 6 | onchange?: (checked: boolean) => void; 7 | /** Label of the checkbox, can be a string or Vnode */ 8 | label?: string | Vnode; 9 | /** If true, the checkbox is checked */ 10 | checked?: boolean; 11 | /** If true, the checkbox is disabled */ 12 | disabled?: boolean; 13 | } 14 | 15 | /** Component to show a check box */ 16 | export const InputCheckbox: FactoryComponent = () => { 17 | return { 18 | view: ({ attrs: { className = 'col s12', onchange, label, checked, disabled, description, style } }) => { 19 | return m( 20 | `div`, 21 | { className, style }, 22 | m('label', [ 23 | m('input[type=checkbox][tabindex=0]', { 24 | checked, 25 | disabled, 26 | onclick: onchange 27 | ? (e: Event) => { 28 | if (e.target && typeof (e.target as HTMLInputElement).checked !== 'undefined') { 29 | onchange((e.target as HTMLInputElement).checked); 30 | } 31 | } 32 | : undefined, 33 | }), 34 | label ? (typeof label === 'string' ? m('span', label) : label) : undefined, 35 | ]), 36 | description && m(HelperText, { className: 'input-checkbox-desc', helperText: description }) 37 | ); 38 | }, 39 | }; 40 | }; 41 | 42 | export interface IInputOption { 43 | /** Option ID */ 44 | id: T; 45 | /** Displayed label */ 46 | label: string; 47 | /** Optional title, often used to display a tooltip - will only work when choosing browser-defaults */ 48 | title?: string; 49 | /** Is the option disabled? */ 50 | disabled?: boolean; 51 | /** Select image */ 52 | img?: string; 53 | /** Select group label */ 54 | group?: string; 55 | /** Optional class name */ 56 | className?: string; 57 | /** Optional description */ 58 | description?: string; 59 | } 60 | 61 | export interface IOptions extends Attributes { 62 | /** Element ID */ 63 | id?: string; 64 | /** Optional title or label */ 65 | label?: string; 66 | /** The options that you have */ 67 | options: IInputOption[]; 68 | /** Event handler that is called when an option is changed */ 69 | onchange?: (checkedId: T[]) => void; 70 | /** 71 | * Selected id or ids (in case of multiple options) 72 | * @deprecated Please use initialValue instead 73 | */ 74 | checkedId?: T | T[]; 75 | /** Selected id or ids (in case of multiple options) */ 76 | initialValue?: T | T[]; 77 | /** Optional description */ 78 | description?: string; 79 | /** Optional CSS that is added to the input checkbox, e.g. if you add col s4, the items will be put inline */ 80 | checkboxClass?: string; 81 | /** If true, start on a new row */ 82 | newRow?: boolean; 83 | /** If true, add a mandatory '*' after the label */ 84 | isMandatory?: boolean; 85 | /** If true, disable the options. */ 86 | disabled?: boolean; 87 | } 88 | 89 | /** A list of checkboxes */ 90 | export const Options = (): Component> => { 91 | const state = {} as { 92 | checkedId?: T | T[]; 93 | checkedIds: T[]; 94 | }; 95 | 96 | const isChecked = (id: T) => state.checkedIds.indexOf(id) >= 0; 97 | 98 | return { 99 | oninit: ({ attrs: { initialValue, checkedId } }) => { 100 | const iv = checkedId || initialValue; 101 | state.checkedId = checkedId; 102 | state.checkedIds = iv ? (iv instanceof Array ? [...iv] : [iv]) : []; 103 | }, 104 | view: ({ 105 | attrs: { 106 | label, 107 | id, 108 | options, 109 | checkedId, 110 | description, 111 | className = 'col s12', 112 | style, 113 | disabled, 114 | checkboxClass, 115 | newRow, 116 | isMandatory, 117 | onchange: callback, 118 | }, 119 | }) => { 120 | if (checkedId && state.checkedId !== checkedId) { 121 | state.checkedId = checkedId; 122 | state.checkedIds = checkedId instanceof Array ? checkedId : [checkedId]; 123 | } 124 | const onchange = callback 125 | ? (propId: T, checked: boolean) => { 126 | const checkedIds = state.checkedIds.filter((i) => i !== propId); 127 | if (checked) { 128 | checkedIds.push(propId); 129 | } 130 | state.checkedIds = checkedIds; 131 | callback(checkedIds); 132 | } 133 | : undefined; 134 | const cn = [newRow ? 'clear' : '', className].filter(Boolean).join(' ').trim(); 135 | return m('div', { className: cn, style }, [ 136 | m('div', { className: 'input-field options' }, m(Label, { id, label, isMandatory })), 137 | m(HelperText, { helperText: description }), 138 | ...options.map((option) => 139 | m(InputCheckbox, { 140 | disabled: disabled || option.disabled, 141 | label: option.label, 142 | onchange: onchange ? (v: boolean) => onchange(option.id, v) : undefined, 143 | className: option.className || checkboxClass, 144 | checked: isChecked(option.id), 145 | description: option.description, 146 | }) 147 | ), 148 | ]); 149 | }, 150 | }; 151 | }; 152 | -------------------------------------------------------------------------------- /packages/lib/src/pagination.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes, Vnode } from 'mithril'; 2 | 3 | export interface IInternalPaginationOption extends IPaginationOption { 4 | active?: boolean; 5 | title: number | Vnode; 6 | } 7 | 8 | export interface IPaginationOption extends Attributes { 9 | href: string; 10 | disabled?: boolean; 11 | } 12 | 13 | const PaginationItem: FactoryComponent = () => ({ 14 | view: ({ attrs: { title, href, active, disabled } }) => 15 | m( 16 | 'li', 17 | { className: active ? 'active' : disabled ? 'disabled' : 'waves-effect' }, 18 | typeof title === 'number' ? m(m.route.Link, { href }, title) : title 19 | ), 20 | }); 21 | 22 | export interface IPaginationOptions extends Attributes { 23 | /** 24 | * How many items do we show 25 | * @default 9 or items.length, whatever is the smallest 26 | */ 27 | size?: number; 28 | /** The active page index */ 29 | curPage?: number; 30 | items: IPaginationOption[]; 31 | } 32 | 33 | export const Pagination: FactoryComponent = () => { 34 | const state = { 35 | pagIndex: 0, 36 | }; 37 | return { 38 | view: ({ attrs: { items, curPage = 1, size = Math.min(9, items.length) } }) => { 39 | const { pagIndex } = state; 40 | const startPage = pagIndex * size; 41 | const endPage = startPage + size; 42 | const canGoBack = pagIndex > 0; 43 | const canGoForward = endPage < items.length; 44 | const displayedItems = [ 45 | { 46 | title: m( 47 | 'a', 48 | { 49 | onclick: () => canGoBack && state.pagIndex--, 50 | }, 51 | m('i.material-icons', 'chevron_left') 52 | ), 53 | disabled: !canGoBack, 54 | }, 55 | ...items.filter((_, i) => startPage <= i && i < endPage), 56 | { 57 | title: m( 58 | 'a', 59 | { 60 | onclick: () => canGoForward && state.pagIndex++, 61 | }, 62 | m('i.material-icons', 'chevron_right') 63 | ), 64 | disabled: !canGoForward, 65 | }, 66 | ] as ( 67 | | IPaginationOption 68 | | { 69 | title?: m.Vnode; 70 | disabled: boolean; 71 | } 72 | )[]; 73 | return m( 74 | 'ul.pagination', 75 | displayedItems.map((item, i) => 76 | m(PaginationItem, { 77 | title: startPage + i, 78 | ...item, 79 | active: startPage + i === curPage, 80 | }) 81 | ) 82 | ); 83 | }, 84 | }; 85 | }; 86 | -------------------------------------------------------------------------------- /packages/lib/src/parallax.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent, Attributes } from 'mithril'; 2 | 3 | export interface IParallax extends Partial, Attributes { 4 | /** Image source */ 5 | src: string; 6 | } 7 | 8 | /** 9 | * Parallax component: 10 | * Parallax is an effect where the background content or image in this case, 11 | * is moved at a different speed than the foreground content while scrolling. 12 | * @see https://materializecss.com/parallax.html 13 | */ 14 | export const Parallax: FactoryComponent = () => { 15 | return { 16 | oncreate: ({ dom, attrs }) => { 17 | M.Parallax.init(dom, attrs); 18 | }, 19 | view: ({ attrs: { src } }) => (src ? m('.parallax-container', m('.parallax', m('img', { src }))) : undefined), 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/lib/src/pickers.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent } from 'mithril'; 2 | import { IInputOptions } from './input-options'; 3 | import { uniqueId } from './utils'; 4 | import { Label, HelperText } from './label'; 5 | 6 | /** Component to pick a date */ 7 | export const DatePicker: FactoryComponent & Partial> = () => { 8 | const state = { id: uniqueId() } as { id: string; dp: M.Datepicker }; 9 | return { 10 | view: ({ 11 | attrs: { 12 | label, 13 | helperText, 14 | initialValue, 15 | newRow, 16 | className = 'col s12', 17 | iconName, 18 | isMandatory, 19 | onchange, 20 | disabled, 21 | ...props 22 | }, 23 | }) => { 24 | const id = state.id; 25 | // const attributes = toAttrs(props); 26 | const onClose = onchange ? () => state.dp && onchange(state.dp.date) : undefined; 27 | const cn = [newRow ? 'clear' : '', className].filter(Boolean).join(' ').trim(); 28 | return m( 29 | '.input-field', 30 | { 31 | className: cn, 32 | onremove: () => { 33 | return state.dp && state.dp.destroy(); 34 | }, 35 | }, 36 | [ 37 | iconName ? m('i.material-icons.prefix', iconName) : '', 38 | m('input', { 39 | ...props, 40 | type: 'text', 41 | tabindex: 0, 42 | className: 'datepicker', 43 | id, 44 | // attributes, 45 | disabled, 46 | oncreate: ({ dom }) => { 47 | state.dp = M.Datepicker.init(dom, { 48 | format: 'yyyy/mm/dd', 49 | showClearBtn: true, 50 | setDefaultDate: true, 51 | defaultDate: initialValue ? new Date(initialValue) : new Date(), 52 | // onSelect: onchange, 53 | ...props, 54 | onClose, 55 | } as Partial); 56 | }, 57 | }), 58 | m(Label, { label, id, isMandatory, isActive: !!initialValue }), 59 | m(HelperText, { helperText }), 60 | ] 61 | ); 62 | }, 63 | }; 64 | }; 65 | 66 | /** Component to pick a time */ 67 | export const TimePicker: FactoryComponent> = () => { 68 | const state = { id: uniqueId() } as { id: string; tp: M.Timepicker }; 69 | return { 70 | view: ({ 71 | attrs: { 72 | label, 73 | helperText, 74 | initialValue, 75 | newRow, 76 | className = 'col s12', 77 | iconName, 78 | isMandatory, 79 | onchange, 80 | disabled, 81 | ...props 82 | }, 83 | }) => { 84 | const id = state.id; 85 | // const attributes = toAttrs(props); 86 | const now = new Date(); 87 | const onCloseEnd = onchange 88 | ? () => state.tp && onchange(state.tp.time || initialValue || `${now.getHours()}:${now.getMinutes()}`) 89 | : undefined; 90 | const cn = ['input-field', 'timepicker', newRow ? 'clear' : '', className].filter(Boolean).join(' ').trim(); 91 | return m( 92 | 'div', 93 | { 94 | className: cn, 95 | onremove: () => state.tp && state.tp.destroy(), 96 | }, 97 | [ 98 | iconName ? m('i.material-icons.prefix', iconName) : '', 99 | m('input', { 100 | ...props, 101 | type: 'text', 102 | tabindex: 0, 103 | id, 104 | disabled, 105 | value: initialValue, 106 | oncreate: ({ dom }) => { 107 | state.tp = M.Timepicker.init(dom, { 108 | twelveHour: false, 109 | showClearBtn: true, 110 | defaultTime: initialValue, 111 | // onSelect: onchange ? (hours: number, minutes: number) => onchange(`${hours}:${minutes}`) : undefined, 112 | ...props, 113 | onCloseEnd, 114 | } as Partial); 115 | }, 116 | }), 117 | m(Label, { label, id, isMandatory, isActive: initialValue }), 118 | m(HelperText, { helperText }), 119 | ] 120 | ); 121 | }, 122 | }; 123 | }; 124 | -------------------------------------------------------------------------------- /packages/lib/src/radio.ts: -------------------------------------------------------------------------------- 1 | import m, { Attributes, Component } from 'mithril'; 2 | import { uniqueId } from './utils'; 3 | import { IInputOption } from './option'; 4 | import { Label } from './label'; 5 | 6 | export interface IRadioButtons extends Attributes { 7 | /** Element ID */ 8 | id?: string; 9 | /** Optional title or label */ 10 | label?: string; 11 | /** The options that you have */ 12 | options: IInputOption[]; 13 | /** Event handler that is called when an option is changed */ 14 | onchange: (id: T) => void; 15 | /** Selected id (in oninit lifecycle) */ 16 | initialValue?: T; 17 | /** Selected id (in oninit and onupdate lifecycle) */ 18 | checkedId?: T; 19 | /** Optional description */ 20 | description?: string; 21 | /** If true, start on a new row */ 22 | newRow?: boolean; 23 | /** If true, add a mandatory '*' after the label */ 24 | isMandatory?: boolean; 25 | /** Optional CSS that is added to the input checkbox, e.g. if you add col s4, the items will be put inline */ 26 | checkboxClass?: string; 27 | /** Disable the button */ 28 | disabled?: boolean; 29 | } 30 | 31 | export interface IRadioButton extends Attributes { 32 | id: T; 33 | checked?: boolean; 34 | onchange: (id: T) => void; 35 | label: string; 36 | groupId: string; 37 | disabled?: boolean; 38 | } 39 | 40 | export const RadioButton = (): Component> => ({ 41 | view: ({ attrs: { id, groupId, label, onchange, className = 'col s12', checked, disabled } }) => { 42 | return m( 43 | 'div', 44 | { className }, 45 | m('label', [ 46 | m('input[type=radio][tabindex=0]', { 47 | name: groupId, 48 | disabled, 49 | checked, 50 | onclick: onchange ? () => onchange(id) : undefined, 51 | }), 52 | m('span', m.trust(label)), 53 | ]) 54 | ); 55 | }, 56 | }); 57 | 58 | /** Component to show a list of radio buttons, from which you can choose one. */ 59 | // export const RadioButtons: FactoryComponent> = () => { 60 | export const RadioButtons = (): Component> => { 61 | const state = { groupId: uniqueId() } as { 62 | groupId: string; 63 | oldCheckedId?: T; 64 | checkedId?: T; 65 | onchange: (id: T) => void; 66 | }; 67 | return { 68 | oninit: ({ attrs: { checkedId, initialValue } }) => { 69 | state.oldCheckedId = checkedId; 70 | state.checkedId = checkedId || initialValue; 71 | }, 72 | view: ({ 73 | attrs: { 74 | id, 75 | checkedId: cid, 76 | newRow, 77 | className = 'col s12', 78 | label = '', 79 | disabled, 80 | description, 81 | options, 82 | isMandatory, 83 | checkboxClass, 84 | onchange: callback, 85 | }, 86 | }) => { 87 | if (state.oldCheckedId !== cid) { 88 | state.oldCheckedId = state.checkedId = cid; 89 | } 90 | const { groupId, checkedId } = state; 91 | const onchange = (propId: T) => { 92 | state.checkedId = propId; 93 | if (callback) { 94 | callback(propId); 95 | } 96 | }; 97 | 98 | if (newRow) className += ' clear'; 99 | return m('div', { id, className }, [ 100 | m('div', { className: 'input-field options' }, m(Label, { id, label, isMandatory })), 101 | description ? m('p.helper-text', m.trust(description)) : '', 102 | ...options.map((r) => 103 | m(RadioButton, { 104 | ...r, 105 | onchange, 106 | groupId, 107 | disabled, 108 | className: checkboxClass, 109 | checked: r.id === checkedId, 110 | } as IRadioButton) 111 | ), 112 | ]); 113 | }, 114 | }; 115 | }; 116 | -------------------------------------------------------------------------------- /packages/lib/src/search-select.ts: -------------------------------------------------------------------------------- 1 | import m, { Attributes, Component } from 'mithril'; 2 | 3 | // Option interface for type safety 4 | export interface Option { 5 | id: T; 6 | label?: string; 7 | disabled?: boolean; 8 | } 9 | 10 | // Component attributes interface 11 | export interface SearchSelectAttrs extends Attributes { 12 | /** Options to display in the select */ 13 | options?: Option[]; 14 | /** Initial value */ 15 | initialValue?: T[]; 16 | /** Callback when user selects or deselects an option */ 17 | onchange?: (selectedOptions: T[]) => void | Promise; 18 | /** Callback when user creates a new option: should return new ID */ 19 | oncreateNewOption?: (term: string) => Option | Promise>; 20 | /** Label for the search select, no default */ 21 | label?: string; 22 | /** Placeholder text for the search input, no default */ 23 | placeholder?: string; 24 | /** Placeholder text for the search input, default 'Search options...' */ 25 | searchPlaceholder?: string; 26 | /** When no options are left, displays this text, default 'No options found' */ 27 | noOptionsFound?: string; 28 | /** Max height of the dropdown menu, default '25rem' */ 29 | maxHeight?: string; 30 | } 31 | 32 | // Component state interface 33 | interface SearchSelectState { 34 | isOpen: boolean; 35 | selectedOptions: Option[]; 36 | searchTerm: string; 37 | options: Option[]; 38 | inputRef: HTMLElement | null; 39 | dropdownRef: HTMLElement | null; 40 | focusedIndex: number; 41 | onchange: any; 42 | } 43 | 44 | /** 45 | * Mithril Factory Component for Multi-Select Dropdown with search 46 | */ 47 | export const SearchSelect = (): Component, SearchSelectState> => { 48 | // (): (): Component, SearchSelectState> => { 49 | // State initialization 50 | const state: SearchSelectState = { 51 | isOpen: false, 52 | selectedOptions: [], //options.filter((o) => iv.includes(o.id)), 53 | searchTerm: '', 54 | options: [], 55 | inputRef: null, 56 | dropdownRef: null, 57 | focusedIndex: -1, 58 | onchange: null, 59 | }; 60 | 61 | // Handle click outside 62 | const handleClickOutside = (e: MouseEvent) => { 63 | const target = e.target as Node; 64 | if (state.inputRef && state.inputRef.contains(target)) { 65 | state.isOpen = !state.isOpen; 66 | m.redraw(); 67 | } else if (state.dropdownRef && !state.dropdownRef.contains(target)) { 68 | state.isOpen = false; 69 | m.redraw(); 70 | } 71 | }; 72 | 73 | // Handle keyboard navigation 74 | const handleKeyDown = (e: KeyboardEvent) => { 75 | if (!state.isOpen) return; 76 | 77 | const filteredOptions = state.options.filter( 78 | (option) => 79 | (option.label || option.id.toString()).toLowerCase().includes((state.searchTerm || '').toLowerCase()) && 80 | !state.selectedOptions.some((selected) => selected.id === option.id) 81 | ); 82 | 83 | switch (e.key) { 84 | case 'ArrowDown': 85 | e.preventDefault(); 86 | state.focusedIndex = Math.min(state.focusedIndex + 1, filteredOptions.length - 1); 87 | m.redraw(); 88 | break; 89 | case 'ArrowUp': 90 | e.preventDefault(); 91 | state.focusedIndex = Math.max(state.focusedIndex - 1, -1); 92 | m.redraw(); 93 | break; 94 | case 'Enter': 95 | e.preventDefault(); 96 | if (state.focusedIndex >= 0 && state.focusedIndex < filteredOptions.length) { 97 | toggleOption(filteredOptions[state.focusedIndex]); 98 | } 99 | break; 100 | case 'Escape': 101 | e.preventDefault(); 102 | state.isOpen = false; 103 | state.focusedIndex = -1; 104 | m.redraw(); 105 | break; 106 | } 107 | }; 108 | 109 | // Toggle option selection 110 | const toggleOption = (option: Option) => { 111 | if (option.disabled) return; 112 | 113 | state.selectedOptions = state.selectedOptions.some((item) => item.id === option.id) 114 | ? state.selectedOptions.filter((item) => item.id !== option.id) 115 | : [...state.selectedOptions, option]; 116 | state.searchTerm = ''; 117 | state.focusedIndex = -1; 118 | state.onchange && state.onchange(state.selectedOptions.map((o) => o.id)); 119 | m.redraw(); 120 | }; 121 | 122 | // Remove a selected option 123 | const removeOption = (option: Option) => { 124 | state.selectedOptions = state.selectedOptions.filter((item) => item.id !== option.id); 125 | state.onchange && state.onchange(state.selectedOptions.map((o) => o.id)); 126 | m.redraw(); 127 | }; 128 | 129 | return { 130 | oninit: ({ attrs: { options = [], initialValue = [], onchange } }) => { 131 | state.options = options; 132 | state.selectedOptions = options.filter((o) => initialValue.includes(o.id)); 133 | state.onchange = onchange; 134 | }, 135 | oncreate() { 136 | document.addEventListener('click', handleClickOutside); 137 | document.addEventListener('keydown', handleKeyDown); 138 | }, 139 | onremove() { 140 | document.removeEventListener('click', handleClickOutside); 141 | document.removeEventListener('keydown', handleKeyDown); 142 | }, 143 | view({ 144 | attrs: { 145 | // onchange, 146 | oncreateNewOption, 147 | className, 148 | placeholder, 149 | searchPlaceholder = 'Search options...', 150 | noOptionsFound = 'No options found', 151 | label, 152 | maxHeight = '25rem', 153 | }, 154 | }) { 155 | // Safely filter options 156 | const filteredOptions = state.options.filter( 157 | (option) => 158 | (option.label || option.id.toString()).toLowerCase().includes((state.searchTerm || '').toLowerCase()) && 159 | !state.selectedOptions.some((selected) => selected.id === option.id) 160 | ); 161 | 162 | // Check if we should show the "add new option" element 163 | const showAddNew = 164 | oncreateNewOption && 165 | state.searchTerm && 166 | !filteredOptions.some((o) => (o.label || o.id.toString()).toLowerCase() === state.searchTerm.toLowerCase()); 167 | 168 | // Render the dropdown 169 | return m('.multi-select-dropdown.input-field', { className }, [ 170 | m( 171 | 'label', 172 | { 173 | class: placeholder || state.selectedOptions.length > 0 ? 'active' : '', 174 | }, 175 | label 176 | ), 177 | m( 178 | '.dropdown-trigger', 179 | { 180 | oncreate: ({ dom }) => { 181 | state.inputRef = dom as HTMLElement; 182 | }, 183 | style: { 184 | borderBottom: '2px solid #d1d5db', 185 | display: 'flex', 186 | justifyContent: 'space-between', 187 | alignItems: 'center', 188 | cursor: 'pointer', 189 | }, 190 | }, 191 | [ 192 | // Selected Options 193 | m( 194 | '.selected-options', 195 | { 196 | style: { 197 | display: 'flex', 198 | flexWrap: 'wrap', 199 | minHeight: '50px', 200 | paddingTop: '12px', 201 | }, 202 | }, 203 | state.selectedOptions.length === 0 204 | ? [m('span', placeholder)] 205 | : state.selectedOptions.map((option) => 206 | m('.chip', [ 207 | option.label || option.id.toString(), 208 | m( 209 | 'button', 210 | { 211 | onclick: (e: Event) => { 212 | e.stopPropagation(); 213 | removeOption(option); 214 | }, 215 | style: { 216 | marginLeft: '0.25rem', 217 | background: 'none', 218 | border: 'none', 219 | cursor: 'pointer', 220 | }, 221 | }, 222 | '×' 223 | ), 224 | ]) 225 | ) 226 | ), 227 | // Dropdown Icon 228 | m( 229 | 'svg.caret', 230 | { 231 | class: 'caret', 232 | height: '24', 233 | viewBox: '0 0 24 24', 234 | width: '24', 235 | xmlns: 'http://www.w3.org/2000/svg', 236 | }, 237 | [m('path', { d: 'M7 10l5 5 5-5z' }), m('path', { d: 'M0 0h24v24H0z', fill: 'none' })] 238 | ), 239 | ] 240 | ), 241 | // Dropdown Menu 242 | state.isOpen && 243 | m( 244 | '.dropdown-menu', 245 | { 246 | oncreate: ({ dom }) => { 247 | state.dropdownRef = dom as HTMLElement; 248 | }, 249 | onremove: () => { 250 | state.dropdownRef = null; 251 | }, 252 | style: { 253 | position: 'absolute', 254 | width: '98%', 255 | marginTop: '0.4rem', 256 | zIndex: 1000, 257 | }, 258 | }, 259 | [ 260 | // Options List 261 | m( 262 | 'ul.dropdown-content.select-dropdown', 263 | { 264 | style: { 265 | maxHeight, 266 | opacity: 1, 267 | display: 'block', 268 | width: '100%', 269 | }, 270 | }, 271 | [ 272 | m( 273 | 'li', // Search Input 274 | { 275 | class: 'search-wrapper', 276 | style: { padding: '0 16px', position: 'relative' }, 277 | }, 278 | [ 279 | m('input', { 280 | type: 'text', 281 | placeholder: searchPlaceholder, 282 | value: state.searchTerm || '', 283 | oninput: (e: InputEvent) => { 284 | state.searchTerm = (e.target as HTMLInputElement).value; 285 | state.focusedIndex = -1; // Reset focus when typing 286 | m.redraw(); 287 | }, 288 | style: { 289 | width: '100%', 290 | outline: 'none', 291 | fontSize: '0.875rem', 292 | }, 293 | }), 294 | ] 295 | ), 296 | 297 | // No options found message or list of options 298 | ...(filteredOptions.length === 0 && !showAddNew 299 | ? [ 300 | m( 301 | 'li', 302 | { 303 | style: { 304 | padding: '0.5rem', 305 | textAlign: 'center', 306 | color: '#9ca3af', 307 | }, 308 | }, 309 | noOptionsFound 310 | ), 311 | ] 312 | : []), 313 | 314 | // Add new option item 315 | ...(showAddNew 316 | ? [ 317 | m( 318 | 'li', 319 | { 320 | onclick: async () => { 321 | const option = await oncreateNewOption(state.searchTerm); 322 | toggleOption(option); 323 | }, 324 | style: { 325 | display: 'flex', 326 | alignItems: 'center', 327 | cursor: 'pointer', 328 | background: state.focusedIndex === filteredOptions.length ? '#f3f4f6' : '', 329 | }, 330 | }, 331 | [m('span', `+ "${state.searchTerm}"`)] 332 | ), 333 | ] 334 | : []), 335 | 336 | // List of filtered options 337 | ...filteredOptions.map((option, index) => 338 | m( 339 | 'li', 340 | { 341 | onclick: () => toggleOption(option), 342 | class: option.disabled ? 'disabled' : undefined, 343 | style: { 344 | display: 'flex', 345 | alignItems: 'center', 346 | cursor: option.disabled ? 'not-allowed' : 'pointer', 347 | background: state.focusedIndex === index ? '#f3f4f6' : '', 348 | }, 349 | }, 350 | m('span', [ 351 | m('input', { 352 | type: 'checkbox', 353 | checked: state.selectedOptions.some((selected) => selected.id === option.id), 354 | style: { marginRight: '0.5rem' }, 355 | }), 356 | option.label || option.id.toString(), 357 | ]) 358 | ) 359 | ), 360 | ] 361 | ), 362 | ] 363 | ), 364 | ]); 365 | }, 366 | }; 367 | }; 368 | -------------------------------------------------------------------------------- /packages/lib/src/select.ts: -------------------------------------------------------------------------------- 1 | import m, { Attributes, Component } from 'mithril'; 2 | import { isNumeric } from './utils'; 3 | import { Label, HelperText } from './label'; 4 | import { IInputOption } from './option'; 5 | 6 | export interface ISelectOptions extends Attributes, Partial { 7 | /** Options to select from */ 8 | options: IInputOption[]; 9 | /** Called when the value is changed, either contains a single or all selected (checked) ids */ 10 | onchange: (checkedIds: T[]) => void; 11 | /** 12 | * Selected id or ids (in case of multiple options). Processed in the oninit and onupdate lifecycle. 13 | * When the checkedId property changes (using a shallow compare), the selections are updated accordingly. 14 | */ 15 | checkedId?: T | T[]; 16 | /** Selected id or ids (in case of multiple options). Only processed in the oninit lifecycle. */ 17 | initialValue?: T | T[]; 18 | /** Select a single option or multiple options */ 19 | multiple?: boolean; 20 | /** Optional label. */ 21 | label?: string; 22 | /** Optional ID. */ 23 | id?: string; 24 | /** Unique key for use of the element in an array. */ 25 | key?: string | number; 26 | /** Add a a placeholder to the input field. */ 27 | placeholder?: string; 28 | /** Add a description underneath the input field. */ 29 | helperText?: string; 30 | /** Uses Materialize icons as a prefix or postfix. */ 31 | iconName?: string; 32 | /** Sets the input field to disabled. */ 33 | disabled?: boolean; 34 | /** Optional style information. */ 35 | style?: string; 36 | /** If true, break to a new row */ 37 | newRow?: boolean; 38 | /** 39 | * If true, add a mandatory * after the label (if any), 40 | * and add the required and aria-required attributes to the input element. 41 | */ 42 | isMandatory?: boolean; 43 | /** Add the required and aria-required attributes to the input element */ 44 | required?: boolean; 45 | /** Enable the clear icon */ 46 | showClearButton?: boolean; 47 | } 48 | 49 | /** Component to select from a list of values in a dropdowns */ 50 | export const Select = (): Component> => { 51 | const state = {} as { 52 | checkedId?: T | T[]; 53 | initialValue?: T[]; 54 | instance?: M.FormSelect; 55 | /** Only initialized when multiple select */ 56 | wrapper?: HTMLDivElement; 57 | /** Only initialized when multiple select */ 58 | inputEl?: HTMLInputElement; 59 | /** Concatenation of all options IDs, to see if the options have changed and we need to re-init the select */ 60 | ids?: string; 61 | }; 62 | const optionsIds = (options: IInputOption[]) => options.map((o) => o.id).join(''); 63 | 64 | const isSelected = (id?: T, checkedId?: T[], selected = false) => 65 | selected || 66 | (checkedId instanceof Array && (id || typeof id === 'number') ? checkedId.indexOf(id) >= 0 : checkedId === id); 67 | 68 | return { 69 | oninit: ({ attrs: { checkedId, initialValue, options } }) => { 70 | state.ids = optionsIds(options); 71 | const iv = checkedId || initialValue; 72 | state.checkedId = checkedId instanceof Array ? [...checkedId] : checkedId; 73 | state.initialValue = 74 | iv !== null && typeof iv !== 'undefined' 75 | ? iv instanceof Array 76 | ? iv.filter((i) => i !== null && typeof i !== 'undefined') 77 | : [iv] 78 | : []; 79 | }, 80 | view: ({ 81 | attrs: { 82 | id, 83 | newRow, 84 | className = 'col s12', 85 | checkedId, 86 | key, 87 | options, 88 | multiple, 89 | label, 90 | helperText, 91 | placeholder = '', 92 | isMandatory, 93 | iconName, 94 | disabled, 95 | classes = '', 96 | dropdownOptions, 97 | // showClearButton, 98 | onchange: callback, 99 | }, 100 | }) => { 101 | if (state.checkedId !== checkedId) { 102 | state.initialValue = checkedId ? (checkedId instanceof Array ? checkedId : [checkedId]) : undefined; 103 | } 104 | const { initialValue } = state; 105 | const onchange = callback 106 | ? multiple 107 | ? () => { 108 | const values = state.instance && state.instance.getSelectedValues(); 109 | const v = values 110 | ? values.length > 0 && isNumeric(values[0]) 111 | ? values.map((n) => +n) 112 | : values.filter((i) => i !== null || typeof i !== 'undefined') 113 | : undefined; 114 | state.initialValue = v ? (v as T[]) : []; 115 | callback(state.initialValue); 116 | } 117 | : (e: Event) => { 118 | if (e && e.currentTarget) { 119 | const b = e.currentTarget as HTMLButtonElement; 120 | const v = (isNumeric(b.value) ? +b.value : b.value) as T; 121 | state.initialValue = typeof v !== undefined ? [v] : []; 122 | } 123 | state.initialValue && callback(state.initialValue); 124 | } 125 | : undefined; 126 | if (newRow) className += ' clear'; 127 | const noValidSelection = !options.some((o) => isSelected(o.id, initialValue)); 128 | const groups = options.reduce((acc, cur) => { 129 | if (cur.group && acc.indexOf(cur.group) < 0) acc.push(cur.group); 130 | return acc; 131 | }, [] as string[]); 132 | 133 | return m( 134 | '.input-field.select-space', 135 | { 136 | className, 137 | key, 138 | oncreate: multiple ? ({ dom }) => (state.wrapper = dom as HTMLDivElement) : undefined, 139 | }, 140 | [ 141 | iconName && m('i.material-icons.prefix', iconName), 142 | m( 143 | 'select', 144 | { 145 | id, 146 | title: label, 147 | disabled, 148 | multiple, 149 | oncreate: ({ dom }) => { 150 | state.instance = M.FormSelect.init(dom, { classes, dropdownOptions }); 151 | }, 152 | onupdate: ({ dom }) => { 153 | if (multiple) { 154 | const i = iconName ? 1 : 0; 155 | // Ugly hack to remove the placeholder when only one item is selected. 156 | if ( 157 | !state.inputEl && 158 | state.wrapper && 159 | state.wrapper.childNodes && 160 | state.wrapper.childNodes.length > 0 && 161 | state.wrapper.childNodes[i].childNodes && 162 | state.wrapper.childNodes[i].childNodes[0] 163 | ) { 164 | state.inputEl = state.wrapper.childNodes[i].childNodes[0] as HTMLInputElement; 165 | } 166 | if (state.inputEl && state.inputEl.value && state.inputEl.value.startsWith(`${placeholder}, `)) { 167 | state.inputEl.value = state.inputEl.value.replace(`${placeholder}, `, ''); 168 | } 169 | } 170 | const ids = optionsIds(options); 171 | let reinit = checkedId && state.checkedId !== checkedId.toString(); 172 | if (state.ids !== ids) { 173 | state.ids = ids; 174 | reinit = true; 175 | } 176 | if ( 177 | state.checkedId instanceof Array && checkedId instanceof Array 178 | ? state.checkedId.join() !== checkedId.join() 179 | : state.checkedId !== checkedId 180 | ) { 181 | state.checkedId = checkedId; 182 | reinit = true; 183 | } 184 | if (reinit) { 185 | state.instance = M.FormSelect.init(dom, { classes, dropdownOptions }); 186 | } 187 | }, 188 | onchange, 189 | }, 190 | // groups.length === 0 && 191 | m('option', { value: '', disabled: true, selected: noValidSelection ? true : undefined }, placeholder), 192 | groups.length === 0 193 | ? options.map((o, i) => 194 | m( 195 | 'option', 196 | { 197 | value: o.id, 198 | title: o.title || undefined, 199 | disabled: o.disabled ? 'true' : undefined, 200 | 'data-icon': o.img || undefined, 201 | selected: isSelected(o.id, initialValue, i === 0 && noValidSelection && !placeholder), 202 | }, 203 | o.label?.replace('&', '&') 204 | ) 205 | ) 206 | : groups.map((g) => 207 | m( 208 | 'optgroup', 209 | { label: g }, 210 | options 211 | .filter((o) => o.group === g) 212 | .map((o, i) => 213 | m( 214 | 'option', 215 | { 216 | value: o.id, 217 | title: o.title || undefined, 218 | disabled: o.disabled ? 'true' : undefined, 219 | 'data-icon': o.img || undefined, 220 | selected: isSelected(o.id, initialValue, i === 0 && noValidSelection && !placeholder), 221 | }, 222 | o.label?.replace('&', '&') 223 | ) 224 | ) 225 | ) 226 | ) 227 | ), 228 | m(Label, { label, isMandatory }), 229 | helperText && m(HelperText, { helperText }), 230 | ] 231 | ); 232 | }, 233 | }; 234 | }; 235 | -------------------------------------------------------------------------------- /packages/lib/src/styles/codeblock.css: -------------------------------------------------------------------------------- 1 | .codeblock { 2 | margin: 1.5rem 0 2.5rem 0; 3 | } 4 | .codeblock > div { 5 | margin-bottom: 1rem; 6 | } 7 | .codeblock > label { 8 | display: inline-block; 9 | } 10 | -------------------------------------------------------------------------------- /packages/lib/src/styles/input.css: -------------------------------------------------------------------------------- 1 | .twist { 2 | transform: scaleY(-1); 3 | } 4 | 5 | input[type='color']:not(.browser-default) { 6 | margin: 0px 0 8px 0; 7 | /** Copied from input[type=number] */ 8 | background-color: transparent; 9 | border: none; 10 | border-bottom: 1px solid #9e9e9e; 11 | border-radius: 0; 12 | outline: none; 13 | height: 3rem; 14 | width: 100%; 15 | font-size: 16px; 16 | padding: 0; 17 | -webkit-box-shadow: none; 18 | box-shadow: none; 19 | -webkit-box-sizing: content-box; 20 | box-sizing: content-box; 21 | -webkit-transition: border 0.3s, -webkit-box-shadow 0.3s; 22 | transition: border 0.3s, -webkit-box-shadow 0.3s; 23 | transition: box-shadow 0.3s, border 0.3s; 24 | transition: box-shadow 0.3s, border 0.3s, -webkit-box-shadow 0.3s; 25 | } 26 | 27 | .input-field.options > label { 28 | top: -2.5rem; 29 | } -------------------------------------------------------------------------------- /packages/lib/src/styles/map-editor.css: -------------------------------------------------------------------------------- 1 | .map-editor .input-field .prefix ~ .collection { 2 | margin-left: 3rem; 3 | width: 92%; 4 | width: calc(100% - 3rem); 5 | } 6 | /* For truthy values, the checkbox is not visible when the item is selected, so make it white */ 7 | .map-editor .active .checkbox-in-collection label > input[type='checkbox']:checked + span:before { 8 | top: -4px; 9 | left: -3px; 10 | width: 12px; 11 | height: 22px; 12 | border-top: 2px solid transparent; 13 | border-left: 2px solid transparent; 14 | border-right: 2px solid white; /* You need to change the colour here */ 15 | border-bottom: 2px solid white; /* And here */ 16 | -webkit-transform: rotate(40deg); 17 | -moz-transform: rotate(40deg); 18 | -ms-transform: rotate(40deg); 19 | -o-transform: rotate(40deg); 20 | transform: rotate(40deg); 21 | -webkit-backface-visibility: hidden; 22 | -webkit-transform-origin: 100% 100%; 23 | -moz-transform-origin: 100% 100%; 24 | -ms-transform-origin: 100% 100%; 25 | -o-transform-origin: 100% 100%; 26 | transform-origin: 100% 100%; 27 | } 28 | -------------------------------------------------------------------------------- /packages/lib/src/styles/switch.css: -------------------------------------------------------------------------------- 1 | .clear, 2 | .clear-10, 3 | .clear-15 { 4 | clear: both; 5 | /* overflow: hidden; Précaution pour IE 7 */ 6 | } 7 | .clear-10 { 8 | margin-bottom: 10px; 9 | } 10 | .clear-15 { 11 | margin-bottom: 15px; 12 | } 13 | span.mandatory { 14 | margin-left: 5px; 15 | color: red; 16 | } 17 | label + .switch { 18 | margin: 1.05rem 0; 19 | } 20 | -------------------------------------------------------------------------------- /packages/lib/src/styles/timeline.css: -------------------------------------------------------------------------------- 1 | .mm_timeline { 2 | margin: 30px 0 0 0; 3 | padding: 0; 4 | list-style: none; 5 | position: relative; 6 | } 7 | 8 | /* The line */ 9 | .mm_timeline:before { 10 | content: ''; 11 | position: absolute; 12 | top: 0; 13 | bottom: 0; 14 | width: 10px; 15 | background: #afdcf8; 16 | left: 20%; 17 | margin-left: -10px; 18 | } 19 | 20 | /* The date/time */ 21 | .mm_timeline > li .mm_time { 22 | display: block; 23 | width: 25%; 24 | padding-right: 100px; 25 | position: absolute; 26 | } 27 | 28 | .mm_timeline > li .mm_time span { 29 | display: block; 30 | text-align: right; 31 | } 32 | 33 | .mm_timeline > li .mm_time span:first-child { 34 | font-size: 0.9em; 35 | color: #bdd0db; 36 | } 37 | 38 | .mm_timeline > li .mm_time span:last-child { 39 | font-size: 1.4em; 40 | color: #3594cb; 41 | } 42 | 43 | .mm_timeline > li:nth-child(odd) .mm_time span:last-child { 44 | color: #6cbfee; 45 | } 46 | 47 | /* Active time */ 48 | .mm_timeline > li.active:nth-child(even) .mm_time span:last-child, 49 | .mm_timeline > li.active:nth-child(odd) .mm_time span:last-child { 50 | color: rgb(6, 5, 88); 51 | } 52 | 53 | /* Right content */ 54 | .mm_timeline > li .mm_label { 55 | margin: 0 0 15px 28%; 56 | background: #3594cb; 57 | color: #fff; 58 | padding: 0.6em 1em; 59 | font-size: 1.2em; 60 | font-weight: 300; 61 | line-height: 1.4; 62 | position: relative; 63 | border-radius: 5px; 64 | } 65 | 66 | /* Active label */ 67 | .mm_timeline > li.active .mm_label { 68 | border: 4px solid rgb(6, 5, 88); 69 | } 70 | 71 | .mm_timeline > li:nth-child(odd) .mm_label { 72 | background: #6cbfee; 73 | } 74 | 75 | .mm_timeline > li .mm_label h5 { 76 | margin-top: 0px; 77 | padding: 0 0 10px 0; 78 | border-bottom: 1px solid rgba(255, 255, 255, 0.4); 79 | } 80 | 81 | /* The triangle */ 82 | .mm_timeline > li .mm_label:after { 83 | right: 100%; 84 | border: solid transparent; 85 | content: ' '; 86 | height: 0; 87 | width: 0; 88 | position: absolute; 89 | pointer-events: none; 90 | border-right-color: #3594cb; 91 | border-width: 10px; 92 | top: 10px; 93 | } 94 | 95 | /* Active triangle */ 96 | .mm_timeline > li:nth-child(even).active .mm_label:after, 97 | .mm_timeline > li:nth-child(odd).active .mm_label:after { 98 | border-right-color: rgb(6, 5, 88); 99 | } 100 | 101 | .mm_timeline > li:nth-child(odd) .mm_label:after { 102 | border-right-color: #6cbfee; 103 | } 104 | 105 | /* The icons */ 106 | .mm_timeline > li .mm_icon { 107 | width: 40px; 108 | height: 40px; 109 | font-style: normal; 110 | font-weight: normal; 111 | font-variant: normal; 112 | text-transform: none; 113 | font-size: 1.4em; 114 | line-height: 40px; 115 | -webkit-font-smoothing: antialiased; 116 | position: absolute; 117 | color: #fff; 118 | background: #46a4da; 119 | border-radius: 50%; 120 | box-shadow: 0 0 0 8px #afdcf8; 121 | text-align: center; 122 | left: 20%; 123 | /* top: 0; */ 124 | margin: 0 0 0 -25px; 125 | } 126 | 127 | /* Active icon */ 128 | .mm_timeline > li.active .mm_icon { 129 | background: rgb(6, 5, 88); 130 | } 131 | 132 | .mm_icon > .material-icons { 133 | line-height: 3rem; 134 | } 135 | 136 | /* Example Media Queries */ 137 | @media screen and (max-width: 65.375em) { 138 | .mm_timeline > li .mm_time span:last-child { 139 | font-size: 1.5em; 140 | } 141 | } 142 | 143 | @media screen and (max-width: 47.2em) { 144 | .mm_timeline:before { 145 | display: none; 146 | } 147 | 148 | .mm_timeline > li .mm_time { 149 | width: 100%; 150 | position: relative; 151 | padding: 0 0 20px 0; 152 | } 153 | 154 | .mm_timeline > li .mm_time span { 155 | text-align: left; 156 | } 157 | 158 | .mm_timeline > li .mm_label { 159 | margin: 0 0 30px 0; 160 | padding: 1em; 161 | font-weight: 400; 162 | font-size: 95%; 163 | } 164 | 165 | .mm_timeline > li .mm_label:after { 166 | right: auto; 167 | left: 20px; 168 | border-right-color: transparent; 169 | border-bottom-color: #3594cb; 170 | top: -20px; 171 | } 172 | 173 | .mm_timeline > li:nth-child(odd) .mm_label:after { 174 | border-right-color: transparent; 175 | border-bottom-color: #6cbfee; 176 | } 177 | 178 | .mm_timeline > li .mm_icon { 179 | position: relative; 180 | float: right; 181 | left: auto; 182 | margin: -55px 5px 0 0px; 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /packages/lib/src/switch.ts: -------------------------------------------------------------------------------- 1 | import m, { FactoryComponent } from 'mithril'; 2 | import { uniqueId } from './utils'; 3 | import { IInputOptions } from './input-options'; 4 | import { Label } from './label'; 5 | import './styles/switch.css'; 6 | 7 | export interface ISwitchOptions extends Partial> { 8 | /** Left text label */ 9 | left?: string; 10 | /** Right text label */ 11 | right?: string; 12 | /** If checked is true, the switch is set in the right position. */ 13 | checked?: boolean; 14 | } 15 | 16 | /** Component to display a switch with two values. */ 17 | export const Switch: FactoryComponent = () => { 18 | const state = { id: uniqueId() }; 19 | return { 20 | view: ({ attrs }) => { 21 | const id = attrs.id || state.id; 22 | const { 23 | label, 24 | left, 25 | right, 26 | disabled, 27 | newRow, 28 | onchange, 29 | checked, 30 | isMandatory, 31 | className = 'col s12', 32 | ...params 33 | } = attrs; 34 | const cn = ['input-field', newRow ? 'clear' : '', className].filter(Boolean).join(' ').trim(); 35 | return m('div', { className: cn }, [ 36 | label ? m(Label, { label: label || '', id, isMandatory, className: 'active' }) : undefined, 37 | m( 38 | '.switch', 39 | params, 40 | m('label', [ 41 | left || 'Off', 42 | m('input[type=checkbox]', { 43 | id, 44 | disabled, 45 | checked, 46 | onclick: onchange 47 | ? (e: Event) => { 48 | if (e.target && typeof (e.target as HTMLInputElement).checked !== 'undefined') { 49 | onchange((e.target as HTMLInputElement).checked); 50 | } 51 | } 52 | : undefined, 53 | }), 54 | m('span.lever'), 55 | right || 'On', 56 | ]) 57 | ), 58 | ]); 59 | }, 60 | }; 61 | }; 62 | -------------------------------------------------------------------------------- /packages/lib/src/tabs.ts: -------------------------------------------------------------------------------- 1 | import m, { Vnode, FactoryComponent, Attributes } from 'mithril'; 2 | 3 | /** 4 | * Link or anchor target may take 4 values: 5 | * - _blank: Opens the linked document in a new window or tab 6 | * - _self: Opens the linked document in the same frame as it was clicked (this is default) 7 | * - _parent: Opens the linked document in the parent frame 8 | * - _top: Opens the linked document in the full body of the window 9 | */ 10 | export type AnchorTarget = '_blank' | '_self' | '_parent' | '_top'; 11 | 12 | export interface ITabItem { 13 | /** Title of the tab */ 14 | title: string; 15 | /** Vnode to render: may be empty in case of a using the tab as a hyperlink. */ 16 | vnode?: Vnode; 17 | /** ID of the tab element. Default the title in lowercase */ 18 | id?: string; 19 | /** If the tab should be active */ 20 | active?: boolean; 21 | /** If the tab should be disabled */ 22 | disabled?: boolean; 23 | /** CSS class for the tab (li), default `.tab.col.s3` */ 24 | className?: string; 25 | /** CSS class for the content (li), default `.tab.col.s3` */ 26 | contentClass?: string; 27 | /** 28 | * By default, Materialize tabs will ignore their default anchor behaviour. 29 | * To force a tab to behave as a regular hyperlink, just specify the target property of that link. 30 | */ 31 | target?: AnchorTarget; 32 | /** Only used in combination with a set target to make the tab act as a regular hyperlink. */ 33 | href?: string; 34 | } 35 | 36 | export interface ITabs extends Partial, Attributes { 37 | /** Selected tab id */ 38 | selectedTabId?: string; 39 | /** 40 | * Tab width, can be `auto` to use the width of the title, 41 | * `fill` to use all availabe space, or `fixed` to use a column size. 42 | */ 43 | tabWidth?: 'auto' | 'fixed' | 'fill'; 44 | /** List of tab items */ 45 | tabs: ITabItem[]; 46 | } 47 | 48 | export const Tabs: FactoryComponent = () => { 49 | const state = {} as { instance: M.Tabs }; 50 | 51 | const createId = (title: string, id?: string) => (id ? id : title.replace(/ /g, '').toLowerCase()); 52 | return { 53 | view: ({ 54 | attrs: { tabWidth, selectedTabId, tabs, className, style, duration, onShow, swipeable, responsiveThreshold }, 55 | }) => { 56 | const activeTab = tabs.filter((t) => t.active).shift(); 57 | const select = selectedTabId || (activeTab ? createId(activeTab.title, activeTab.id) : ''); 58 | const cn = [tabWidth === 'fill' ? 'tabs-fixed-width' : '', className].filter(Boolean).join(' ').trim(); 59 | return m('.row', [ 60 | m( 61 | '.col.s12', 62 | m( 63 | 'ul.tabs', 64 | { 65 | className: cn, 66 | style, 67 | oncreate: ({ dom }) => { 68 | state.instance = M.Tabs.init(dom, { 69 | duration, 70 | onShow, 71 | responsiveThreshold, 72 | swipeable, 73 | }); 74 | }, 75 | onupdate: () => { 76 | if (select) { 77 | const el = document.getElementById(`tab_${select}`); 78 | if (el) { 79 | el.click(); 80 | } 81 | } 82 | }, 83 | onremove: () => state.instance.destroy(), 84 | }, 85 | tabs.map(({ className, title, id, active, disabled, target, href }) => { 86 | const cn = [tabWidth === 'fixed' ? `col s${Math.floor(12 / tabs.length)}` : '', className] 87 | .filter(Boolean) 88 | .join(' ') 89 | .trim(); 90 | const anchorId = createId(title, id); 91 | const tabId = `tab_${anchorId}`; 92 | const cnA = active ? 'active' : ''; 93 | return m( 94 | 'li.tab', 95 | { 96 | className: cn, 97 | disabled, 98 | }, 99 | m('a', { id: tabId, className: cnA, target, href: href || `#${anchorId}` }, title) 100 | ); 101 | }) 102 | ) 103 | ), 104 | tabs 105 | .filter(({ href }) => typeof href === 'undefined') 106 | .map(({ id, title, vnode, contentClass }) => 107 | m('.col.s12', { id: createId(title, id), className: contentClass }, vnode) 108 | ), 109 | ]); 110 | }, 111 | }; 112 | }; 113 | -------------------------------------------------------------------------------- /packages/lib/src/timeline.ts: -------------------------------------------------------------------------------- 1 | import m, { Vnode, FactoryComponent, Attributes } from 'mithril'; 2 | import './styles/timeline.css'; 3 | import { padLeft } from './utils'; 4 | 5 | export interface ITimelineItem { 6 | id?: string; 7 | title?: Vnode | string; 8 | datetime: Date; 9 | iconName?: string; 10 | active?: boolean; 11 | content?: Vnode | string; 12 | } 13 | 14 | interface IInternalTimelineItem extends ITimelineItem { 15 | /** Formatter for the dates, normally specified by Timeline component */ 16 | dateFormatter: (d: Date) => string; 17 | /** Formatter for the time, normally specified by Timeline component */ 18 | timeFormatter: (d: Date) => string; 19 | /** When an item is selected, call this function */ 20 | onSelect?: (ti: ITimelineItem) => void; 21 | } 22 | 23 | export interface ITimeline extends Attributes { 24 | items: ITimelineItem[]; 25 | /** When an item is selected, call this function */ 26 | onSelect?: (ti: ITimelineItem) => void; 27 | /** Formatter for the dates, @default date/month/year in UTC */ 28 | dateFormatter?: (d: Date) => string; 29 | /** Formatter for the time @default HH:mm in UTC */ 30 | timeFormatter?: (d: Date) => string; 31 | } 32 | 33 | const TimelineItem: FactoryComponent = () => { 34 | return { 35 | view: ({ attrs: { id, title, datetime, active, content, iconName, dateFormatter, timeFormatter, onSelect } }) => { 36 | const onclick = onSelect ? () => onSelect({ id, title, datetime, active, content }) : undefined; 37 | const style = onSelect ? 'cursor: pointer;' : undefined; 38 | return m('li', { id, className: active ? 'active' : undefined, onclick, style }, [ 39 | m('.mm_time', { datetime }, [m('span', dateFormatter(datetime)), m('span', timeFormatter(datetime))]), 40 | iconName ? m('.mm_icon', m('i.material-icons', iconName)) : undefined, 41 | m('.mm_label', [ 42 | title ? (typeof title === 'string' ? m('h5', title) : title) : undefined, 43 | content ? (typeof content === 'string' ? m('p', content) : content) : undefined, 44 | ]), 45 | ]); 46 | }, 47 | }; 48 | }; 49 | 50 | /** 51 | * A timeline component to generate a simple vertical timeline based on Codrops' Vertical Timeline. 52 | * @see https://tympanus.net/codrops/2013/05/02/vertical-timeline/ 53 | */ 54 | export const Timeline: FactoryComponent = () => { 55 | const df = (d: Date) => `${d.getUTCDate()}/${d.getUTCMonth() + 1}/${d.getUTCFullYear()}`; 56 | const tf = (d: Date) => `${padLeft(d.getUTCHours())}:${padLeft(d.getUTCMinutes())}`; 57 | return { 58 | view: ({ attrs: { items, onSelect, timeFormatter = tf, dateFormatter = df } }) => { 59 | return m( 60 | 'ul.mm_timeline', 61 | items.map((item) => m(TimelineItem, { onSelect, dateFormatter, timeFormatter, ...item })) 62 | ); 63 | }, 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /packages/lib/src/utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create a unique ID 3 | * @see https://stackoverflow.com/a/2117523/319711 4 | * 5 | * @returns id followed by 8 hexadecimal characters. 6 | */ 7 | export const uniqueId = () => { 8 | // tslint:disable-next-line:no-bitwise 9 | return 'idxxxxxxxx'.replace(/[x]/g, () => ((Math.random() * 16) | 0).toString(16)); 10 | }; 11 | 12 | /** 13 | * Create a GUID 14 | * @see https://stackoverflow.com/a/2117523/319711 15 | * 16 | * @returns RFC4122 version 4 compliant GUID 17 | */ 18 | export const uuid4 = () => { 19 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { 20 | // tslint:disable-next-line:no-bitwise 21 | const r = (Math.random() * 16) | 0; 22 | // tslint:disable-next-line:no-bitwise 23 | const v = c === 'x' ? r : (r & 0x3) | 0x8; 24 | return v.toString(16); 25 | }); 26 | }; 27 | 28 | /** Check if a string or number is numeric. @see https://stackoverflow.com/a/9716488/319711 */ 29 | export const isNumeric = (n: string | number) => !isNaN(parseFloat(n as string)) && isFinite(n as number); 30 | 31 | /** 32 | * Pad left, default width 2 with a '0' 33 | * 34 | * @see http://stackoverflow.com/a/10073788/319711 35 | * @param {(string | number)} n 36 | * @param {number} [width=2] 37 | * @param {string} [z='0'] 38 | * @returns 39 | */ 40 | export const padLeft = (n: string | number, width: number = 2, z: string = '0') => String(n).padStart(width, z); 41 | -------------------------------------------------------------------------------- /packages/lib/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ESNext" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 5 | "module": "ESNext" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 6 | "isolatedModules": true, 7 | "lib": ["dom", "es5", "es2015.promise", "es2017"] /* Specify library files to be included in the compilation. */, 8 | // "allowJs": true, /* Allow javascript files to be compiled. */ 9 | // "checkJs": true, /* Report errors in .js files. */ 10 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 11 | "declaration": true /* Generates corresponding '.d.ts' file. */, 12 | // "sourceMap": true /* Generates corresponding '.map' file. */, 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist" /* Redirect output structure to the directory. */, 15 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */, 16 | "removeComments": false /* Do not emit comments to output. */, 17 | // "noEmit": true, /* Do not emit outputs. */ 18 | "importHelpers": true /* Import emit helpers from 'tslib'. */, 19 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 20 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 21 | 22 | /* Strict Type-Checking Options */ 23 | "skipLibCheck": true, 24 | "strict": true /* Enable all strict type-checking options. */, 25 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 26 | "strictNullChecks": true /* Enable strict null checks. */, 27 | "strictFunctionTypes": true /* Enable strict checking of function types. */, 28 | "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 29 | "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 30 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 31 | 32 | /* Additional Checks */ 33 | "noUnusedLocals": true /* Report errors on unused locals. */, 34 | "noUnusedParameters": true /* Report errors on unused parameters. */, 35 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 36 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 37 | 38 | /* Module Resolution Options */ 39 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 40 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 41 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 42 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 43 | // "typeRoots": [], /* List of folders to include type definitions from. */ 44 | // "types": [], /* Type declaration files to be included in compilation. */ 45 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 46 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 47 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 48 | 49 | /* Source Map Options */ 50 | // "sourceRoot": "./", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 51 | // "mapRoot": "./", /* Specify the location where debugger should locate map files instead of generated locations. */ 52 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 53 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 54 | 55 | /* Experimental Options */ 56 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 57 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 58 | }, 59 | "parcelTsPluginOptions": { 60 | // If true type-checking is disabled 61 | "transpileOnly": false 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /packages/lib/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "no-console": false, 7 | "no-debugger": false, 8 | "quotemark": [true, "single"], 9 | "trailing-comma": [ 10 | true, 11 | { 12 | "multiline": { 13 | "objects": "always", 14 | "arrays": "always", 15 | "functions": "never", 16 | "typeLiterals": "ignore" 17 | }, 18 | "esSpecCompliant": true 19 | } 20 | ], 21 | "object-literal-sort-keys": false, 22 | "ordered-imports": false, 23 | "arrow-parens": [false, "ban-single-arg-parens"] 24 | }, 25 | "rulesDirectory": [] 26 | } 27 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | # all packages in subdirs of packages/ and components/ 3 | - 'packages/**' 4 | # exclude packages that are inside test directories 5 | - '!**/test/**' 6 | --------------------------------------------------------------------------------