├── .gitignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── build.yml │ └── release.yml ├── simple-tabs-gif.gif ├── hacs.json ├── .prettierrc.js ├── .vscode ├── tasks.json └── extensions.json ├── tsconfig.json ├── .eslintrc.js ├── rollup.config.js ├── LICENSE ├── package.json ├── EXAMPLES.md ├── README.md └── src ├── simple-tabs-editor.ts └── simple-tabs.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /.rpt2_cache/ 3 | package-lock.json 4 | /dist -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [agoberg85] 2 | custom: ['https://buymeacoffee.com/mysmarthomeblog'] -------------------------------------------------------------------------------- /simple-tabs-gif.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agoberg85/home-assistant-simple-tabs/HEAD/simple-tabs-gif.gif -------------------------------------------------------------------------------- /hacs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Simple Tabs Card", 3 | "render_readme": true, 4 | "filename": "simple-tabs.js" 5 | } -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: true, 3 | trailingComma: 'all', 4 | singleQuote: true, 5 | printWidth: 120, 6 | tabWidth: 2, 7 | }; -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "npm", 6 | "script": "start", 7 | "problemMatcher": [], 8 | "label": "npm: start", 9 | "detail": "rollup -c --watch" 10 | } 11 | ] 12 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "github.vscode-pull-request-github", 4 | "eamodio.gitlens", 5 | "dbaeumer.vscode-eslint", 6 | "esbenp.prettier-vscode", 7 | "bierner.lit-html", 8 | "runem.lit-plugin", 9 | "auchenberg.vscode-browser-preview", 10 | "davidanson.vscode-markdownlint", 11 | "redhat.vscode-yaml" 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "lib": ["ES2021", "DOM"], 7 | "declaration": false, 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "strict": true, 11 | "esModuleInterop": true, 12 | "skipLibCheck": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "experimentalDecorators": true, 15 | "useDefineForClassFields": false, 16 | "types": ["node"] 17 | }, 18 | "include": ["src/**/*.ts"], 19 | "exclude": ["node_modules", "dist"] 20 | } -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | parserOptions: { 4 | ecmaVersion: 2020, // Allows for the parsing of modern ECMAScript features 5 | sourceType: "module" // Allows for the use of imports 6 | }, 7 | extends: [ 8 | "plugin:@typescript-eslint/recommended" // Uses the recommended rules from the @typescript-eslint/eslint-plugin 9 | ], 10 | rules: { 11 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 12 | // e.g. "@typescript-eslint/explicit-function-return-type": "off", 13 | } 14 | }; -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature request 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** 10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** 13 | A clear and concise description of what you want to happen. 14 | 15 | **Describe alternatives you've considered** 16 | A clear and concise description of any alternative solutions or features you've considered. 17 | 18 | **Additional context** 19 | Add any other context or screenshots about the feature request here. 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: 'Build Check' 2 | 3 | on: 4 | push: 5 | branches: 6 | - main # Corrected from 'master' 7 | pull_request: 8 | branches: 9 | - main # Corrected from 'master' 10 | 11 | jobs: 12 | build: 13 | name: Test build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout the repository 17 | uses: actions/checkout@v3 # Use a modern version of the checkout action 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '18' # Specify a stable Node.js version 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Run build 28 | run: npm run build -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import nodeResolve from '@rollup/plugin-node-resolve'; 4 | import terser from '@rollup/plugin-terser'; 5 | import serve from 'rollup-plugin-serve'; 6 | 7 | const dev = process.env.ROLLUP_WATCH; 8 | 9 | export default { 10 | input: 'src/simple-tabs.ts', 11 | output: { 12 | file: 'dist/simple-tabs.js', 13 | format: 'es', 14 | inlineDynamicImports: true, 15 | }, 16 | // --- ADD THIS LINE --- 17 | inlineDynamicImports: true, 18 | // -------------------- 19 | plugins: [ 20 | nodeResolve(), 21 | commonjs(), 22 | typescript(), 23 | dev && serve({ 24 | contentBase: '.', 25 | host: '0.0.0.0', 26 | port: 5000, 27 | headers: { 28 | 'Access-Control-Allow-Origin': '*', 29 | }, 30 | }), 31 | !dev && terser({ format: { comments: false } }), 32 | ], 33 | }; -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | release: 9 | name: Create Release Asset 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout the repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Set up Node.js 16 | uses: actions/setup-node@v3 17 | with: 18 | node-version: '18' 19 | 20 | - name: Install dependencies 21 | run: npm install 22 | 23 | - name: Build the production file 24 | run: npm run build 25 | 26 | - name: Upload the card to the release 27 | uses: svenstaro/upload-release-action@v2 # Use a modern version of the upload action 28 | with: 29 | repo_token: ${{ secrets.GITHUB_TOKEN }} 30 | file: dist/simple-tabs.js # Corrected path to your built file 31 | asset_name: simple-tabs.js # Corrected name for the release asset 32 | tag: ${{ github.ref }} 33 | overwrite: true -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Custom cards for Home Assistant 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "simple-tabs-card", 3 | "version": "1.1.7", 4 | "description": "A simple, configurable tabs card for Home Assistant.", 5 | "main": "dist/simple-tabs.js", 6 | "module": "dist/simple-tabs.js", 7 | "author": "Your Name", 8 | "license": "MIT", 9 | "type": "module", 10 | "scripts": { 11 | "start": "rollup -c --watch", 12 | "build": "rollup -c", 13 | "lint": "eslint src/*.ts" 14 | }, 15 | "dependencies": { 16 | "custom-card-helpers": "^1.9.0", 17 | "home-assistant-js-websocket": "^9.0.0", 18 | "js-yaml": "^4.1.0", 19 | "lit": "^3.0.0" 20 | }, 21 | "devDependencies": { 22 | "@rollup/plugin-commonjs": "^25.0.4", 23 | "@rollup/plugin-node-resolve": "^15.3.1", 24 | "@rollup/plugin-terser": "^0.4.3", 25 | "@types/js-yaml": "^4.0.9", 26 | "@types/node": "^20.5.9", 27 | "@typescript-eslint/eslint-plugin": "^5.62.0", 28 | "@typescript-eslint/parser": "^5.62.0", 29 | "eslint": "^8.48.0", 30 | "rollup": "^3.28.1", 31 | "rollup-plugin-serve": "^2.0.2", 32 | "rollup-plugin-typescript2": "^0.36.0", 33 | "typescript": "^5.2.2" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | --- 8 | 9 | 14 | 15 | **Checklist:** 16 | 17 | - [ ] I updated to the latest version available 18 | - [ ] I cleared the cache of my browser 19 | 20 | **Release with the issue:** 21 | 22 | **Last working release (if known):** 23 | 24 | **Browser and Operating System:** 25 | 26 | 29 | 30 | **Description of problem:** 31 | 32 | 35 | 36 | **Javascript errors shown in the web inspector (if applicable):** 37 | 38 | ``` 39 | 40 | ``` 41 | 42 | **Additional information:** 43 | -------------------------------------------------------------------------------- /EXAMPLES.md: -------------------------------------------------------------------------------- 1 | ### Conditional Tabs 2 | 3 | You can dynamically show or hide a tab by adding a `conditions` list to its configuration. The tab will only be visible if **all** conditions in the list are met (this is an "AND" relationship). 4 | 5 | Each condition in the list must be an object of one of the following types: 6 | 7 | #### State Condition 8 | 9 | This condition checks if a specific entity has a specific state. 10 | 11 | | Key | Type | Description | 12 | | :--- | :--- | :--- | 13 | | `entity`| string | The entity ID to check. | 14 | | `state` | string | The state the entity must have for the condition to be true. | 15 | 16 | **Example:** Show a "Security" tab only if an `input_boolean` is on. 17 | ```yaml 18 | tabs: 19 | - title: Security 20 | icon: mdi:shield-lock 21 | conditions: 22 | - entity: input_boolean.show_security_tab 23 | state: 'on' 24 | card: 25 | type: alarm-panel 26 | entity: alarm_control_panel.home 27 | ``` 28 | 29 | #### Template Condition 30 | 31 | This condition evaluates a Home Assistant template in real-time. The tab will be shown if the template's result is "truthy" (e.g., `true`, a non-zero number, or a non-empty string like "show"). For clarity, it's best to have your template explicitly return `true` or `false`. 32 | 33 | | Key | Type | Description | 34 | | :--- | :--- | :--- | 35 | | `template`| string | The Home Assistant template to evaluate. | 36 | 37 | **Example:** Only show a "Guest Mode" tab if the `guest_mode` input boolean is on. 38 | ```yaml 39 | tabs: 40 | - title: Guest Mode 41 | icon: mdi:account-star 42 | conditions: 43 | - template: "{{ is_state('input_boolean.guest_mode', 'on') }}" 44 | card: 45 | # ... card config for guests 46 | ``` 47 | 48 | #### Combining Conditions 49 | 50 | You can add multiple condition objects to the list to create more specific rules. 51 | 52 | **Example:** Show a "Good Morning" tab only if a specific person is home *and* it is between 6 AM and 11 AM. 53 | ```yaml 54 | tabs: 55 | - title: Good Morning 56 | icon: mdi:weather-sunset-up 57 | conditions: 58 | # Condition 1: Person must be home 59 | - entity: person.jane_doe 60 | state: 'home' 61 | # AND Condition 2: Must be morning 62 | - template: "{{ now().hour >= 6 and now().hour < 11 }}" 63 | card: 64 | # ... card showing morning routine info 65 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Tabs Card 2 | 3 | A clean and configurable tabs card for Home Assistant Dashboards. 4 | 5 | ![Simple Tabs Card Screenshot](https://raw.githubusercontent.com/agoberg85/home-assistant-simple-tabs/main/simple-tabs-gif.gif) 6 | 7 | ## Features 8 | 9 | - **Organize Your Dashboard:** Group any Dashboard cards into a clean, tabbed interface. 10 | - **Tab Icons:** Add icon to your tab titles. 11 | - **Stylable:** Customize colors for the background, border, text, and active tab. 12 | - **Alignment:** Align your tabs to the start, center, or end of the card. 13 | - **Performance:** Use the default "lazy-loading" for the best performance, or enable "pre-loading" for instantaneous tab switching. 14 | - **Conditional Tabs:** Dynamically show or hide tabs based on entity states or complex jinja templates. 15 | 16 | ## Installation 17 | 18 | ### HACS (Recommended) 19 | 20 | 1. Go to the HACS page in your Home Assistant instance. 21 | 2. Click the three-dot menu in the top right. 22 | 3. Select "Custom repositories". 23 | 4. In the "Repository" field, paste the URL of this repository (https://github.com/agoberg85/home-assistant-simple-tabs). 24 | 5. For "Category", select "Dashboard". 25 | 6. Click "Add". 26 | 7. The `simple-tabs-card` will now appear in the HACS Frontend list. Click "Install". 27 | 28 | ### Manual Installation 29 | 30 | 1. Download the `simple-tabs.js` file from the latest [release](https://github.com/agoberg85/home-assistant-simple-tabs/releases). 31 | 2. Copy the file to the `www` directory in your Home Assistant `config` folder. 32 | 3. In your Lovelace dashboard, go to "Manage Resources" and add a new resource: 33 | - URL: `/local/simple-tabs.js` 34 | - Resource Type: `JavaScript Module` 35 | 36 | ## Configuration 37 | 38 | ### Main Options 39 | 40 | | Name | Type | Required? | Description | Default | 41 | | :--- | :--- | :--- | :--- | :--- | 42 | | `type` | string | **Required** | `custom:simple-tabs` | | 43 | | `tabs` | list | **Required** | A list of tab objects to display. See below. | | 44 | | `alignment` | string | Optional | Justification for the row of tabs. (`start`, `center`, `end`) | `'center'` | 45 | | `default_tab` | number | Optional | Defines the default tab. If a tab is hidden via conditions it will fall back to the first visible tab. | `1` | 46 | | `pre-load` | boolean | Optional | If `true`, renders all tab content on load for faster switching. | `false` | 47 | | `background-color`| string | Optional | CSS color for the button background. | `none` | 48 | | `border-color` | string | Optional | CSS color for the button border. | Your theme's `divider-color` | 49 | | `text-color` | string | Optional | CSS color for the button text. | Your theme's `secondary-text-color`| 50 | | `hover-color` | string | Optional | CSS color for button text and border on hover. | Your theme's `primary-text-color`| 51 | | `active-text-color`| string | Optional | CSS color for the active tab's text. | Your theme's `text-primary-color`| 52 | | `active-background`| string | Optional | CSS color/gradient for the active tab's background. | Your theme's `primary-color` | 53 | | `container_background`| string | Optional | CSS color/gradient for the background color of the container. | none | 54 | | `container_padding`| string | Optional | Container padding | 0 | 55 | | `container_rounding`| string | Optional | Border radius of the container | 0 | 56 | 57 | ### Tab Object Options 58 | 59 | Each entry in the `tabs` list is an object with the following properties: 60 | 61 | | Name | Type | Required? | Description | 62 | | :--- | :--- | :--- | :--- | 63 | | `title` | string | Optional* | The text to display on the tab. Can be jinja template | 64 | | `icon` | string | Optional* | An MDI icon to display next to the title (e.g., `mdi:lightbulb`). Can be jinja template | 65 | | `card` | object | **Required** | A standard Lovelace card configuration. | 66 | | `conditions` | list | Optional | A list of conditions that must be met to show the tab. See [EXAMPLES.md](EXAMPLES.md) | 67 | 68 | *Either title or icon has to be defined. 69 | 70 | ## Example Usage 71 | 72 | ### Example Configuration 73 | 74 | This will create two centered tabs. 75 | 76 | ```yaml 77 | type: custom:simple-tabs 78 | pre-load: false 79 | default_tab: 2 80 | alignment: center 81 | background-color: "#2a2a2a" 82 | border-color: "#555555" 83 | text-color: "#bbbbbb" 84 | hover-color: "#ffffff" 85 | active-text-color: "#000000" 86 | active-background: linear-gradient(122deg,rgba(230, 163, 222, 1) 20%, rgba(0, 212, 255, 1) 150%) 87 | tabs: 88 | - title: Weather 89 | icon: mdi:weather-sunny 90 | conditions: 91 | - entity: input_boolean.zone_home 92 | state: "on" 93 | - template: "{{ now().hour < 17 }}" 94 | card: 95 | type: markdown 96 | content: Weather card goes here 97 | - title: "Lights on: {{ states.light | selectattr('state','eq','on') | list | count }}" 98 | icon: mdi:lightbulb 99 | card: 100 | type: markdown 101 | content: Lights goes here 102 | ``` 103 | 104 | ## Roadmap ahead 105 | 106 | - **Visual Configuration Editor:** The current version has a basic UI editor, will continue developing a more robust UI editor. 107 | - **More styling options:** Add more configuration options for for example font size, font weight, tab spacing, and button border-radius. 108 | - **Touch navigation:** Add support for touch/swipe navigation on mobile devices. 109 | - **Animations:** Add animations when switching between tabs. 110 | - **URL support:** Make tabs linkable via URLs (like #tab-2) 111 | - **Badges:** Option to add badges (text, number, color) to tab buttons. 112 | 113 | ## Support development 114 | 115 | Buy me a coffee: https://buymeacoffee.com/mysmarthomeblog 116 | 117 | Subscribe to Youtube channel: https://www.youtube.com/@My_Smart_Home 118 | -------------------------------------------------------------------------------- /src/simple-tabs-editor.ts: -------------------------------------------------------------------------------- 1 | import { LitElement, html, css, TemplateResult } from 'lit'; 2 | import { customElement, property, state } from 'lit/decorators.js'; 3 | import { fireEvent, HomeAssistant } from 'custom-card-helpers'; 4 | import { TabsCardConfig } from './simple-tabs'; 5 | import { LovelaceCardConfig } from 'custom-card-helpers/dist/types'; 6 | import * as yaml from 'js-yaml'; 7 | 8 | // Declare the types for Home Assistant's custom elements globally. 9 | declare global { 10 | interface HTMLElementTagNameMap { 11 | 'ha-yaml-editor': HaYamlEditor; 12 | 'ha-icon-picker': HaIconPicker; 13 | 'ha-textfield': HaTextField; 14 | 'ha-expansion-panel': HaExpansionPanel; 15 | } 16 | } 17 | 18 | interface HaYamlEditor extends HTMLElement { 19 | defaultValue: string; 20 | value: string; 21 | hass: HomeAssistant; 22 | isValid: boolean; 23 | name: string; 24 | } 25 | 26 | interface HaIconPicker extends HTMLElement { 27 | value: string; 28 | label: string; 29 | name: string; 30 | } 31 | 32 | interface HaTextField extends HTMLElement { 33 | value: string; 34 | label: string; 35 | name: string; 36 | } 37 | 38 | interface HaExpansionPanel extends HTMLElement { 39 | header: string; 40 | expanded: boolean; 41 | } 42 | 43 | 44 | // Helper function to safely stringify the card config into YAML 45 | function stringifyCard(card: LovelaceCardConfig | string): string { 46 | let cardObject: LovelaceCardConfig; 47 | 48 | if (typeof card === 'string') { 49 | try { 50 | // Try to parse the string as YAML. 51 | cardObject = yaml.load(card) as LovelaceCardConfig; 52 | // If the parsed result is not an object (e.g., just a string or number), 53 | // return the original string because we can't format it as a card. 54 | if (typeof cardObject !== 'object' || cardObject === null) { 55 | return card; 56 | } 57 | } catch (e) { 58 | // If it's not valid YAML, return the string as is for the user to fix. 59 | return card; 60 | } 61 | } else { 62 | cardObject = card; 63 | } 64 | 65 | // Now we are sure we have an object, dump it to a clean YAML string. 66 | try { 67 | return yaml.dump(cardObject, { skipInvalid: true, indent: 2 }).trimEnd(); 68 | } catch (e) { 69 | console.error("Error dumping YAML:", e); 70 | return JSON.stringify(cardObject, null, 2); 71 | } 72 | } 73 | 74 | @customElement('simple-tabs-editor') 75 | export class SimpleTabsEditor extends LitElement { 76 | @property({ attribute: false }) public hass!: HomeAssistant; 77 | @state() private _config!: TabsCardConfig; 78 | 79 | public setConfig(config: TabsCardConfig): void { 80 | this._config = config; 81 | } 82 | 83 | private _valueChanged(newConfig: TabsCardConfig): void { 84 | fireEvent(this, 'config-changed', { config: newConfig }); 85 | } 86 | 87 | private _handleTabChange(ev: Event, index: number): void { 88 | if (!this._config) return; 89 | 90 | const target = ev.target as (HaTextField | HaYamlEditor | HaIconPicker); 91 | const newTabs = [...this._config.tabs]; 92 | let value: string | object; 93 | 94 | // FIX: Changed the unsafe type cast from 'HTMLInputElement' to a safer generic object type. 95 | const eventValue = (ev as CustomEvent).detail?.value ?? (target as { value: string }).value; 96 | const fieldName = target.name; 97 | 98 | if (fieldName === 'card') { 99 | try { 100 | // Add indentation to each line of the input to make it valid YAML 101 | const indentedValue = eventValue 102 | .split('\n') 103 | .map((line: string) => ` ${line}`) 104 | .join('\n'); 105 | value = yaml.load(indentedValue) as object; 106 | if (value === null || typeof value !== 'object') { 107 | value = { type: '' }; 108 | } 109 | } catch (e) { 110 | value = eventValue; 111 | } 112 | } else { 113 | value = eventValue; 114 | } 115 | 116 | newTabs[index] = { ...newTabs[index], [fieldName]: value }; 117 | this._valueChanged({ ...this._config, tabs: newTabs }); 118 | } 119 | 120 | private _addTab(): void { 121 | if (!this._config) return; 122 | const newTabs = [...(this._config.tabs || []), { 123 | title: 'New Tab', 124 | icon: 'mdi:new-box', 125 | card: { type: 'markdown', content: '## New Tab Content' } 126 | }]; 127 | this._valueChanged({ ...this._config, tabs: newTabs }); 128 | } 129 | 130 | private _removeTab(index: number): void { 131 | if (!this._config) return; 132 | const newTabs = [...this._config.tabs]; 133 | newTabs.splice(index, 1); 134 | this._valueChanged({ ...this._config, tabs: newTabs }); 135 | } 136 | 137 | private _moveTab(index: number, direction: 'up' | 'down'): void { 138 | if (!this._config) return; 139 | const newTabs = [...this._config.tabs]; 140 | const [tab] = newTabs.splice(index, 1); 141 | const newIndex = direction === 'up' ? index - 1 : index + 1; 142 | newTabs.splice(newIndex, 0, tab); 143 | this._valueChanged({ ...this._config, tabs: newTabs }); 144 | } 145 | 146 | protected render(): TemplateResult { 147 | if (!this.hass || !this._config) { 148 | return html``; 149 | } 150 | 151 | return html` 152 |
153 |
154 | ${this._config.tabs.map((tab, index) => html` 155 | 156 |
157 |
158 | { 164 | e.stopPropagation(); 165 | this._moveTab(index, 'up'); 166 | }} 167 | > 168 | { 174 | e.stopPropagation(); 175 | this._moveTab(index, 'down'); 176 | }} 177 | > 178 |
179 | this._handleTabChange(e, index)} 185 | @click=${(e: Event) => e.stopPropagation()} 186 | @keydown=${(e: KeyboardEvent) => e.stopPropagation()} 187 | > 188 | { 193 | e.stopPropagation(); 194 | this._removeTab(index); 195 | }} 196 | > 197 |
198 | 199 |
200 | this._handleTabChange(e, index)} 205 | > 206 |

Card content (Only YAML for now):

207 | this._handleTabChange(e, index)} 212 | > 213 |
214 |
215 | `)} 216 |
217 | 218 | 219 | Add Tab 220 | 221 |
222 | `; 223 | } 224 | 225 | static styles = css` 226 | .card-config { 227 | padding: 16px; 228 | } 229 | .tabs-list { 230 | display: flex; 231 | flex-direction: column; 232 | gap: 8px; 233 | margin-bottom: 16px; 234 | } 235 | ha-expansion-panel { 236 | border-radius: 6px; 237 | --expansion-panel-content-padding: 0; 238 | background: var(--sidebar-background-color); 239 | } 240 | p {margin: 12px 0 0 0;} 241 | .summary-header { 242 | display: flex; 243 | align-items: center; 244 | width: 100%; 245 | } 246 | .summary-title { 247 | flex: 1; 248 | --mdc-text-field-fill-color: transparent; 249 | --text-field-border-width: 0px; 250 | } 251 | .remove-icon { 252 | color: var(--secondary-text-color); 253 | padding: 0 8px; 254 | } 255 | .add-btn { 256 | background: var(--accent-color); 257 | padding: 8px 16px 8px 8px; 258 | border-radius: 20px; 259 | cursor: pointer; 260 | color: var(--mdc-theme-on-secondary); 261 | } 262 | .card-content { 263 | padding: 16px; 264 | display: grid; 265 | gap: 16px; 266 | } 267 | .reorder-controls { 268 | display: flex; 269 | align-items: center; 270 | padding-left: 8px; 271 | } 272 | .reorder-btn { 273 | cursor: pointer; 274 | color: var(--secondary-text-color); 275 | } 276 | .reorder-btn[disabled] { 277 | opacity: 0.3; 278 | pointer-events: none; 279 | } 280 | `; 281 | } -------------------------------------------------------------------------------- /src/simple-tabs.ts: -------------------------------------------------------------------------------- 1 | import './simple-tabs-editor'; 2 | import { LitElement, html, css, TemplateResult } from 'lit'; 3 | import { customElement, property, state, query } from 'lit/decorators.js'; 4 | import { styleMap } from 'lit/directives/style-map.js'; 5 | import type { 6 | HomeAssistant, 7 | LovelaceCard, 8 | LovelaceCardConfig, 9 | LovelaceCardEditor, 10 | } from 'custom-card-helpers'; 11 | 12 | // (Interfaces like StateCondition, TabConfig, etc. remain the same) 13 | // ... (Your existing interfaces go here) ... 14 | function configChanged(oldConfig: TabsCardConfig | undefined, newConfig: TabsCardConfig): boolean { 15 | if (!oldConfig) return true; 16 | if (oldConfig.tabs.length !== newConfig.tabs.length) return true; 17 | 18 | return oldConfig.tabs.some((tab, index) => { 19 | const newTab = newConfig.tabs[index]; 20 | if (!newTab) return true; 21 | 22 | return tab.title !== newTab.title || 23 | tab.icon !== newTab.icon || 24 | JSON.stringify(tab.card) !== JSON.stringify(tab.card) || 25 | JSON.stringify(tab.conditions) !== JSON.stringify(tab.conditions); 26 | }); 27 | } 28 | 29 | export interface StateCondition { entity: string; state: string; } 30 | export interface TemplateCondition { template: string; } 31 | 32 | export interface TabConfig { 33 | title: string; 34 | icon?: string; 35 | card: LovelaceCardConfig; 36 | conditions?: (StateCondition | TemplateCondition)[]; 37 | } 38 | 39 | export interface TabsCardConfig { 40 | type: string; 41 | tabs: TabConfig[]; 42 | default_tab?: number; 43 | 'pre-load'?: boolean; 44 | alignment?: 'start' | 'center' | 'end'; 45 | 'background-color'?: string; 46 | 'border-color'?: string; 47 | 'text-color'?: string; 48 | 'hover-color'?: string; 49 | 'active-text-color'?: string; 50 | 'active-background'?: string; 51 | margin?: string; 52 | container_background?: string; 53 | container_padding?: string; 54 | container_rounding?: string; 55 | } 56 | 57 | declare global { 58 | interface Window { 59 | loadCardHelpers?: () => Promise; 60 | customCards?: { type: string; name: string; preview?: boolean; description?: string; }[]; 61 | } 62 | } 63 | 64 | 65 | @customElement('simple-tabs') 66 | export class SimpleTabs extends LitElement { 67 | @property({ attribute: false }) public hass!: HomeAssistant; 68 | @state() private _config!: TabsCardConfig; 69 | @state() private _cards: (LovelaceCard | null)[] = []; 70 | @state() private _selectedTabIndex = 0; 71 | @state() private _tabVisibility: boolean[] = []; 72 | @state() private _renderedTitles: (string | undefined)[] = []; 73 | @state() private _renderedIcons: (string | undefined)[] = []; 74 | 75 | @query('.tabs') private _tabsEl?: HTMLDivElement; 76 | 77 | private _helpers?: any; 78 | private _helpersPromise?: Promise; 79 | private _templateUnsubscribers: (() => void)[] = []; 80 | private _disconnectCleanupTimeout?: number; 81 | private _hassSet = false; 82 | 83 | static async getConfigElement(): Promise { 84 | return document.createElement('simple-tabs-editor') as LovelaceCardEditor; 85 | } 86 | 87 | static getStubConfig(): Record { 88 | return { 89 | type: 'custom:simple-tabs', 90 | tabs: [ 91 | { title: 'Tab 1', icon: 'mdi:home', card: { type: 'markdown', content: 'Content for Tab 1' } }, 92 | { title: 'Tab 2', icon: 'mdi:cog', card: { type: 'markdown', content: 'Content for Tab 2' } }, 93 | ] 94 | }; 95 | } 96 | 97 | private _loadHelpers(): Promise { 98 | if (this._helpers) return Promise.resolve(); 99 | if (!this._helpersPromise) { 100 | this._helpersPromise = new Promise(async (resolve, reject) => { 101 | try { 102 | this._helpers = await window.loadCardHelpers?.(); 103 | resolve(); 104 | } catch (e) { 105 | console.error('[Simple Tabs] Error loading card helpers:', e); 106 | reject(e); 107 | } 108 | }); 109 | } 110 | return this._helpersPromise; 111 | } 112 | 113 | public connectedCallback(): void { 114 | super.connectedCallback(); 115 | if (this._disconnectCleanupTimeout) { 116 | clearTimeout(this._disconnectCleanupTimeout); 117 | this._disconnectCleanupTimeout = undefined; 118 | } 119 | window.addEventListener('resize', this._handleResize); 120 | } 121 | 122 | public async disconnectedCallback(): Promise { 123 | super.disconnectedCallback(); 124 | window.removeEventListener('resize', this._handleResize); 125 | this._disconnectCleanupTimeout = window.setTimeout(() => { 126 | if (!this.isConnected) this._unsubscribeTemplates(); 127 | }, 0); 128 | } 129 | 130 | private _handleResize = (): void => { this._updateOverflowState(); }; 131 | 132 | private _unsubscribeTemplates(): void { 133 | this._templateUnsubscribers.forEach(unsubscriber => unsubscriber?.()); 134 | this._templateUnsubscribers = []; 135 | } 136 | 137 | public async setConfig(config: TabsCardConfig): Promise { 138 | if (!config || !config.tabs) throw new Error('Invalid configuration'); 139 | 140 | if (!configChanged(this._config, config)) return; 141 | 142 | this._loadHelpers(); 143 | this._unsubscribeTemplates(); 144 | 145 | this._config = { alignment: 'center', 'pre-load': false, ...config }; 146 | this._cards = new Array(config.tabs.length).fill(null); 147 | this._tabVisibility = new Array(config.tabs.length).fill(true); 148 | this._renderedTitles = config.tabs.map(tab => tab.title); 149 | this._renderedIcons = config.tabs.map(tab => tab.icon); 150 | 151 | if (this._hassSet) { 152 | this._subscribeToTemplates(this._config.tabs); 153 | } 154 | 155 | let initialTabIndex = 0; 156 | if (config.default_tab !== undefined) { 157 | const defaultIndex = config.default_tab - 1; 158 | if (defaultIndex >= 0 && defaultIndex < config.tabs.length) { 159 | initialTabIndex = defaultIndex; 160 | } else { 161 | console.warn(`[Simple Tabs] Invalid default_tab: ${config.default_tab}. Falling back to first tab.`); 162 | } 163 | } 164 | this._selectedTabIndex = initialTabIndex; 165 | 166 | if (this._config['pre-load']) { 167 | this._createCards(this._config.tabs).then(cards => { this._cards = cards; }); 168 | } 169 | } 170 | 171 | private _isTemplate(value: unknown): value is string { 172 | return typeof value === 'string' && (value.includes('{{') || value.includes('{%')); 173 | } 174 | 175 | private async _subscribeToTemplates(tabs: TabConfig[]): Promise { 176 | const renderTemplate = async (template: string, callback: (result: any) => void) => { 177 | try { 178 | const unsub = await this.hass.connection.subscribeMessage(callback, { type: 'render_template', template }); 179 | this._templateUnsubscribers.push(unsub); 180 | } catch (e) { 181 | console.error("[Simple Tabs] Error subscribing to template:", e); 182 | } 183 | }; 184 | 185 | const promises = tabs.flatMap((tab, index) => { 186 | const subs: Promise[] = []; 187 | const updateState = (key: '_renderedTitles' | '_renderedIcons', value: any) => { 188 | const currentArray = this[key]; 189 | if (currentArray[index] !== value) { 190 | const newArray = [...currentArray]; 191 | newArray[index] = value; 192 | this[key] = newArray; 193 | } 194 | }; 195 | 196 | if (this._isTemplate(tab.title)) { 197 | subs.push(renderTemplate(tab.title, msg => updateState('_renderedTitles', msg.result))); 198 | } 199 | if (this._isTemplate(tab.icon)) { 200 | subs.push(renderTemplate(tab.icon, msg => updateState('_renderedIcons', msg.result))); 201 | } 202 | tab.conditions?.forEach(cond => { 203 | if ('template' in cond) { 204 | subs.push(renderTemplate(cond.template, msg => { 205 | let isTrue = !!msg.result; 206 | if (typeof msg.result === 'string') { 207 | const lower = msg.result.toLowerCase().trim(); 208 | isTrue = lower !== 'false' && lower !== ''; 209 | } 210 | if (this._tabVisibility[index] !== isTrue) { 211 | const newVisibility = [...this._tabVisibility]; 212 | newVisibility[index] = isTrue; 213 | this._tabVisibility = newVisibility; 214 | } 215 | })); 216 | } 217 | }); 218 | return subs; 219 | }); 220 | await Promise.all(promises); 221 | } 222 | 223 | protected shouldUpdate(changedProps: Map): boolean { 224 | // Always update if the configuration, selected tab, or visibility changes. 225 | if ( 226 | changedProps.has('_config') || 227 | changedProps.has('_selectedTabIndex') || 228 | changedProps.has('_tabVisibility') || 229 | changedProps.has('_renderedTitles') || 230 | changedProps.has('_renderedIcons') 231 | ) { 232 | return true; 233 | } 234 | 235 | const oldHass = changedProps.get('hass') as HomeAssistant | undefined; 236 | 237 | // If there's no old hass object, we need to update. 238 | if (!oldHass) { 239 | return true; 240 | } 241 | 242 | // This is the key change: we check if the entity states have changed. 243 | // We also check 'localize' for language changes. 244 | return ( 245 | oldHass.states !== this.hass.states || 246 | oldHass.localize !== this.hass.localize 247 | ); 248 | } 249 | 250 | private _shouldShowTab(tab: TabConfig, index: number): boolean { 251 | return tab.conditions?.every(c => { 252 | if ('entity' in c) return this.hass.states[c.entity]?.state === c.state; 253 | if ('template' in c) return this._tabVisibility[index]; 254 | return false; 255 | }) ?? true; 256 | } 257 | 258 | private async _createCard(tabConfig: TabConfig): Promise { 259 | try { 260 | await this._loadHelpers(); 261 | const element = this._helpers.createCardElement(tabConfig.card) as LovelaceCard; 262 | element.hass = this.hass; 263 | return element; 264 | } catch (e) { 265 | console.error('[Simple Tabs] Error creating card:', tabConfig.card, e); 266 | return null; 267 | } 268 | } 269 | 270 | private async _ensureCard(index: number): Promise { 271 | if (this._cards[index] || !this._config.tabs[index]) return; 272 | const card = await this._createCard(this._config.tabs[index]); 273 | this._cards = [...this._cards.slice(0, index), card, ...this._cards.slice(index + 1)]; 274 | } 275 | 276 | private _scrollToActiveTab(smooth = true): void { 277 | const tabsContainer = this._tabsEl; 278 | const activeButton = this.shadowRoot?.querySelector('.tab-button.active'); 279 | if (tabsContainer && activeButton) { 280 | const containerRect = tabsContainer.getBoundingClientRect(); 281 | const buttonRect = activeButton.getBoundingClientRect(); 282 | const scrollLeft = buttonRect.left - containerRect.left + tabsContainer.scrollLeft - containerRect.width / 2 + buttonRect.width / 2; 283 | tabsContainer.scrollTo({ 284 | left: scrollLeft, 285 | behavior: smooth ? 'smooth' : 'auto' 286 | }); 287 | } 288 | } 289 | 290 | private _updateOverflowState(): void { 291 | const tabsContainer = this._tabsEl; 292 | const containerWrapper = this.shadowRoot?.querySelector('.tabs-container'); 293 | if (tabsContainer && containerWrapper) { 294 | const scrollBuffer = 1; 295 | const canScrollLeft = tabsContainer.scrollLeft > scrollBuffer; 296 | const canScrollRight = tabsContainer.scrollWidth > tabsContainer.clientWidth + tabsContainer.scrollLeft + scrollBuffer; 297 | containerWrapper.classList.toggle('can-scroll-left', canScrollLeft); 298 | containerWrapper.classList.toggle('can-scroll-right', canScrollRight); 299 | } 300 | } 301 | 302 | private async _createCards(tabConfigs: TabConfig[]): Promise<(LovelaceCard | null)[]> { 303 | await this._loadHelpers(); 304 | const cardPromises = tabConfigs.map(tab => this._createCard(tab)); 305 | return Promise.all(cardPromises); 306 | } 307 | 308 | public firstUpdated(): void { 309 | requestAnimationFrame(() => this._scrollToActiveTab(false)); 310 | 311 | if (!this._config['pre-load']) { 312 | setTimeout(() => this._startBackgroundCardLoading(), 100); 313 | } 314 | } 315 | 316 | private _startBackgroundCardLoading(): void { 317 | if (!this._config) return; 318 | 319 | const tabsToLoad = this._config.tabs 320 | .map((_, index) => index) 321 | .filter(index => index !== this._selectedTabIndex && !this._cards[index]); 322 | 323 | const loadNext = () => { 324 | if (tabsToLoad.length === 0) return; 325 | const indexToLoad = tabsToLoad.shift()!; 326 | this._ensureCard(indexToLoad).then(() => { 327 | requestAnimationFrame(loadNext); 328 | }); 329 | }; 330 | 331 | loadNext(); 332 | } 333 | 334 | // FIX: Updated drag handler to distinguish between click and drag 335 | private _handleDragStart(e: MouseEvent): void { 336 | const tabsEl = this._tabsEl; 337 | if (!tabsEl) return; 338 | 339 | if (e.button !== 0) return; // Only drag with left mouse button 340 | 341 | let isDragging = false; 342 | const startX = e.pageX; 343 | const scrollLeft = tabsEl.scrollLeft; 344 | 345 | const handleDragMove = (em: MouseEvent): void => { 346 | const walk = em.pageX - startX; 347 | 348 | // If we haven't started dragging and we've moved enough, start the drag 349 | if (!isDragging && Math.abs(walk) > 3) { 350 | isDragging = true; 351 | tabsEl.classList.add('dragging'); 352 | } 353 | 354 | if (isDragging) { 355 | tabsEl.scrollLeft = scrollLeft - walk; 356 | this._updateOverflowState(); 357 | } 358 | }; 359 | 360 | const handleDragEnd = (): void => { 361 | tabsEl.classList.remove('dragging'); 362 | document.removeEventListener('mousemove', handleDragMove); 363 | document.removeEventListener('mouseup', handleDragEnd); 364 | }; 365 | 366 | document.addEventListener('mousemove', handleDragMove); 367 | document.addEventListener('mouseup', handleDragEnd); 368 | } 369 | 370 | protected updated(changedProps: Map): void { 371 | if (this.hass && this._config && !this._hassSet) { 372 | this._hassSet = true; 373 | this._subscribeToTemplates(this._config.tabs); 374 | } 375 | 376 | if (changedProps.has('hass')) { 377 | this._cards.forEach(card => { if (card) card.hass = this.hass; }); 378 | } 379 | 380 | if (changedProps.has('_selectedTabIndex') && !this._config['pre-load']) { 381 | this._ensureCard(this._selectedTabIndex); 382 | } 383 | 384 | // FIX: No changes here, but confirming that this call now correctly uses 385 | // the default smooth scrolling for clicks. 386 | if (changedProps.has('_selectedTabIndex')) { 387 | this._scrollToActiveTab(); 388 | } 389 | 390 | if (changedProps.has('_config') || changedProps.has('_tabVisibility')) { 391 | requestAnimationFrame(() => this._updateOverflowState()); 392 | } 393 | } 394 | 395 | protected render(): TemplateResult { 396 | if (!this._config || !this.hass) return html``; 397 | 398 | const visibleTabs = this._config.tabs 399 | .map((tab, originalIndex) => ({ tab, originalIndex })) 400 | .filter(({ tab, originalIndex }) => this._shouldShowTab(tab, originalIndex)); 401 | 402 | if (visibleTabs.length > 0 && !visibleTabs.some(({ originalIndex }) => originalIndex === this._selectedTabIndex)) { 403 | Promise.resolve().then(() => { 404 | this._selectedTabIndex = visibleTabs[0].originalIndex; 405 | }); 406 | } 407 | 408 | const styles: { [key: string]: string | undefined } = { 409 | '--simple-tabs-bg-color': this._config['background-color'], 410 | '--simple-tabs-border-color': this._config['border-color'], 411 | '--simple-tabs-text-color': this._config['text-color'], 412 | '--simple-tabs-hover-color': this._config['hover-color'], 413 | '--simple-tabs-active-text-color': this._config['active-text-color'], 414 | '--simple-tabs-active-bg': this._config['active-background'], 415 | '--simple-tabs-container-bg': this._config.container_background, 416 | '--simple-tabs-container-padding': this._config.container_padding, 417 | '--simple-tabs-container-rounding': this._config.container_rounding, 418 | }; 419 | 420 | if (this._config.margin) { 421 | styles.margin = this._config.margin; 422 | } 423 | 424 | const content = this._config.tabs.map((tab, index) => html` 425 |
426 | ${this._shouldShowTab(tab, index) ? this._cards[index] : ''} 427 |
`); 428 | 429 | const alignmentClass = `align-${this._config.alignment || 'center'}`; 430 | 431 | return html` 432 |
433 |
434 |
435 | ${visibleTabs.map(({ originalIndex }) => html` 436 | ` 443 | )} 444 |
445 |
446 |
${content}
447 |
448 | `; 449 | } 450 | 451 | static styles = css` 452 | :host { display: block; } 453 | .card-container { 454 | position: relative; 455 | isolation: isolate; 456 | background: var(--simple-tabs-container-bg, none); 457 | padding: var(--simple-tabs-container-padding, 0); 458 | border-radius: var(--simple-tabs-container-rounding, 0); 459 | } 460 | 461 | .tabs-container { 462 | position: relative; 463 | overflow: hidden; 464 | } 465 | .tabs-container::before, .tabs-container::after { 466 | content: ''; 467 | position: absolute; 468 | top: 0; 469 | width: 60px; 470 | height: 100%; 471 | pointer-events: none; 472 | z-index: 10; 473 | will-change: opacity; 474 | transform: translateZ(0); 475 | opacity: 0; 476 | transition: opacity 0.3s ease; 477 | } 478 | .tabs-container::before { 479 | left: 0; 480 | background: linear-gradient(to right, var(--primary-background-color, white), transparent); 481 | } 482 | 483 | .tabs-container::after { 484 | right: 0; 485 | background: linear-gradient(to left, var(--primary-background-color, white), transparent); 486 | } 487 | .tabs-container.can-scroll-left::before { opacity: 1; } 488 | .tabs-container.can-scroll-right::after { opacity: 1; } 489 | 490 | .tabs { 491 | display: flex; 492 | flex-wrap: nowrap; 493 | gap: 6px; 494 | overflow-x: auto; 495 | overflow-y: hidden; 496 | padding: 1px; 497 | scroll-behavior: smooth; 498 | scrollbar-width: none; 499 | -ms-overflow-style: none; 500 | cursor: grab; 501 | user-select: none; 502 | -webkit-user-select: none; 503 | } 504 | 505 | .tabs.dragging { 506 | cursor: grabbing; 507 | } 508 | 509 | .tabs.dragging .tab-button { 510 | pointer-events: none; 511 | } 512 | 513 | .tabs::-webkit-scrollbar { display: none; } 514 | 515 | .tabs-container.align-start .tabs { 516 | justify-content: flex-start; 517 | } 518 | .tabs-container.align-end .tabs { 519 | justify-content: flex-end; 520 | } 521 | .tabs-container.align-center .tabs::before, 522 | .tabs-container.align-center .tabs::after { 523 | content: ''; 524 | flex-grow: 1; 525 | } 526 | 527 | .tab-button { 528 | box-sizing: border-box; 529 | background: var(--simple-tabs-bg-color, none); 530 | outline: 1px solid var(--simple-tabs-border-color, var(--divider-color)); 531 | border: none; 532 | cursor: pointer; 533 | padding: 8px 16px; 534 | font-size: var(--ha-font-size-m); 535 | color: var(--simple-tabs-text-color, var(--secondary-text-color)); 536 | position: relative; 537 | z-index: 1; 538 | border-radius: 24px; 539 | transition: all 0.3s; 540 | display: inline-flex; 541 | align-items: center; 542 | justify-content: center; 543 | gap: 8px; 544 | font-family: var(--primary-font-family); 545 | text-wrap: nowrap; 546 | } 547 | .tab-button ha-icon { margin-left: -4px; } 548 | .tab-button:not(:has(span)) { padding: 8px 12px; } 549 | .tab-button:not(:has(span)) ha-icon { margin: 0; } 550 | .tab-button:hover { 551 | color: var(--simple-tabs-hover-color, var(--primary-text-color)); 552 | outline-color: var(--simple-tabs-hover-color, var(--primary-text-color)); 553 | } 554 | .tab-button.active { 555 | color: var(--simple-tabs-active-text-color, var(--text-primary-color)); 556 | background: var(--simple-tabs-active-bg, var(--primary-color)); 557 | outline-color: transparent; 558 | } 559 | .content-container { padding-top: 12px; } 560 | .tab-panel[hidden] { display: none; } 561 | `; 562 | } 563 | 564 | window.customCards = window.customCards || []; 565 | window.customCards.push({ 566 | type: "simple-tabs", 567 | name: "Simple Tabs", 568 | preview: true, 569 | description: "A card to display multiple cards in a tabbed interface." 570 | }); --------------------------------------------------------------------------------