├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .np-config.js ├── .npmignore ├── LICENSE-MIT.txt ├── README.md ├── babel.config.js ├── biome.json ├── bun.lock ├── commitlint.config.js ├── helper ├── README.md ├── analyzer-utils.ts ├── analyzer.ts ├── classes.json ├── config.ts ├── data.ts ├── get-dynamic-classes.ts ├── index.ts ├── mappings.json └── utils.ts ├── inject.sh ├── package.json ├── postcss.config.js ├── public ├── favicon.ico └── index.html ├── rollup.config.js ├── src ├── App.vue ├── assets │ ├── logo.png │ └── vue.svg ├── components │ ├── README.md │ ├── compounds │ │ ├── Accordion │ │ │ ├── Accordion.vue │ │ │ └── AccordionMenu.vue │ │ ├── Breadcrumb │ │ │ ├── Breadcrumb.vue │ │ │ ├── BreadcrumbItem.vue │ │ │ └── __tests__ │ │ │ │ └── Breadcrumb.spec.ts │ │ ├── Card │ │ │ ├── Card.vue │ │ │ ├── CardContent.vue │ │ │ ├── CardFooter.vue │ │ │ ├── CardFooterItem.vue │ │ │ ├── CardHeader.vue │ │ │ ├── CardImage.vue │ │ │ └── __tests__ │ │ │ │ └── Card.spec.ts │ │ ├── Columns │ │ │ ├── Column.vue │ │ │ ├── Columns.vue │ │ │ └── __tests__ │ │ │ │ └── Columns.spec.ts │ │ ├── Dropdown │ │ │ ├── Dropdown.vue │ │ │ ├── DropdownItem.vue │ │ │ └── dropdown-symbol.ts │ │ ├── Menu │ │ │ ├── Menu.vue │ │ │ ├── MenuItem.vue │ │ │ ├── MenuList.vue │ │ │ └── __tests__ │ │ │ │ └── Menu.spec.ts │ │ ├── Navbar │ │ │ ├── Navbar.vue │ │ │ ├── NavbarBurger.vue │ │ │ ├── NavbarDropdown.vue │ │ │ └── NavbarItem.vue │ │ ├── Pagination │ │ │ ├── Pagination.vue │ │ │ ├── PaginationItem.vue │ │ │ └── __tests__ │ │ │ │ └── Pagination.spec.ts │ │ ├── Steps │ │ │ ├── StepItem.vue │ │ │ └── Steps.vue │ │ ├── Table │ │ │ ├── DataGrid.ts │ │ │ └── Table.vue │ │ ├── Tabs │ │ │ ├── Tab.vue │ │ │ └── Tabs.vue │ │ └── Toast │ │ │ ├── Toaster.ts │ │ │ ├── Toaster.vue │ │ │ ├── defaults │ │ │ └── position.ts │ │ │ ├── helpers │ │ │ ├── event-bus.ts │ │ │ ├── mount-component.ts │ │ │ ├── remove-element.ts │ │ │ └── timer.ts │ │ │ └── toast-api.ts │ ├── global-settings.js │ ├── index.js │ └── primitives │ │ ├── Autocomplete │ │ └── Autocomplete.vue │ │ ├── Avatar │ │ ├── Avatar.vue │ │ └── __tests__ │ │ │ └── Avatar.spec.ts │ │ ├── Button │ │ ├── Button.spec.ts │ │ └── Button.vue │ │ ├── Calendar │ │ ├── Calendar.vue │ │ └── bulma-calendar.d.ts │ │ ├── Chart │ │ └── Chart.vue │ │ ├── Checkbox │ │ └── Checkbox.vue │ │ ├── Container │ │ ├── Container.vue │ │ └── __tests__ │ │ │ └── Container.spec.ts │ │ ├── DepthChart │ │ ├── DepthChart.vue │ │ └── DepthChartBaseData.ts │ │ ├── Field │ │ ├── Field.vue │ │ └── __tests__ │ │ │ └── Field.spec.ts │ │ ├── FileInput │ │ ├── FileInput.vue │ │ └── __tests__ │ │ │ └── FileInput.spec.ts │ │ ├── Icon │ │ ├── Icon.vue │ │ └── __tests__ │ │ │ └── Icon.spec.ts │ │ ├── Image │ │ ├── Image.vue │ │ └── __tests__ │ │ │ └── Image.spec.ts │ │ ├── Input │ │ ├── EyeIcon.vue │ │ ├── Input.vue │ │ └── __tests__ │ │ │ └── Input.spec.ts │ │ ├── Media │ │ ├── Media.vue │ │ └── __tests__ │ │ │ └── Media.spec.ts │ │ ├── Modal │ │ ├── Modal.vue │ │ └── __tests__ │ │ │ └── Modal.spec.ts │ │ ├── Progress │ │ ├── Progress.vue │ │ └── __tests__ │ │ │ └── Progress.spec.ts │ │ ├── Select │ │ ├── Select.vue │ │ └── __tests__ │ │ │ └── Select.spec.ts │ │ ├── Sidebar │ │ └── Sidebar.vue │ │ ├── Slider │ │ ├── Slider.vue │ │ └── __tests__ │ │ │ └── Slider.spec.ts │ │ ├── Switch │ │ ├── Switch.vue │ │ └── __tests__ │ │ │ └── Switch.spec.ts │ │ ├── Tag │ │ ├── Tag.vue │ │ └── __tests__ │ │ │ └── Tag.spec.ts │ │ ├── Textarea │ │ ├── Textarea.vue │ │ └── __tests__ │ │ │ └── Textarea.spec.ts │ │ ├── Timeline │ │ ├── Timeline.vue │ │ └── __tests__ │ │ │ └── Timeline.spec.ts │ │ ├── Tooltip │ │ ├── Tooltip.vue │ │ └── __tests__ │ │ │ └── Tooltip.spec.ts │ │ └── Upload │ │ ├── Upload.vue │ │ └── __tests__ │ │ └── Upload.spec.ts ├── index.css ├── main.js ├── shims-vue.d.ts ├── state.js ├── types │ ├── component-types.ts │ └── globals.d.ts ├── utils │ ├── functions.spec.ts │ ├── functions.ts │ ├── onClickOutside.ts │ ├── tabs-store.ts │ └── unrefElement.ts └── views │ └── App.vue ├── tsconfig.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | tab_width = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: [ opened, synchronize, reopened ] 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Bun 18 | uses: oven-sh/setup-bun@v1 19 | with: 20 | bun-version: latest 21 | 22 | - name: Install dependencies 23 | run: bun install 24 | 25 | - name: Run linting 26 | run: bun run lint 27 | 28 | - name: Run tests 29 | run: bun run test 30 | 31 | - name: Build 32 | run: bun run build -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *.ps1 4 | .idea 5 | coverage 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | bun commitlint --edit ${1} -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | bun lint-staged -------------------------------------------------------------------------------- /.np-config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | // Avoids `np` trying to set 2FA again and again 3 | // after publishing to NPM 4 | exists: true, 5 | }; 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .* 3 | *config.* 4 | -------------------------------------------------------------------------------- /LICENSE-MIT.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 PathScale 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 | # Vue3-ui 2 | 3 |

4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 | Vue3 ready components library built with love and care designed to integrate beautifully with Bulma CSS 15 |

16 | 17 | ## Documentation 18 | 19 | The full documentation for Vue3-ui can be found on [vue3.dev](https://vue3.dev/). 20 | 21 | ## Requirements 22 | 23 | To build this project on node 20, you need to install these requiements. 24 | 25 | - python 2.7.15 26 | - visual studio build tools 2017 27 | 28 | ## Contributing 29 | 30 | ### Testing new components 31 | 32 | This repo only contains source code for components and does not come with examples or test environment where you can use them. 33 | 34 | For this purpose you can take a look at [vue3-starter](https://github.com/pathscale/vue3-starter) or any other end user project that uses vue3-ui. 35 | 36 | Additionally, if you want to develop new components, they can be locally injected into such project by using `inject.sh`, this will make them available immediately allowing you to test them. 37 | 38 | 1. Clone [vue3-starter](https://github.com/pathscale/vue3-starter) or any other project that makes use of vue3-ui and make sure they are on the same directory. 39 | 40 | ``` 41 | . 42 | ├── vue3-starter 43 | └── vue3-ui 44 | ``` 45 | 46 | 2. Run `bash inject.sh vue3-starter` to move your changes and update @pathscale/vue3-ui bundle locally on vue3-starter. 47 | 48 | ### Configuring components for efficient purging 49 | 50 | We have developed [rollup-plugin-vue3-ui-css-purge](https://github.com/pathscale/rollup-plugin-vue3-ui-css-purge) to handle very efficient purging on end user projects that use vue3-ui components, but the following guidelines must be followed to achieve efficient purging. 51 | 52 | 1. Whenever a component makes use of a transition, it must be registered in `helper/data.ts` 53 | 54 | 2. Components usually depend on classes that are named after a prop, for example in v-button, `is-rounded` class depends on the prop `rounded`, as this is very common, the purger will recognize this automatically, on the other hand, when a component class depends on a prop that is not named accordingly (a class x that depend on a prop not named is-x), it must be registered in `helpers/classes.json` 55 | 56 | 3. Build generates automatically mappings.json file which will be used by @rollup-plugin-vue3-ui-css-purge later 57 | 58 | ## License 59 | 60 | The MIT License (MIT). Please see [License File](https://github.com/pathscale/vue3-ui/blob/master/LICENSE-MIT.txt) for more information. 61 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | }; 4 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": ["dist", "coverage", "tsconfig.json"] 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 2 16 | }, 17 | "organizeImports": { 18 | "enabled": true 19 | }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true 24 | } 25 | }, 26 | "javascript": { 27 | "formatter": { 28 | "quoteStyle": "double" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | extends: ["@commitlint/config-conventional"], 3 | }; 4 | -------------------------------------------------------------------------------- /helper/README.md: -------------------------------------------------------------------------------- 1 | # Note 2 | 3 | mapping.json file is autogenerated from classes.json by analizer-utils.ts script on build -------------------------------------------------------------------------------- /helper/analyzer-utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type SFCScriptBlock, 3 | type SFCTemplateBlock, 4 | parse, 5 | } from "@vue/compiler-sfc"; 6 | 7 | export interface ParsedSFC { 8 | template: SFCTemplateBlock | null; 9 | script: SFCScriptBlock | null; 10 | } 11 | 12 | export function parseSFC(code: string, id: string): ParsedSFC { 13 | const { 14 | descriptor: { template, script }, 15 | } = parse(code, { 16 | sourceMap: false, 17 | sourceRoot: process.cwd(), 18 | filename: id, 19 | pad: "line", 20 | }); 21 | 22 | return { template, script }; 23 | } 24 | 25 | export function isVueSFC(id: string): boolean { 26 | return /\.vue$/i.test(id); 27 | } 28 | -------------------------------------------------------------------------------- /helper/classes.json: -------------------------------------------------------------------------------- 1 | { 2 | "VAccordion": { 3 | "is-horizontal": ["isHorizontal"], 4 | "is-vertical": ["isHorizontal"], 5 | "accordion-active": ["isActive", "hover"], 6 | "accordion-default": ["isActive", "hover"], 7 | "accordion-type-hover": ["hover"], 8 | "accordion-type-click": ["hover"], 9 | "header-is-trigger": ["headerIsTrigger"], 10 | "trigger-right": ["triggerRight"], 11 | "trigger-left": ["triggerLeft"] 12 | }, 13 | "VColumns": { 14 | "is-variable": ["gap"], 15 | "is-centered": ["hcentered"] 16 | }, 17 | "VDropdown": { 18 | "is-active": ["inline"], 19 | "is-mobile-modal": ["mobileModal"] 20 | }, 21 | "VDropdownItem": { 22 | "is-active": ["isActive"], 23 | "dropdown-item": ["hasLink"], 24 | "has-link": ["hasLink"], 25 | "is-mobile-modal": [] 26 | }, 27 | "VMenuItem": { 28 | "is-flex": ["icon"], 29 | "is-active": ["active"] 30 | }, 31 | "VNavbar": { 32 | "is-fixed-top": ["fixedTop"], 33 | "is-fixed-bottom": ["fixedBottom"], 34 | "has-shadow": ["shadow"], 35 | "is-active": [] 36 | }, 37 | "VNavbarBurger": { 38 | "is-active": ["isActive"] 39 | }, 40 | "VNavbarDropdown": { 41 | "is-hoverable": ["hoverable"], 42 | "is-active": ["active"] 43 | }, 44 | "VTable": { 45 | "table-container": ["sticky"], 46 | "sticky-table": ["sticky"], 47 | "tags": [], 48 | "has-addons": [], 49 | "has-ellipsis": [], 50 | "has-delete-icon": [], 51 | "is-delete": [], 52 | "is-fullwidth": [], 53 | "has-icons-left": [], 54 | "has-icons-right": [], 55 | "is-current": [], 56 | "has-mobile-cards": ["mobileCards"] 57 | }, 58 | "VSelect": { 59 | "is-fullwidth": ["expanded"] 60 | }, 61 | "VButton": { 62 | "is-fullwidth": ["expanded"], 63 | "disabled": ["disabled"] 64 | }, 65 | "VTag": { 66 | "tags": ["isClosable"], 67 | "has-addons": ["isClosable"], 68 | "has-ellipsis": ["ellipsis"], 69 | "has-delete-icon": ["closeIcon"], 70 | "is-delete": ["closeIcon"] 71 | }, 72 | "VSlider": { 73 | "is-circle": ["rounded"], 74 | "has-output-tooltip": ["tooltip"] 75 | }, 76 | "VTabs": { 77 | "is-fullwidth": ["expanded"], 78 | "is-toggle-rounded": ["rounded"], 79 | "is-toggle": ["rounded"], 80 | "is-active": [], 81 | "is-disabled": [], 82 | "is-height-animated": ["vanimated"] 83 | }, 84 | "VTab": { 85 | "is-fullwidth": [], 86 | "is-toggle-rounded": [], 87 | "is-toggle": [], 88 | "is-active": [], 89 | "is-disabled": [], 90 | "is-height-animated": [] 91 | }, 92 | "VField": { 93 | "is-grouped-multiline": ["groupMultiline"], 94 | "has-addons": ["addons"] 95 | }, 96 | "VFile": { 97 | "has-name": ["hasName"] 98 | }, 99 | "VImage": { 100 | "container": ["centered"] 101 | }, 102 | "VInput": { 103 | "has-icons-left": [], 104 | "has-icons-right": [] 105 | }, 106 | "VProgress": { 107 | "more-than-half": [] 108 | }, 109 | "VSidebar": { 110 | "is-fixed": ["position"], 111 | "is-static": ["position"], 112 | "is-absolute": ["position"], 113 | "is-mini": ["reduce"], 114 | "is-mini-expand": ["expandOnHover"], 115 | "is-mini-expand-fixed": ["isMiniExpandFixed"], 116 | "is-mini-mobile": ["isMiniMobile"], 117 | "is-hidden-mobile": ["isHiddenMobile"], 118 | "is-fullwidth-mobile": ["isFullwidthMobile"] 119 | }, 120 | "VTextarea": { 121 | "is-invisible": [] 122 | }, 123 | "VTooltip": { 124 | "v-tooltip": ["active"] 125 | }, 126 | "VIcon": { 127 | "icon": ["shouldIconClass"] 128 | }, 129 | "VTimeline": { 130 | "has-text-success": ["stages"], 131 | "is-active": ["stages"], 132 | "has-text-grey": ["stages"], 133 | "has-text-danger": ["stages"] 134 | }, 135 | "VUpload": { 136 | "is-fullwidth": ["expanded"], 137 | "has-name": [] 138 | }, 139 | "VPaginationItem": { 140 | "is-current": [] 141 | }, 142 | "VPagination": { 143 | "is-current": [] 144 | }, 145 | "VSteps": { 146 | "is-active": [], 147 | "is-completed": [], 148 | "is-disabled": [], 149 | "is-clickable": [] 150 | }, 151 | "VStepItem": { 152 | "is-active": [], 153 | "is-completed": [], 154 | "is-disabled": [], 155 | "is-clickable": [] 156 | }, 157 | "VAutocomplete": { 158 | "has-icons-left": [], 159 | "has-icons-right": [], 160 | "is-active": [], 161 | "is-grouped-multiline": [], 162 | "has-addons": [] 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /helper/config.ts: -------------------------------------------------------------------------------- 1 | import type { ParserOptions } from "@babel/parser"; 2 | 3 | export const parserOpts: ParserOptions = { 4 | sourceType: "unambiguous", 5 | plugins: [ 6 | "asyncGenerators", 7 | "bigInt", 8 | "classPrivateMethods", 9 | "classPrivateProperties", 10 | "classProperties", 11 | "decimal", 12 | "doExpressions", 13 | "dynamicImport", 14 | "exportDefaultFrom", 15 | "functionBind", 16 | "functionSent", 17 | "importMeta", 18 | "jsx", 19 | "logicalAssignment", 20 | "nullishCoalescingOperator", 21 | "numericSeparator", 22 | "objectRestSpread", 23 | "optionalCatchBinding", 24 | "optionalChaining", 25 | "partialApplication", 26 | "placeholders", 27 | "privateIn", 28 | "throwExpressions", 29 | "typescript", 30 | ], 31 | }; 32 | -------------------------------------------------------------------------------- /helper/data.ts: -------------------------------------------------------------------------------- 1 | // TODO: Dynamic `transition` name search logic 2 | 3 | export const transitions = ["fade", "slide", "slide-right", "slide-left"]; 4 | -------------------------------------------------------------------------------- /helper/get-dynamic-classes.ts: -------------------------------------------------------------------------------- 1 | function getDynamicClasses(raw: string): { 2 | optional: string[]; 3 | unstable: string[]; 4 | } { 5 | // finds everything between simple quotes in value, where value is 6 | const classes: string[] = []; 7 | 8 | const tokens = raw.match(/(?<=').+?(?=')/g) ?? []; 9 | const goodClasses = tokens.filter((cl) => !cl.startsWith(":")); 10 | for (const cl of goodClasses) { 11 | for (const cll of cl.split(" ")) { 12 | classes.push(cll); 13 | } 14 | } 15 | 16 | // expected and most common pattern is: classes like is-X will depend on a prop named X 17 | // nevertheless, sometimes a class will depend on a computed prop or on a specifically named prop 18 | 19 | // list of classes not named is-x or that are named is-x but depend on something other than x 20 | const unusualClasses = classes.filter( 21 | (cl) => !cl.startsWith("is-") || !raw.includes(`'${cl}': ${cl.slice(3)}`), 22 | ); 23 | 24 | // list of classes that are not unusual 25 | const optional = classes.filter((cl) => !unusualClasses.includes(cl)); 26 | 27 | return { optional, unstable: unusualClasses }; 28 | } 29 | 30 | export default getDynamicClasses; 31 | -------------------------------------------------------------------------------- /helper/index.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { parse as jsparserParse } from "@babel/parser"; 3 | import traverse from "@babel/traverse"; 4 | import fg from "fast-glob"; 5 | import fs from "fs-extra"; 6 | import { getWhitelist } from "./analyzer"; 7 | import { isVueSFC } from "./analyzer-utils"; 8 | import { parserOpts } from "./config"; 9 | import { normalizePath } from "./utils"; 10 | 11 | const srcDir = path.resolve(__dirname, "..", "src"); 12 | 13 | async function main(): Promise { 14 | const namesMap: Record = {}; 15 | const inputFile = normalizePath(srcDir, "components", "index.js"); 16 | const inputCode = fs.readFileSync(inputFile, "utf-8"); 17 | 18 | const ast = jsparserParse(inputCode, parserOpts); 19 | traverse(ast, { 20 | // eslint-disable-next-line @typescript-eslint/naming-convention -- AST 21 | ExportNamedDeclaration({ node }) { 22 | if (!node.source) return; 23 | const { value } = node.source; 24 | if (!isVueSFC(value)) return; 25 | for (const spec of node.specifiers) { 26 | if (spec.type !== "ExportSpecifier") continue; 27 | if (spec.local.name !== "default") continue; 28 | if ("name" in spec.exported) 29 | namesMap[path.parse(value).name] = spec.exported.name; 30 | } 31 | }, 32 | }); 33 | 34 | const mappings: Record = 35 | {}; 36 | const pattern = normalizePath(srcDir, "**", "*.vue"); 37 | const files = ((await fg(pattern)) as string[]).sort(); 38 | 39 | for await (const file of files) { 40 | const name = namesMap[path.parse(file).name]; 41 | if (!name) continue; 42 | mappings[name] = getWhitelist(file, name); 43 | } 44 | 45 | fs.writeFileSync( 46 | path.join(__dirname, "mappings.json"), 47 | JSON.stringify(mappings, null, " "), 48 | ); 49 | } 50 | 51 | main(); 52 | -------------------------------------------------------------------------------- /helper/utils.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import queryString from "query-string"; 3 | 4 | /** Parsed Vue SFC query. */ 5 | export type Query = 6 | | { vue: false } 7 | | { 8 | filename: string; 9 | vue: true; 10 | type: "custom"; 11 | index?: number; 12 | src: boolean; 13 | } 14 | | { filename: string; vue: true; type: "template"; id?: string; src: boolean } 15 | | { filename: string; vue: true; type: "script"; src: boolean } 16 | | { 17 | filename: string; 18 | vue: true; 19 | type: "style"; 20 | index?: number; 21 | id?: string; 22 | scoped?: boolean; 23 | module?: string | boolean; 24 | src: boolean; 25 | }; 26 | 27 | export function parseQuery(id: string): Query { 28 | const [filename, query] = id.split("?", 2); 29 | if (!query) return { vue: false }; 30 | 31 | const raw = queryString.parse(query); 32 | if (!("vue" in raw)) return { vue: false }; 33 | 34 | return { 35 | ...raw, 36 | filename, 37 | vue: true, 38 | index: raw.index && Number(raw.index), 39 | src: "src" in raw, 40 | scoped: "scoped" in raw, 41 | } as Query; 42 | } 43 | 44 | export function normalizePath(...paths: string[]): string { 45 | const f = path.join(...paths).replace(/\\/g, "/"); 46 | if (/^\.[/\\]/.test(paths[0])) return `./${f}`; 47 | return f; 48 | } 49 | -------------------------------------------------------------------------------- /inject.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Vue3-UI does not comes with examples nor a test environment where you could use components 4 | # For this purpose you can take a look at vue3-starter or some end user project that uses vue3-ui 5 | # 6 | # Additionally, if you wish to have available on such project components that are local 7 | # Invoke this script with the name of such component and it will inject the latest bundle into that project 8 | 9 | # For example 10 | # ./inject.sh my-dev-environment 11 | # will copy inject the latest local bundle into ../my-dev-environment/node_modules/@pathscale/vue3-ui/dist/ 12 | 13 | if [ -z $1 ] 14 | then 15 | echo "expected project name where the bundle should be injected" 16 | exit 17 | fi 18 | 19 | bun run build 20 | echo "bundle created" 21 | 22 | cp ./dist/* ../$1/node_modules/@pathscale/vue3-ui/dist/ 23 | 24 | if [ $? -eq 0 ]; then 25 | echo "bundle injected into $1" 26 | else 27 | echo "there was a problem injecting bundle into $1" 28 | fi 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@pathscale/vue3-ui", 3 | "version": "1.0.18", 4 | "description": "Very clean Vue3 components styled with love and care.", 5 | "type": "module", 6 | "keywords": [ 7 | "vue", 8 | "vue3", 9 | "ui" 10 | ], 11 | "homepage": "https://github.com/pathscale/vue3-ui#readme", 12 | "bugs": { 13 | "url": "https://github.com/pathscale/vue3-ui/issues" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/pathscale/vue3-ui.git" 18 | }, 19 | "license": "MIT", 20 | "author": "PathScale (https://vue3.dev/)", 21 | "contributors": [], 22 | "sideEffects": false, 23 | "main": "dist/bundle.js", 24 | "module": "dist/bundle.js", 25 | "browser": "dist/bundle-browser.js", 26 | "scripts": { 27 | "prepare": "husky", 28 | "prebuild": "bun run helper/index.ts && rm -rf dist || true", 29 | "build": "rollup -c --environment NODE_ENV:production", 30 | "postbuild": "shx cp helper/mappings.json helper/classes.json dist", 31 | "prepublishOnly": "bun run build", 32 | "release": "bun run build && np --yolo", 33 | "lint": "biome lint --write .", 34 | "lint:fix": "biome check --write .", 35 | "test": "vitest run", 36 | "test:watch": "vitest", 37 | "test:coverage": "vitest run --coverage", 38 | "test:ui": "vitest --ui" 39 | }, 40 | "browserslist": [ 41 | "last 2 versions", 42 | "not IE 11", 43 | "not dead", 44 | "not OperaMini all" 45 | ], 46 | "lint-staged": { 47 | "*.{js,ts,vue,spec.ts}": [ 48 | "biome check --write" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "@babel/core": "^7.26.10", 53 | "@babel/parser": "^7.27.0", 54 | "@babel/traverse": "^7.27.0", 55 | "@biomejs/biome": "1.9.4", 56 | "@commitlint/cli": "^19.8.0", 57 | "@commitlint/config-conventional": "^19.8.0", 58 | "@pathscale/frappe-charts": "^0.0.2", 59 | "@rollup/plugin-alias": "^5.1.1", 60 | "@rollup/plugin-node-resolve": "^16.0.1", 61 | "@rollup/plugin-replace": "^6.0.2", 62 | "@rollup/plugin-sucrase": "^5.0.2", 63 | "@testing-library/vue": "^8.1.0", 64 | "@types/babel__traverse": "7.20.7", 65 | "@types/fs-extra": "11.0.4", 66 | "@types/node": "22.14.1", 67 | "@types/resolve": "1.20.6", 68 | "@vitejs/plugin-vue": "^5.2.3", 69 | "@vitest/coverage-v8": "^3.1.1", 70 | "@vue/compiler-sfc": "3.5.13", 71 | "@vue/test-utils": "^2.4.6", 72 | "autoprefixer": "^10.4.21", 73 | "fast-glob": "3.3.3", 74 | "fs-extra": "11.3.0", 75 | "htmlparser2": "10.0.0", 76 | "husky": "^9.1.7", 77 | "jsdom": "^26.1.0", 78 | "lint-staged": "^15.5.1", 79 | "np": "^10.2.0", 80 | "postcss": "^8.5.3", 81 | "query-string": "9.1.1", 82 | "resolve": "1.22.10", 83 | "rollup": "^4.40.0", 84 | "rollup-plugin-node-externals": "^8.0.0", 85 | "rollup-plugin-styles": "^4.0.0", 86 | "shx": "0.4.0", 87 | "typescript": "^5.8.3", 88 | "vitest": "^3.1.1", 89 | "vue": "^3.5.13" 90 | }, 91 | "engines": { 92 | "node": ">=10.0.0" 93 | }, 94 | "publishConfig": { 95 | "access": "public", 96 | "registry": "https://registry.npmjs.org/" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | import autoprefixer from "autoprefixer"; 2 | 3 | export default { 4 | plugins: [autoprefixer], 5 | }; 6 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathscale/vue3-ui/aaba5f4fb4040c75103713564c6cecc51b21cb19/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Vue 3 Bulma components 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { fileURLToPath } from "node:url"; 3 | import alias from "@rollup/plugin-alias"; 4 | import resolve from "@rollup/plugin-node-resolve"; 5 | import replace from "@rollup/plugin-replace"; 6 | import sucrase from "@rollup/plugin-sucrase"; 7 | import vue from "@vitejs/plugin-vue"; 8 | import externals from "rollup-plugin-node-externals"; 9 | import styles from "rollup-plugin-styles"; 10 | 11 | import pkg from "./package.json" with { type: "json" }; 12 | 13 | const __filename = fileURLToPath(import.meta.url); 14 | const __dirname = path.dirname(__filename); 15 | 16 | export default [ 17 | { 18 | input: "src/components/index.js", 19 | external: ["vue"], 20 | output: { 21 | format: "es", 22 | file: pkg.module, 23 | assetFileNames: "[name][extname]", 24 | }, 25 | plugins: [ 26 | alias({ 27 | entries: [{ find: "@", replacement: path.resolve(__dirname, "src") }], 28 | }), 29 | externals({ deps: true }), 30 | replace({ 31 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(true), 32 | preventAssignment: true, 33 | }), 34 | styles({ mode: ["extract", "bundle.css"], url: { inline: true } }), 35 | resolve({ extensions: [".vue", ".js", ".css"] }), 36 | vue(), 37 | sucrase({ 38 | transforms: ["typescript"], 39 | }), 40 | ], 41 | }, 42 | { 43 | input: "src/components/index.js", 44 | output: { 45 | format: "es", 46 | file: pkg.browser, 47 | assetFileNames: "[name][extname]", 48 | }, 49 | plugins: [ 50 | alias({ 51 | entries: [{ find: "@", replacement: path.resolve(__dirname, "src") }], 52 | }), 53 | // Not defined in browser 54 | replace({ 55 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV), 56 | __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: JSON.stringify(true), 57 | preventAssignment: true, 58 | }), 59 | resolve({ extensions: [".vue", ".js"] }), 60 | vue(), 61 | // Vue plugin won't handle CSS currently 62 | styles(), 63 | sucrase({ 64 | transforms: ["typescript"], 65 | }), 66 | ], 67 | }, 68 | ]; 69 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 17 | 18 | 28 | -------------------------------------------------------------------------------- /src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pathscale/vue3-ui/aaba5f4fb4040c75103713564c6cecc51b21cb19/src/assets/logo.png -------------------------------------------------------------------------------- /src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | Vue.js icon -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # Components library 2 | 3 | - [Button](#button) 4 | - [Card](#card) 5 | - [CardBody](#cardbody) 6 | - [CardTitle](#cardtitle) 7 | - [Input](#input) 8 | - [Typography](#typography) 9 | 10 | ## Button 11 | 12 | | Name | Type | Required | Default | Values | Description | 13 | | ----- | ------ | -------- | ------- | ----------------- | ---------------- | 14 | | color | string | false | wine | light, dark, wine | background color | 15 | | slot | node | | | | button content | 16 | 17 | ## Card 18 | 19 | | Name | Type | Required | Default | Values | Description | 20 | | ----- | ------ | -------- | ------- | ----------- | ---------------- | 21 | | color | string | false | light | light, dark | background color | 22 | | slot | node | | | | card content | 23 | 24 | ## CardBody 25 | 26 | | Name | Type | Required | Default | Values | Description | 27 | | ---- | ---- | -------- | ------- | ------ | ----------------- | 28 | | slot | node | | | | card body content | 29 | 30 | ## CardTitle 31 | 32 | | Name | Type | Required | Default | Values | Description | 33 | | ---- | ---- | -------- | ------- | ------ | ------------------- | 34 | | slot | node | | | | card header content | 35 | 36 | ## Input 37 | 38 | | Name | Type | Required | Default | Values | Description | 39 | | ----- | ------ | -------- | ------- | ------ | ----------------------------------- | 40 | | label | string | false | | | label for the input | 41 | | name | string | true | | | field name, binding label and input | 42 | 43 | ## Typography 44 | 45 | | Name | Type | Required | Default | Values | Description | 46 | | ------- | ------ | -------- | ------- | ------------------------- | ---------------------------- | 47 | | variant | string | false | p | h1, h2, h3, h4, h5, h6, p | html tag to use for the text | 48 | | color | string | false | dark | light, dark | text color | 49 | | slot | node | | | | text | 50 | -------------------------------------------------------------------------------- /src/components/compounds/Accordion/Accordion.vue: -------------------------------------------------------------------------------- 1 | 51 | 52 | 100 | -------------------------------------------------------------------------------- /src/components/compounds/Accordion/AccordionMenu.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/compounds/Breadcrumb/Breadcrumb.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 20 | -------------------------------------------------------------------------------- /src/components/compounds/Breadcrumb/BreadcrumbItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /src/components/compounds/Breadcrumb/__tests__/Breadcrumb.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { h } from "vue"; 4 | 5 | import Breadcrumb from "../Breadcrumb.vue"; 6 | import BreadcrumbItem from "../BreadcrumbItem.vue"; 7 | 8 | describe("Breadcrumb", () => { 9 | it("renders default breadcrumb", () => { 10 | const wrapper = mount(Breadcrumb, { 11 | slots: { 12 | default: () => [ 13 | h(BreadcrumbItem, null, () => "Home"), 14 | h(BreadcrumbItem, null, () => "Components"), 15 | h(BreadcrumbItem, { active: true }, () => "Breadcrumb"), 16 | ], 17 | }, 18 | }); 19 | 20 | expect(wrapper.find("nav.breadcrumb").exists()).toBe(true); 21 | expect(wrapper.findAll("li").length).toBe(3); 22 | expect(wrapper.find("li.is-active").exists()).toBe(true); 23 | expect(wrapper.find("li.is-active").text()).toBe("Breadcrumb"); 24 | }); 25 | 26 | it("applies alignment classes", () => { 27 | const centerWrapper = mount(Breadcrumb, { 28 | props: { alignment: "is-centered" }, 29 | }); 30 | expect(centerWrapper.find("nav.breadcrumb").classes()).toContain( 31 | "is-centered", 32 | ); 33 | 34 | const rightWrapper = mount(Breadcrumb, { 35 | props: { alignment: "is-right" }, 36 | }); 37 | expect(rightWrapper.find("nav.breadcrumb").classes()).toContain("is-right"); 38 | }); 39 | 40 | it("applies separator classes", () => { 41 | const separators = [ 42 | "has-arrow-separator", 43 | "has-bullet-separator", 44 | "has-dot-separator", 45 | "has-succeeds-separator", 46 | ]; 47 | for (const separator of separators) { 48 | const wrapper = mount(Breadcrumb, { props: { separator } }); 49 | expect(wrapper.find("nav.breadcrumb").classes()).toContain(separator); 50 | } 51 | }); 52 | 53 | it("applies size classes", () => { 54 | const sizes = ["is-small", "is-medium", "is-large"]; 55 | for (const size of sizes) { 56 | const wrapper = mount(Breadcrumb, { props: { size } }); 57 | expect(wrapper.find("nav.breadcrumb").classes()).toContain(size); 58 | } 59 | }); 60 | }); 61 | 62 | describe("BreadcrumbItem", () => { 63 | it("renders default item with tag", () => { 64 | const wrapper = mount(BreadcrumbItem, { 65 | slots: { default: () => "Link Item" }, 66 | attrs: { href: "#test" }, 67 | }); 68 | const item = wrapper.find("li"); 69 | const link = item.find("a"); 70 | expect(item.exists()).toBe(true); 71 | expect(link.exists()).toBe(true); 72 | expect(link.text()).toBe("Link Item"); 73 | expect(link.attributes("href")).toBe("#test"); 74 | expect(item.classes()).not.toContain("is-active"); 75 | }); 76 | 77 | it("renders item with custom tag", () => { 78 | const wrapper = mount(BreadcrumbItem, { 79 | props: { tag: "span" }, 80 | slots: { default: () => "Span Item" }, 81 | }); 82 | const item = wrapper.find("li"); 83 | expect(item.find("span").exists()).toBe(true); 84 | expect(item.find("span").text()).toBe("Span Item"); 85 | }); 86 | 87 | it("renders active item", () => { 88 | const wrapper = mount(BreadcrumbItem, { 89 | props: { active: true }, 90 | slots: { default: () => "Active Item" }, 91 | }); 92 | const item = wrapper.find("li"); 93 | expect(item.classes()).toContain("is-active"); 94 | expect(item.find("a").exists()).toBe(true); 95 | expect(item.text()).toBe("Active Item"); 96 | }); 97 | 98 | it("renders active item with custom tag", () => { 99 | const wrapper = mount(BreadcrumbItem, { 100 | props: { active: true, tag: "p" }, 101 | slots: { default: () => "Active Paragraph" }, 102 | }); 103 | const item = wrapper.find("li"); 104 | expect(item.classes()).toContain("is-active"); 105 | expect(item.find("p").exists()).toBe(true); 106 | expect(item.find("p").text()).toBe("Active Paragraph"); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /src/components/compounds/Card/Card.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 21 | -------------------------------------------------------------------------------- /src/components/compounds/Card/CardContent.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /src/components/compounds/Card/CardFooter.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 19 | -------------------------------------------------------------------------------- /src/components/compounds/Card/CardFooterItem.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /src/components/compounds/Card/CardHeader.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 23 | -------------------------------------------------------------------------------- /src/components/compounds/Card/CardImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 11 | -------------------------------------------------------------------------------- /src/components/compounds/Columns/Column.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 62 | -------------------------------------------------------------------------------- /src/components/compounds/Columns/Columns.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 59 | -------------------------------------------------------------------------------- /src/components/compounds/Columns/__tests__/Columns.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { h } from "vue"; 4 | import Column from "../Column.vue"; 5 | import Columns from "../Columns.vue"; 6 | 7 | describe("Columns", () => { 8 | it("renders default columns", () => { 9 | const wrapper = mount(Columns); 10 | expect(wrapper.find(".columns").exists()).toBe(true); 11 | }); 12 | 13 | it("renders columns with content", () => { 14 | const wrapper = mount(Columns, { 15 | slots: { 16 | default: "
Column content
", 17 | }, 18 | }); 19 | expect(wrapper.text()).toBe("Column content"); 20 | }); 21 | 22 | it("applies responsive classes", () => { 23 | const wrapper = mount(Columns, { 24 | props: { 25 | mobile: true, 26 | desktop: true, 27 | }, 28 | }); 29 | const columns = wrapper.find(".columns"); 30 | expect(columns.classes()).toContain("is-mobile"); 31 | expect(columns.classes()).toContain("is-desktop"); 32 | }); 33 | 34 | it("applies gap classes", () => { 35 | const wrapper = mount(Columns, { 36 | props: { 37 | gap: "is-1", 38 | gapless: true, 39 | }, 40 | }); 41 | const columns = wrapper.find(".columns"); 42 | expect(columns.classes()).toContain("is-1"); 43 | expect(columns.classes()).toContain("is-gapless"); 44 | expect(columns.classes()).toContain("is-variable"); 45 | }); 46 | 47 | it("applies alignment classes", () => { 48 | const wrapper = mount(Columns, { 49 | props: { 50 | vcentered: true, 51 | hcentered: true, 52 | multiline: true, 53 | }, 54 | }); 55 | const columns = wrapper.find(".columns"); 56 | expect(columns.classes()).toContain("is-vcentered"); 57 | expect(columns.classes()).toContain("is-centered"); 58 | expect(columns.classes()).toContain("is-multiline"); 59 | }); 60 | 61 | it("renders with nested columns", () => { 62 | const wrapper = mount(Columns, { 63 | slots: { 64 | default: [ 65 | h(Column, null, () => "Column 1"), 66 | h(Column, null, () => "Column 2"), 67 | h(Column, null, () => "Column 3"), 68 | ], 69 | }, 70 | global: { 71 | components: { 72 | Column, 73 | }, 74 | }, 75 | }); 76 | 77 | expect(wrapper.findAllComponents(Column)).toHaveLength(3); 78 | expect(wrapper.findAll(".column")).toHaveLength(3); 79 | }); 80 | }); 81 | 82 | describe("Column", () => { 83 | it("renders default column", () => { 84 | const wrapper = mount(Column); 85 | expect(wrapper.find(".column").exists()).toBe(true); 86 | }); 87 | 88 | it("renders column with content", () => { 89 | const wrapper = mount(Column, { 90 | slots: { 91 | default: "Column content", 92 | }, 93 | }); 94 | expect(wrapper.text()).toBe("Column content"); 95 | }); 96 | 97 | it("applies size class", () => { 98 | const wrapper = mount(Column, { 99 | props: { 100 | size: "is-4", 101 | }, 102 | }); 103 | expect(wrapper.classes()).toContain("is-4"); 104 | }); 105 | 106 | it("applies offset class", () => { 107 | const wrapper = mount(Column, { 108 | props: { 109 | offset: "is-offset-3", 110 | }, 111 | }); 112 | expect(wrapper.classes()).toContain("is-offset-3"); 113 | }); 114 | 115 | it("applies narrow classes", () => { 116 | const wrapper = mount(Column, { 117 | props: { 118 | narrow: true, 119 | narrowBreakpoint: "is-narrow-mobile", 120 | }, 121 | }); 122 | expect(wrapper.classes()).toContain("is-narrow"); 123 | expect(wrapper.classes()).toContain("is-narrow-mobile"); 124 | }); 125 | 126 | it("combines multiple props", () => { 127 | const wrapper = mount(Column, { 128 | props: { 129 | size: "is-6", 130 | offset: "is-offset-2", 131 | narrow: true, 132 | narrowBreakpoint: "is-narrow-tablet", 133 | }, 134 | }); 135 | const classes = wrapper.classes(); 136 | expect(classes).toContain("is-6"); 137 | expect(classes).toContain("is-offset-2"); 138 | expect(classes).toContain("is-narrow"); 139 | expect(classes).toContain("is-narrow-tablet"); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/components/compounds/Dropdown/Dropdown.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 104 | -------------------------------------------------------------------------------- /src/components/compounds/Dropdown/DropdownItem.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 88 | -------------------------------------------------------------------------------- /src/components/compounds/Dropdown/dropdown-symbol.ts: -------------------------------------------------------------------------------- 1 | import type { InjectionKey } from "vue"; 2 | 3 | export type DDSelection = { 4 | // biome-ignore lint/suspicious/noExplicitAny: allow any type according to docs 5 | value: any; 6 | // biome-ignore lint/suspicious/noExplicitAny: allow any type according to docs 7 | selectItem: (newValue: any) => void; 8 | }; 9 | 10 | export const DropdownSymbol = Symbol("Dropdown") as InjectionKey; 11 | -------------------------------------------------------------------------------- /src/components/compounds/Menu/Menu.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 10 | -------------------------------------------------------------------------------- /src/components/compounds/Menu/MenuItem.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 95 | -------------------------------------------------------------------------------- /src/components/compounds/Menu/MenuList.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 16 | -------------------------------------------------------------------------------- /src/components/compounds/Navbar/Navbar.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 66 | -------------------------------------------------------------------------------- /src/components/compounds/Navbar/NavbarBurger.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 19 | -------------------------------------------------------------------------------- /src/components/compounds/Navbar/NavbarDropdown.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 70 | -------------------------------------------------------------------------------- /src/components/compounds/Navbar/NavbarItem.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 28 | -------------------------------------------------------------------------------- /src/components/compounds/Pagination/PaginationItem.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 40 | -------------------------------------------------------------------------------- /src/components/compounds/Pagination/__tests__/Pagination.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Pagination from "../Pagination.vue"; 4 | 5 | describe("Pagination", () => { 6 | it("renders with default props", () => { 7 | const wrapper = mount(Pagination, { 8 | props: { 9 | total: 100, 10 | }, 11 | }); 12 | expect(wrapper.find("nav").exists()).toBe(true); 13 | expect(wrapper.find(".pagination-previous").exists()).toBe(true); 14 | expect(wrapper.find(".pagination-next").exists()).toBe(true); 15 | expect(wrapper.findAll(".pagination-link").length).toBe(5); 16 | }); 17 | 18 | it("calculates pages correctly", () => { 19 | const wrapper = mount(Pagination, { 20 | props: { 21 | total: 100, 22 | perPage: 20, 23 | current: 1, 24 | }, 25 | }); 26 | expect(wrapper.findAll(".pagination-link").length).toBe(5); 27 | }); 28 | 29 | it("handles navigation", async () => { 30 | const wrapper = mount(Pagination, { 31 | props: { 32 | total: 100, 33 | perPage: 20, 34 | current: 1, 35 | }, 36 | }); 37 | 38 | await wrapper.find(".pagination-next").trigger("click"); 39 | expect(wrapper.emitted("update:current")?.[0]).toEqual([2]); 40 | expect(wrapper.emitted("change")?.[0]).toEqual([2]); 41 | }); 42 | 43 | it("disables previous button on first page", () => { 44 | const wrapper = mount(Pagination, { 45 | props: { 46 | total: 100, 47 | perPage: 20, 48 | current: 1, 49 | }, 50 | }); 51 | expect( 52 | wrapper.find(".pagination-previous").attributes("disabled"), 53 | ).toBeDefined(); 54 | }); 55 | 56 | it("disables next button on last page", () => { 57 | const wrapper = mount(Pagination, { 58 | props: { 59 | total: 100, 60 | perPage: 20, 61 | current: 5, 62 | }, 63 | }); 64 | expect( 65 | wrapper.find(".pagination-next").attributes("disabled"), 66 | ).toBeDefined(); 67 | }); 68 | 69 | it("shows simple view when simple prop is true", () => { 70 | const wrapper = mount(Pagination, { 71 | props: { 72 | total: 100, 73 | perPage: 20, 74 | current: 2, 75 | simple: true, 76 | }, 77 | }); 78 | expect(wrapper.find(".info").exists()).toBe(true); 79 | expect(wrapper.find(".info").text()).toBe("21-40 / 100"); 80 | }); 81 | 82 | it("applies style props correctly", () => { 83 | const wrapper = mount(Pagination, { 84 | props: { 85 | total: 100, 86 | size: "is-small", 87 | rounded: true, 88 | order: "is-centered", 89 | }, 90 | }); 91 | const nav = wrapper.find("nav"); 92 | expect(nav.classes()).toContain("is-small"); 93 | expect(nav.classes()).toContain("is-rounded"); 94 | expect(nav.classes()).toContain("is-centered"); 95 | }); 96 | 97 | it("handles range props correctly", () => { 98 | const wrapper = mount(Pagination, { 99 | props: { 100 | total: 200, 101 | current: 5, 102 | rangeBefore: 2, 103 | rangeAfter: 2, 104 | perPage: 20, 105 | }, 106 | }); 107 | const links = wrapper.findAll(".pagination-link"); 108 | const numbers = links.map((link) => link.text()); 109 | expect(numbers).toContain("1"); 110 | expect(numbers).toContain("3"); 111 | expect(numbers).toContain("4"); 112 | expect(numbers).toContain("5"); 113 | expect(numbers).toContain("6"); 114 | expect(numbers).toContain("7"); 115 | expect(numbers).toContain("10"); 116 | }); 117 | 118 | it("handles aria labels correctly", () => { 119 | const wrapper = mount(Pagination, { 120 | props: { 121 | total: 100, 122 | current: 2, 123 | ariaNextLabel: "Next page", 124 | ariaPreviousLabel: "Previous page", 125 | ariaPageLabel: "Page", 126 | ariaCurrentLabel: "Current", 127 | }, 128 | }); 129 | expect(wrapper.find(".pagination-next").attributes("aria-label")).toBe( 130 | "Next page", 131 | ); 132 | expect(wrapper.find(".pagination-previous").attributes("aria-label")).toBe( 133 | "Previous page", 134 | ); 135 | const currentPage = wrapper.find(".pagination-link.is-current"); 136 | expect(currentPage.attributes("aria-label")).toBe("Current, Page 2."); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /src/components/compounds/Steps/StepItem.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 36 | -------------------------------------------------------------------------------- /src/components/compounds/Steps/Steps.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 88 | -------------------------------------------------------------------------------- /src/components/compounds/Tabs/Tab.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 57 | -------------------------------------------------------------------------------- /src/components/compounds/Tabs/Tabs.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 89 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/Toaster.ts: -------------------------------------------------------------------------------- 1 | import type { App } from "vue"; 2 | import Toaster from "./Toaster.vue"; 3 | import ToasterPositions from "./defaults/position"; 4 | import createToaster from "./toast-api"; 5 | 6 | Toaster.install = (app: App, options = {}) => { 7 | const methods = createToaster(options); 8 | app.config.globalProperties.$toast = methods; 9 | app.provide("$toast", methods); 10 | }; 11 | 12 | export default Toaster; 13 | export { Toaster, ToasterPositions, createToaster }; 14 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/Toaster.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 177 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/defaults/position.ts: -------------------------------------------------------------------------------- 1 | enum Position { 2 | TOP_RIGHT = "top-right", 3 | TOP = "top", 4 | TOP_LEFT = "top-left", 5 | BOTTOM_RIGHT = "bottom-right", 6 | BOTTOM = "bottom", 7 | BOTTOM_LEFT = "bottom-left", 8 | } 9 | 10 | export default Position; 11 | 12 | export function definePosition(position: Position, top: T, bottom: B) { 13 | let result: T | B; 14 | switch (position) { 15 | case Position.TOP: 16 | case Position.TOP_RIGHT: 17 | case Position.TOP_LEFT: 18 | result = top; 19 | break; 20 | 21 | case Position.BOTTOM: 22 | case Position.BOTTOM_RIGHT: 23 | case Position.BOTTOM_LEFT: 24 | result = bottom; 25 | break; 26 | default: 27 | result = top; 28 | } 29 | return result; 30 | } 31 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/helpers/event-bus.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/suspicious/noExplicitAny: 2 | type Callback = (data?: any) => void; 3 | 4 | type Queue = Record; 5 | 6 | class EventBus { 7 | queue: Queue; 8 | 9 | constructor() { 10 | this.queue = {}; 11 | } 12 | 13 | $on(name: string, callback: Callback) { 14 | this.queue[name] = this.queue[name] || []; 15 | this.queue[name].push(callback); 16 | } 17 | 18 | $off(name: string, callback: Callback) { 19 | if (this.queue[name]) { 20 | for (let i = 0; i < this.queue[name].length; i++) { 21 | if (this.queue[name][i] === callback) { 22 | this.queue[name].splice(i, 1); 23 | break; 24 | } 25 | } 26 | } 27 | } 28 | 29 | // biome-ignore lint/suspicious/noExplicitAny: 30 | $emit(name: string, data?: any) { 31 | if (this.queue[name]) { 32 | for (const callback of this.queue[name]) { 33 | callback(data); 34 | } 35 | } 36 | } 37 | } 38 | 39 | export default new EventBus(); 40 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/helpers/mount-component.ts: -------------------------------------------------------------------------------- 1 | import { type Component, type VNode, h, render } from "vue"; 2 | 3 | const createElement = () => 4 | typeof document !== "undefined" && document.createElement("div"); 5 | 6 | const mount = ( 7 | component: Component, 8 | // biome-ignore lint/suspicious/noExplicitAny: 9 | { props, children, element, app }: any = {}, 10 | ) => { 11 | let el = element || createElement(); 12 | 13 | let vNode: VNode | null = h(component, props, children); 14 | 15 | if (app?._context) { 16 | vNode.appContext = app._context; 17 | } 18 | 19 | render(vNode, el); 20 | 21 | const destroy = () => { 22 | if (el) { 23 | render(null, el); 24 | } 25 | el = null; 26 | vNode = null; 27 | }; 28 | 29 | return { vNode, destroy, el }; 30 | }; 31 | 32 | export default mount; 33 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/helpers/remove-element.ts: -------------------------------------------------------------------------------- 1 | const removeElement = (el: HTMLElement) => { 2 | if (typeof el.remove !== "undefined") { 3 | el.remove(); 4 | } else { 5 | el.parentNode?.removeChild(el); 6 | } 7 | }; 8 | 9 | export { removeElement }; 10 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/helpers/timer.ts: -------------------------------------------------------------------------------- 1 | type Callback = () => void; 2 | 3 | // https://stackoverflow.com/a/3969760 4 | export default class Timer { 5 | startedAt: number; 6 | callback: Callback; 7 | delay: number; 8 | timer: number; 9 | 10 | constructor(callback: Callback, delay: number) { 11 | this.startedAt = Date.now(); 12 | this.callback = callback; 13 | this.delay = delay; 14 | 15 | this.timer = window.setTimeout(callback, delay); 16 | } 17 | 18 | pause() { 19 | this.stop(); 20 | this.delay -= Date.now() - this.startedAt; 21 | } 22 | 23 | resume() { 24 | this.stop(); 25 | this.startedAt = Date.now(); 26 | this.timer = window.setTimeout(this.callback, this.delay); 27 | } 28 | 29 | stop() { 30 | window.clearTimeout(this.timer); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/compounds/Toast/toast-api.ts: -------------------------------------------------------------------------------- 1 | import Toaster from "./Toaster.vue"; 2 | import type Position from "./defaults/position"; 3 | import eventBus from "./helpers/event-bus.js"; 4 | import mount from "./helpers/mount-component"; 5 | 6 | export interface ToastOptions { 7 | type?: "is-success" | "is-danger" | "is-info" | "is-warning" | "is-primary"; 8 | position?: Position; 9 | duration?: number | false; 10 | dismissible?: boolean; 11 | pauseOnHover?: boolean; 12 | onClose?: () => void; 13 | onClick?: () => void; 14 | } 15 | 16 | export interface ToastGlobalOptions extends ToastOptions { 17 | maxToasts?: number | false; 18 | queue?: boolean; 19 | } 20 | 21 | export interface ToasterProps extends ToastOptions, ToastGlobalOptions { 22 | message: string; // required 23 | } 24 | 25 | const ToastApi = (globalOptions: ToastGlobalOptions = {}) => { 26 | return { 27 | show(message: string, options: ToastOptions = {}) { 28 | const localOptions = { message, ...options }; 29 | const mergedOptions: ToasterProps = { ...globalOptions, ...localOptions }; 30 | const c = mount(Toaster, { 31 | props: mergedOptions, 32 | }); 33 | 34 | return c; 35 | }, 36 | clear() { 37 | eventBus.$emit("toast-clear"); 38 | }, 39 | success(message: string, options: ToastOptions = {}) { 40 | options.type = "is-success"; 41 | return this.show(message, options); 42 | }, 43 | error(message: string, options: ToastOptions = {}) { 44 | options.type = "is-danger"; 45 | return this.show(message, options); 46 | }, 47 | info(message: string, options: ToastOptions = {}) { 48 | options.type = "is-info"; 49 | return this.show(message, options); 50 | }, 51 | warning(message: string, options: ToastOptions = {}) { 52 | options.type = "is-warning"; 53 | return this.show(message, options); 54 | }, 55 | }; 56 | }; 57 | 58 | export default ToastApi; 59 | -------------------------------------------------------------------------------- /src/components/global-settings.js: -------------------------------------------------------------------------------- 1 | import { inject, provide, reactive } from "vue"; 2 | 3 | const globalSettingsSymbol = Symbol("global-settings"); 4 | 5 | const createSettings = () => { 6 | const button = reactive({ 7 | rounded: false, 8 | }); 9 | 10 | return { 11 | button, 12 | }; 13 | }; 14 | 15 | export const createGlobalSettings = () => { 16 | const settings = createSettings(); 17 | 18 | return { 19 | ...settings, 20 | install(app) { 21 | app.provide(globalSettingsSymbol, settings); 22 | }, 23 | }; 24 | }; 25 | 26 | export const useGlobalSettings = () => inject(globalSettingsSymbol); 27 | 28 | export const provideGlobalSettings = () => 29 | provide(globalSettingsSymbol, createSettings()); 30 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | import "../index.css"; 2 | 3 | /* Primitives */ 4 | 5 | export { default as VAvatar } from "./primitives/Avatar/Avatar.vue"; 6 | export { default as VButton } from "./primitives/Button/Button.vue"; 7 | export { default as VCalendar } from "./primitives/Calendar/Calendar.vue"; 8 | export { default as VChart } from "./primitives/Chart/Chart.vue"; 9 | export { default as VCheckbox } from "./primitives/Checkbox/Checkbox.vue"; 10 | export { default as VContainer } from "./primitives/Container/Container.vue"; 11 | export { default as VDepthChart } from "./primitives/DepthChart/DepthChart.vue"; 12 | export { default as VField } from "./primitives/Field/Field.vue"; 13 | export { default as VFile } from "./primitives/FileInput/FileInput.vue"; 14 | export { default as VIcon } from "./primitives/Icon/Icon.vue"; 15 | export { default as VImage } from "./primitives/Image/Image.vue"; 16 | export { default as VInput } from "./primitives/Input/Input.vue"; 17 | export { default as VMedia } from "./primitives/Media/Media.vue"; 18 | export { default as VModal } from "./primitives/Modal/Modal.vue"; 19 | export { default as VProgress } from "./primitives/Progress/Progress.vue"; 20 | export { default as VSelect } from "./primitives/Select/Select.vue"; 21 | export { default as VSidebar } from "./primitives/Sidebar/Sidebar.vue"; 22 | export { default as VSlider } from "./primitives/Slider/Slider.vue"; 23 | export { default as VSwitch } from "./primitives/Switch/Switch.vue"; 24 | export { default as VTag } from "./primitives/Tag/Tag.vue"; 25 | export { default as VTextarea } from "./primitives/Textarea/Textarea.vue"; 26 | export { default as VTimeline } from "./primitives/Timeline/Timeline.vue"; 27 | export { default as VTooltip } from "./primitives/Tooltip/Tooltip.vue"; 28 | export { default as VUpload } from "./primitives/Upload/Upload.vue"; 29 | export { default as VAutocomplete } from "./primitives/Autocomplete/Autocomplete.vue"; 30 | 31 | /* Compounds */ 32 | 33 | export { default as DataGrid } from "./compounds/Table/DataGrid.ts"; 34 | export { 35 | default as Toaster, 36 | ToasterPositions, 37 | createToaster, 38 | } from "./compounds/Toast/Toaster"; 39 | export { default as VAccordion } from "./compounds/Accordion/Accordion.vue"; 40 | export { default as VAccordionMenu } from "./compounds/Accordion/AccordionMenu.vue"; 41 | export { default as VBreadcrumb } from "./compounds/Breadcrumb/Breadcrumb.vue"; 42 | export { default as VBreadcrumbItem } from "./compounds/Breadcrumb/BreadcrumbItem.vue"; 43 | export { default as VCard } from "./compounds/Card/Card.vue"; 44 | export { default as VCardContent } from "./compounds/Card/CardContent.vue"; 45 | export { default as VCardFooter } from "./compounds/Card/CardFooter.vue"; 46 | export { default as VCardFooterItem } from "./compounds/Card/CardFooterItem.vue"; 47 | export { default as VCardHeader } from "./compounds/Card/CardHeader.vue"; 48 | export { default as VCardImage } from "./compounds/Card/CardImage.vue"; 49 | export { default as VColumn } from "./compounds/Columns/Column.vue"; 50 | export { default as VColumns } from "./compounds/Columns/Columns.vue"; 51 | export { default as VDropdown } from "./compounds/Dropdown/Dropdown.vue"; 52 | export { default as VDropdownItem } from "./compounds/Dropdown/DropdownItem.vue"; 53 | export { default as VMenu } from "./compounds/Menu/Menu.vue"; 54 | export { default as VMenuItem } from "./compounds/Menu/MenuItem.vue"; 55 | export { default as VMenuList } from "./compounds/Menu/MenuList.vue"; 56 | export { default as VNavbar } from "./compounds/Navbar/Navbar.vue"; 57 | export { default as VNavbarBurger } from "./compounds/Navbar/NavbarBurger.vue"; 58 | export { default as VNavbarDropdown } from "./compounds/Navbar/NavbarDropdown.vue"; 59 | export { default as VNavbarItem } from "./compounds/Navbar/NavbarItem.vue"; 60 | export { default as VPagination } from "./compounds/Pagination/Pagination.vue"; 61 | export { default as VPaginationItem } from "./compounds/Pagination/PaginationItem.vue"; 62 | export { default as VStepItem } from "./compounds/Steps/StepItem.vue"; 63 | export { default as VSteps } from "./compounds/Steps/Steps.vue"; 64 | export { default as VTab } from "./compounds/Tabs/Tab.vue"; 65 | export { default as VTable } from "./compounds/Table/Table.vue"; 66 | export { default as VTabs } from "./compounds/Tabs/Tabs.vue"; 67 | 68 | export * from "./global-settings"; 69 | -------------------------------------------------------------------------------- /src/components/primitives/Autocomplete/Autocomplete.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 108 | -------------------------------------------------------------------------------- /src/components/primitives/Avatar/Avatar.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 61 | -------------------------------------------------------------------------------- /src/components/primitives/Avatar/__tests__/Avatar.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import Avatar from "../Avatar.vue"; 4 | 5 | vi.mock("@/utils/functions", () => ({ 6 | checkBenchieSupport: () => false, 7 | })); 8 | 9 | describe("Avatar", () => { 10 | it("renders with default props", () => { 11 | const wrapper = mount(Avatar); 12 | expect(wrapper.classes()).toContain("is-64x64"); 13 | expect(wrapper.classes()).toContain("has-background-link"); 14 | expect(wrapper.classes()).toContain("has-text-white"); 15 | }); 16 | 17 | it("renders image when src is provided", () => { 18 | const src = "https://example.com/avatar.jpg"; 19 | const wrapper = mount(Avatar, { 20 | props: { 21 | src, 22 | alt: "Test Avatar", 23 | }, 24 | }); 25 | 26 | const img = wrapper.find("img"); 27 | expect(img.exists()).toBe(true); 28 | expect(img.attributes("src")).toBe(src); 29 | expect(img.attributes("alt")).toBe("Test Avatar"); 30 | }); 31 | 32 | it("renders caption when no src is provided", () => { 33 | const wrapper = mount(Avatar, { 34 | props: { 35 | alt: "John Doe", 36 | }, 37 | }); 38 | 39 | const span = wrapper.find("span"); 40 | expect(span.exists()).toBe(true); 41 | expect(span.text()).toBe("JD"); 42 | }); 43 | 44 | it("applies correct size class", () => { 45 | const wrapper = mount(Avatar, { 46 | props: { 47 | size: "is-128x128", 48 | }, 49 | }); 50 | 51 | expect(wrapper.classes()).toContain("is-128x128"); 52 | }); 53 | 54 | it("applies rounded class when rounded is true", () => { 55 | const wrapper = mount(Avatar, { 56 | props: { 57 | src: "https://example.com/avatar.jpg", 58 | rounded: true, 59 | }, 60 | }); 61 | 62 | const img = wrapper.find("img"); 63 | expect(img.classes()).toContain("is-rounded"); 64 | }); 65 | 66 | it("applies custom background and text colors", () => { 67 | const wrapper = mount(Avatar, { 68 | props: { 69 | background: "has-background-primary", 70 | text: "has-text-black", 71 | }, 72 | }); 73 | 74 | expect(wrapper.classes()).toContain("has-background-primary"); 75 | expect(wrapper.classes()).toContain("has-text-black"); 76 | }); 77 | 78 | it("applies custom class to image", () => { 79 | const wrapper = mount(Avatar, { 80 | props: { 81 | src: "https://example.com/avatar.jpg", 82 | customClass: "custom-avatar", 83 | }, 84 | }); 85 | 86 | const img = wrapper.find("img"); 87 | expect(img.classes()).toContain("custom-avatar"); 88 | }); 89 | 90 | it("sets data-src attribute when provided", () => { 91 | const dataSrc = "avatar/123"; 92 | const wrapper = mount(Avatar, { 93 | props: { 94 | dataSrc, 95 | }, 96 | }); 97 | 98 | const img = wrapper.find("img"); 99 | expect(img.attributes("data-src")).toBe(dataSrc); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/components/primitives/Button/Button.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Button from "./Button.vue"; 4 | 5 | describe("Button", () => { 6 | it("renders default button correctly", () => { 7 | const wrapper = mount(Button); 8 | expect(wrapper.exists()).toBe(true); 9 | expect(wrapper.classes()).toContain("button"); 10 | expect(wrapper.classes()).toContain("is-primary"); 11 | }); 12 | 13 | it("renders label when prop is provided", () => { 14 | const label = "Click me"; 15 | const wrapper = mount(Button, { 16 | props: { label }, 17 | }); 18 | expect(wrapper.text()).toBe(label); 19 | }); 20 | 21 | it("renders slot content", () => { 22 | const slotContent = "Button Content"; 23 | const wrapper = mount(Button, { 24 | slots: { 25 | default: slotContent, 26 | }, 27 | }); 28 | expect(wrapper.text()).toBe(slotContent); 29 | }); 30 | 31 | it("applies type class correctly", () => { 32 | const wrapper = mount(Button, { 33 | props: { 34 | type: "is-danger", 35 | }, 36 | }); 37 | expect(wrapper.classes()).toContain("is-danger"); 38 | }); 39 | 40 | it("applies size class correctly", () => { 41 | const wrapper = mount(Button, { 42 | props: { 43 | size: "is-large", 44 | }, 45 | }); 46 | expect(wrapper.classes()).toContain("is-large"); 47 | }); 48 | 49 | const booleanProps = [ 50 | { prop: "rounded", class: "is-rounded" }, 51 | { prop: "loading", class: "is-loading" }, 52 | { prop: "outlined", class: "is-outlined" }, 53 | { prop: "expanded", class: "is-fullwidth" }, 54 | { prop: "inverted", class: "is-inverted" }, 55 | { prop: "focused", class: "is-focused" }, 56 | { prop: "active", class: "is-active" }, 57 | { prop: "hovered", class: "is-hovered" }, 58 | { prop: "selected", class: "is-selected" }, 59 | { prop: "light", class: "is-light" }, 60 | ]; 61 | 62 | for (const { prop, class: className } of booleanProps) { 63 | it(`applies ${className} class when ${prop} is true`, () => { 64 | const wrapper = mount(Button, { 65 | props: { 66 | [prop]: true, 67 | }, 68 | }); 69 | expect(wrapper.classes()).toContain(className); 70 | }); 71 | } 72 | 73 | it("renders as button by default", () => { 74 | const wrapper = mount(Button); 75 | expect(wrapper.element.tagName).toBe("BUTTON"); 76 | }); 77 | 78 | it('renders as anchor when tag prop is "a"', () => { 79 | const wrapper = mount(Button, { 80 | props: { 81 | tag: "a", 82 | }, 83 | }); 84 | expect(wrapper.element.tagName).toBe("A"); 85 | }); 86 | 87 | it("forces button tag when disabled", () => { 88 | const wrapper = mount(Button, { 89 | props: { 90 | tag: "a", 91 | }, 92 | attrs: { 93 | disabled: true, 94 | }, 95 | }); 96 | expect(wrapper.element.tagName).toBe("BUTTON"); 97 | }); 98 | 99 | it("sets correct native type attribute", () => { 100 | const wrapper = mount(Button, { 101 | props: { 102 | nativeType: "submit", 103 | }, 104 | }); 105 | expect(wrapper.attributes("type")).toBe("submit"); 106 | }); 107 | 108 | it("emits click event when clicked", async () => { 109 | const wrapper = mount(Button); 110 | await wrapper.trigger("click"); 111 | expect(wrapper.emitted("click")).toBeTruthy(); 112 | }); 113 | 114 | it("does not emit click event when disabled", async () => { 115 | const wrapper = mount(Button, { 116 | attrs: { 117 | disabled: true, 118 | }, 119 | }); 120 | await wrapper.trigger("click"); 121 | expect(wrapper.emitted("click")).toBeFalsy(); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/components/primitives/Button/Button.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 72 | -------------------------------------------------------------------------------- /src/components/primitives/Calendar/Calendar.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 86 | -------------------------------------------------------------------------------- /src/components/primitives/Chart/Chart.vue: -------------------------------------------------------------------------------- 1 | 80 | -------------------------------------------------------------------------------- /src/components/primitives/Checkbox/Checkbox.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 41 | -------------------------------------------------------------------------------- /src/components/primitives/Container/Container.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 42 | -------------------------------------------------------------------------------- /src/components/primitives/Container/__tests__/Container.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import Container from "../Container.vue"; 4 | 5 | vi.mock("@/utils/functions", () => ({ 6 | checkBenchieSupport: () => false, 7 | })); 8 | 9 | describe("Container", () => { 10 | it("renders with default props", () => { 11 | const wrapper = mount(Container); 12 | expect(wrapper.html({ raw: true })).toContain( 13 | '
', 14 | ); 15 | }); 16 | 17 | it("render with type class", () => { 18 | const wrapper = mount(Container, { 19 | props: { 20 | type: "type-class-1 type-class-2", 21 | }, 22 | }); 23 | expect(wrapper.classes()).length(3); 24 | expect(wrapper.classes()).toContain("container"); 25 | expect(wrapper.classes()).toContain("type-class-1"); 26 | expect(wrapper.classes()).toContain("type-class-2"); 27 | 28 | // check order 29 | expect(wrapper.html({ raw: true })).toContain( 30 | '
', 31 | ); 32 | }); 33 | 34 | it("render with class attribute", () => { 35 | const wrapper = mount(Container, { 36 | attrs: { 37 | class: "custom-class", 38 | }, 39 | }); 40 | expect(wrapper.classes()).length(2); 41 | expect(wrapper.classes()).toContain("container"); 42 | expect(wrapper.classes()).toContain("custom-class"); 43 | 44 | // check order 45 | expect(wrapper.html({ raw: true })).toContain( 46 | '
', 47 | ); 48 | }); 49 | 50 | it("render with type class and class attribute", () => { 51 | const wrapper = mount(Container, { 52 | props: { 53 | type: "type-class-1 type-class-2", 54 | }, 55 | attrs: { 56 | class: "custom-class-1 custom-class-2", 57 | }, 58 | }); 59 | expect(wrapper.classes()).length(5); 60 | expect(wrapper.classes()).toContain("container"); 61 | expect(wrapper.classes()).toContain("type-class-1"); 62 | expect(wrapper.classes()).toContain("type-class-2"); 63 | expect(wrapper.classes()).toContain("custom-class-1"); 64 | expect(wrapper.classes()).toContain("custom-class-2"); 65 | 66 | // check order 67 | expect(wrapper.html({ raw: true })).toContain( 68 | '
', 69 | ); 70 | }); 71 | 72 | it("render with class attribute", () => { 73 | const wrapper = mount(Container, { 74 | attrs: { 75 | class: "custom-class", 76 | }, 77 | }); 78 | expect(wrapper.classes()).length(2); 79 | expect(wrapper.classes()).toContain("container"); 80 | expect(wrapper.classes()).toContain("custom-class"); 81 | }); 82 | 83 | it("render with type class and class attribute", () => { 84 | const wrapper = mount(Container, { 85 | props: { 86 | type: "type-class-1 type-class-2", 87 | }, 88 | attrs: { 89 | class: "custom-class-1 custom-class-2", 90 | }, 91 | }); 92 | expect(wrapper.classes()).length(5); 93 | expect(wrapper.classes()).toContain("container"); 94 | expect(wrapper.classes()).toContain("type-class-1"); 95 | expect(wrapper.classes()).toContain("type-class-2"); 96 | expect(wrapper.classes()).toContain("custom-class-1"); 97 | expect(wrapper.classes()).toContain("custom-class-2"); 98 | }); 99 | 100 | it("render with background image", () => { 101 | const wrapper = mount(Container, { 102 | props: { 103 | bg: "https://example.com/image.jpg", 104 | }, 105 | }); 106 | const containerEl = wrapper.element; 107 | expect(containerEl.style.backgroundImage).toBe( 108 | "url(https://example.com/image.jpg)", 109 | ); 110 | expect(containerEl.style.backgroundSize).toBe("cover"); 111 | expect(containerEl.style.backgroundRepeat).toBe("no-repeat"); 112 | }); 113 | }); 114 | -------------------------------------------------------------------------------- /src/components/primitives/Field/Field.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 45 | -------------------------------------------------------------------------------- /src/components/primitives/Field/__tests__/Field.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Field from "../Field.vue"; 4 | 5 | describe("Field", () => { 6 | it("renders with default props", () => { 7 | const wrapper = mount(Field); 8 | expect(wrapper.find(".field").exists()).toBe(true); 9 | }); 10 | 11 | it("renders label when provided", () => { 12 | const wrapper = mount(Field, { 13 | props: { 14 | label: "Test Label", 15 | labelFor: "test-input", 16 | }, 17 | }); 18 | const label = wrapper.find("label.label"); 19 | expect(label.exists()).toBe(true); 20 | expect(label.text()).toBe("Test Label"); 21 | expect(label.attributes("for")).toBe("test-input"); 22 | }); 23 | 24 | it("renders horizontal label correctly", () => { 25 | const wrapper = mount(Field, { 26 | props: { 27 | label: "Horizontal Label", 28 | horizontal: true, 29 | labelFor: "test-input", 30 | }, 31 | }); 32 | const fieldLabel = wrapper.find(".field-label"); 33 | expect(fieldLabel.exists()).toBe(true); 34 | const label = fieldLabel.find("label.label"); 35 | expect(label.text()).toBe("Horizontal Label"); 36 | expect(label.attributes("for")).toBe("test-input"); 37 | expect(wrapper.find(".field-body").exists()).toBe(true); 38 | }); 39 | 40 | it("applies size class to field-label", () => { 41 | const wrapper = mount(Field, { 42 | props: { 43 | label: "Label", 44 | horizontal: true, 45 | size: "is-small", 46 | }, 47 | }); 48 | expect(wrapper.find(".field-label").classes()).toContain("is-small"); 49 | }); 50 | 51 | it("renders help message with type", () => { 52 | const wrapper = mount(Field, { 53 | props: { 54 | message: "Help text", 55 | type: "is-danger", 56 | }, 57 | }); 58 | const help = wrapper.find(".help"); 59 | expect(help.exists()).toBe(true); 60 | expect(help.text()).toBe("Help text"); 61 | expect(help.classes()).toContain("is-danger"); 62 | }); 63 | 64 | it("applies position class", () => { 65 | const wrapper = mount(Field, { 66 | props: { 67 | position: "is-centered", 68 | }, 69 | }); 70 | expect(wrapper.classes()).toContain("is-centered"); 71 | }); 72 | 73 | it("applies conditional classes correctly", () => { 74 | const wrapper = mount(Field, { 75 | props: { 76 | expanded: true, 77 | horizontal: true, 78 | grouped: true, 79 | groupMultiline: true, 80 | addons: true, 81 | }, 82 | }); 83 | const classes = wrapper.find(".field").classes(); 84 | expect(classes).toContain("is-expanded"); 85 | expect(classes).toContain("is-horizontal"); 86 | expect(classes).toContain("is-grouped"); 87 | expect(classes).toContain("is-grouped-multiline"); 88 | expect(classes).toContain("has-addons"); 89 | }); 90 | 91 | it("renders default slot content", () => { 92 | const wrapper = mount(Field, { 93 | slots: { 94 | default: "
Slot Content
", 95 | }, 96 | }); 97 | expect(wrapper.find(".test-content").exists()).toBe(true); 98 | expect(wrapper.find(".test-content").text()).toBe("Slot Content"); 99 | }); 100 | 101 | it("passes type as color prop to slot in non-horizontal mode", () => { 102 | const wrapper = mount(Field, { 103 | props: { 104 | type: "is-primary", 105 | }, 106 | slots: { 107 | default: ``, 110 | }, 111 | }); 112 | expect(wrapper.find(".type-test").text()).toBe("is-primary"); 113 | }); 114 | 115 | it("passes type as rounded prop to slot in horizontal mode", () => { 116 | const wrapper = mount(Field, { 117 | props: { 118 | horizontal: true, 119 | type: "is-primary", 120 | }, 121 | slots: { 122 | default: ``, 125 | }, 126 | }); 127 | expect(wrapper.find(".type-test").text()).toBe("is-primary"); 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/components/primitives/FileInput/FileInput.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | -------------------------------------------------------------------------------- /src/components/primitives/FileInput/__tests__/FileInput.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import FileInput from "../FileInput.vue"; 4 | 5 | describe("FileInput", () => { 6 | it("file input with attributes", () => { 7 | const wrapper = mount(FileInput, { 8 | attrs: { 9 | name: "upload-file", 10 | accept: "image/png, image/jpeg", 11 | multiple: true, 12 | disabled: true, 13 | }, 14 | }); 15 | const file = wrapper.find('input[type="file"]'); 16 | expect(file.exists()).toBeTruthy(); 17 | 18 | const fileEl = file.element as HTMLInputElement; 19 | expect(fileEl.name).toBe("upload-file"); 20 | expect(fileEl.accept).toBe("image/png, image/jpeg"); 21 | expect(fileEl.multiple).toBe(true); 22 | expect(fileEl.disabled).toBe(true); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/components/primitives/Icon/Icon.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 51 | -------------------------------------------------------------------------------- /src/components/primitives/Icon/__tests__/Icon.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import Icon from "../Icon.vue"; 4 | 5 | describe("Icon", () => { 6 | it("console logging warns if required props are missing", () => { 7 | const consoleWarnSpy = vi 8 | .spyOn(console, "warn") 9 | .mockImplementation(() => {}); 10 | 11 | mount(Icon); // without required props 12 | 13 | expect(consoleWarnSpy).toHaveBeenCalled(); 14 | expect(consoleWarnSpy.mock.calls[0][0]).toBe( 15 | '[Vue warn]: Missing required prop: "name"', 16 | ); 17 | expect(consoleWarnSpy.mock.calls[1][0]).toBe( 18 | '[Vue warn]: Missing required prop: "bundle"', 19 | ); 20 | 21 | consoleWarnSpy.mockRestore(); 22 | }); 23 | 24 | it("basic icon render", () => { 25 | const wrapper = mount(Icon, { 26 | props: { 27 | name: "github-icon", 28 | bundle: "icons", 29 | }, 30 | }); 31 | expect(wrapper.html({ raw: true })).toContain( 32 | '', 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/components/primitives/Image/Image.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 44 | -------------------------------------------------------------------------------- /src/components/primitives/Image/__tests__/Image.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | import Image from "../Image.vue"; 4 | 5 | vi.mock("@/utils/functions", () => ({ 6 | checkBenchieSupport: vi.fn(() => false), 7 | })); 8 | 9 | describe("Image", () => { 10 | it("renders default image", () => { 11 | const wrapper = mount(Image); 12 | expect(wrapper.find(".image").exists()).toBe(true); 13 | expect(wrapper.find("img").exists()).toBe(true); 14 | }); 15 | 16 | it("applies size class", () => { 17 | const wrapper = mount(Image, { 18 | props: { 19 | size: "is-128x128", 20 | }, 21 | }); 22 | expect(wrapper.find(".image").classes()).toContain("is-128x128"); 23 | }); 24 | 25 | it("applies radio class", () => { 26 | const wrapper = mount(Image, { 27 | props: { 28 | radio: "is-square", 29 | }, 30 | }); 31 | expect(wrapper.find(".image").classes()).toContain("is-square"); 32 | }); 33 | 34 | it("applies rounded class to img", () => { 35 | const wrapper = mount(Image, { 36 | props: { 37 | rounded: true, 38 | }, 39 | }); 40 | expect(wrapper.find("img").classes()).toContain("is-rounded"); 41 | }); 42 | 43 | it("centers the image when centered prop is true", () => { 44 | const wrapper = mount(Image, { 45 | props: { 46 | centered: true, 47 | }, 48 | }); 49 | expect(wrapper.find(".image").classes()).toContain("container"); 50 | }); 51 | 52 | it("sets src attribute from props", () => { 53 | const src = "test-image.jpg"; 54 | const wrapper = mount(Image, { 55 | props: { src }, 56 | }); 57 | expect(wrapper.find("img").attributes("src")).toBe(src); 58 | }); 59 | 60 | it("sets data-src attribute from props", () => { 61 | const dataSrc = "test-image.jpg"; 62 | const wrapper = mount(Image, { 63 | props: { dataSrc }, 64 | }); 65 | expect(wrapper.find("img").attributes("data-src")).toBe(dataSrc); 66 | }); 67 | 68 | it("applies custom class to img", () => { 69 | const customClass = "custom-image"; 70 | const wrapper = mount(Image, { 71 | props: { customClass }, 72 | }); 73 | expect(wrapper.find("img").classes()).toContain(customClass); 74 | }); 75 | 76 | it("passes through attributes to img element", () => { 77 | const wrapper = mount(Image, { 78 | attrs: { 79 | alt: "Test image", 80 | loading: "lazy", 81 | }, 82 | }); 83 | const img = wrapper.find("img"); 84 | expect(img.attributes("alt")).toBe("Test image"); 85 | expect(img.attributes("loading")).toBe("lazy"); 86 | }); 87 | 88 | it("handles multiple props simultaneously", () => { 89 | const wrapper = mount(Image, { 90 | props: { 91 | size: "is-64x64", 92 | rounded: true, 93 | centered: true, 94 | customClass: "custom-image", 95 | src: "test.jpg", 96 | }, 97 | }); 98 | 99 | const figure = wrapper.find(".image"); 100 | const img = wrapper.find("img"); 101 | 102 | expect(figure.classes()).toContain("is-64x64"); 103 | expect(figure.classes()).toContain("container"); 104 | expect(img.classes()).toContain("is-rounded"); 105 | expect(img.classes()).toContain("custom-image"); 106 | expect(img.attributes("src")).toBe("test.jpg"); 107 | }); 108 | 109 | it("uses dataSrc as source when src is not provided", () => { 110 | const dataSrc = "test-image.jpg"; 111 | const wrapper = mount(Image, { 112 | props: { dataSrc }, 113 | }); 114 | expect(wrapper.find("img").attributes("src")).toBe(dataSrc); 115 | }); 116 | 117 | it("prioritizes src over dataSrc for source", () => { 118 | const src = "primary.jpg"; 119 | const dataSrc = "fallback.jpg"; 120 | const wrapper = mount(Image, { 121 | props: { src, dataSrc }, 122 | }); 123 | expect(wrapper.find("img").attributes("src")).toBe(src); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/components/primitives/Input/EyeIcon.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 33 | -------------------------------------------------------------------------------- /src/components/primitives/Input/Input.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 76 | -------------------------------------------------------------------------------- /src/components/primitives/Input/__tests__/Input.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { h, nextTick } from "vue"; 4 | import Input from "../Input.vue"; 5 | 6 | describe("Input", () => { 7 | it("renders default input", () => { 8 | const wrapper = mount(Input); 9 | expect(wrapper.find("input").exists()).toBe(true); 10 | expect(wrapper.find(".control").exists()).toBe(true); 11 | }); 12 | 13 | it("updates v-model value", async () => { 14 | const wrapper = mount(Input, { 15 | props: { 16 | modelValue: "initial", 17 | }, 18 | }); 19 | 20 | const input = wrapper.find("input"); 21 | await input.setValue("new value"); 22 | await nextTick(); 23 | 24 | const emitted = wrapper.emitted("update:modelValue"); 25 | expect(emitted).toBeTruthy(); 26 | if (emitted) { 27 | expect(emitted[emitted.length - 1]).toEqual(["new value"]); 28 | } 29 | }); 30 | 31 | it("applies color class", () => { 32 | const wrapper = mount(Input, { 33 | props: { 34 | color: "is-primary", 35 | }, 36 | }); 37 | expect(wrapper.find("input").classes()).toContain("is-primary"); 38 | }); 39 | 40 | it("applies size class", () => { 41 | const wrapper = mount(Input, { 42 | props: { 43 | size: "is-large", 44 | }, 45 | }); 46 | expect(wrapper.find(".control").classes()).toContain("is-large"); 47 | expect(wrapper.find("input").classes()).toContain("is-large"); 48 | }); 49 | 50 | it("applies rounded class", () => { 51 | const wrapper = mount(Input, { 52 | props: { 53 | rounded: true, 54 | }, 55 | }); 56 | expect(wrapper.find("input").classes()).toContain("is-rounded"); 57 | }); 58 | 59 | it("shows loading state", () => { 60 | const wrapper = mount(Input, { 61 | props: { 62 | loading: true, 63 | }, 64 | }); 65 | expect(wrapper.find(".control").classes()).toContain("is-loading"); 66 | }); 67 | 68 | it("applies expanded class", () => { 69 | const wrapper = mount(Input, { 70 | props: { 71 | expanded: true, 72 | }, 73 | }); 74 | expect(wrapper.find("input").classes()).toContain("is-expanded"); 75 | }); 76 | 77 | it("renders left icon", () => { 78 | const wrapper = mount(Input, { 79 | slots: { 80 | leftIcon: h("i", { class: "icon-left" }), 81 | }, 82 | }); 83 | expect(wrapper.find(".control").classes()).toContain("has-icons-left"); 84 | expect(wrapper.find(".icon.is-left").exists()).toBe(true); 85 | expect(wrapper.find(".icon-left").exists()).toBe(true); 86 | }); 87 | 88 | it("renders right icon", () => { 89 | const wrapper = mount(Input, { 90 | slots: { 91 | rightIcon: h("i", { class: "icon-right" }), 92 | }, 93 | }); 94 | expect(wrapper.find(".control").classes()).toContain("has-icons-right"); 95 | expect(wrapper.find(".icon.is-right").exists()).toBe(true); 96 | expect(wrapper.find(".icon-right").exists()).toBe(true); 97 | }); 98 | 99 | describe("password reveal", () => { 100 | it("shows password reveal icon when enabled", () => { 101 | const wrapper = mount(Input, { 102 | props: { 103 | passwordReveal: true, 104 | type: "password", 105 | }, 106 | }); 107 | expect(wrapper.find(".control").classes()).toContain("has-icons-right"); 108 | expect(wrapper.find(".icon.is-right").exists()).toBe(true); 109 | expect(wrapper.findComponent({ name: "EyeIcon" }).exists()).toBe(true); 110 | }); 111 | 112 | it("toggles password visibility on click", async () => { 113 | const wrapper = mount(Input, { 114 | props: { 115 | passwordReveal: true, 116 | type: "password", 117 | }, 118 | }); 119 | 120 | const input = wrapper.find("input"); 121 | expect(input.attributes("type")).toBe("password"); 122 | 123 | await wrapper.find(".is-clickable").trigger("click"); 124 | await nextTick(); 125 | expect(input.attributes("type")).toBe("text"); 126 | 127 | await wrapper.find(".is-clickable").trigger("click"); 128 | await nextTick(); 129 | expect(input.attributes("type")).toBe("password"); 130 | }); 131 | 132 | it("uses custom right icon with password reveal", () => { 133 | const wrapper = mount(Input, { 134 | props: { 135 | passwordReveal: true, 136 | type: "password", 137 | }, 138 | slots: { 139 | rightIcon: h("i", { class: "custom-eye" }), 140 | }, 141 | }); 142 | expect(wrapper.find(".custom-eye").exists()).toBe(true); 143 | }); 144 | }); 145 | 146 | it("passes HTML attributes to input element", () => { 147 | const wrapper = mount(Input, { 148 | attrs: { 149 | placeholder: "Enter text", 150 | maxlength: "10", 151 | required: true, 152 | }, 153 | }); 154 | const input = wrapper.find("input"); 155 | expect(input.attributes("placeholder")).toBe("Enter text"); 156 | expect(input.attributes("maxlength")).toBe("10"); 157 | expect(input.attributes("required")).toBe(""); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/components/primitives/Media/Media.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 18 | -------------------------------------------------------------------------------- /src/components/primitives/Media/__tests__/Media.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { h } from "vue"; 4 | import Media from "../Media.vue"; 5 | 6 | describe("Media", () => { 7 | it("renders default media", () => { 8 | const wrapper = mount(Media); 9 | expect(wrapper.find(".media").exists()).toBe(true); 10 | expect(wrapper.find(".media-left").exists()).toBe(true); 11 | expect(wrapper.find(".media-content").exists()).toBe(true); 12 | expect(wrapper.find(".media-right").exists()).toBe(true); 13 | }); 14 | 15 | it("renders left slot content", () => { 16 | const wrapper = mount(Media, { 17 | slots: { 18 | left: "
Test
", 19 | }, 20 | }); 21 | const leftSection = wrapper.find(".media-left"); 22 | expect(leftSection.find(".image").exists()).toBe(true); 23 | expect(leftSection.find("img").attributes("src")).toBe("test.jpg"); 24 | }); 25 | 26 | it("renders content slot", () => { 27 | const wrapper = mount(Media, { 28 | slots: { 29 | content: ` 30 |
31 |

John Smith

32 |

Lorem ipsum dolor sit amet

33 |
34 | `, 35 | }, 36 | }); 37 | const content = wrapper.find(".media-content"); 38 | expect(content.find(".content").exists()).toBe(true); 39 | expect(content.find("strong").text()).toBe("John Smith"); 40 | }); 41 | 42 | it("renders right slot content", () => { 43 | const wrapper = mount(Media, { 44 | slots: { 45 | right: "", 46 | }, 47 | }); 48 | const rightSection = wrapper.find(".media-right"); 49 | expect(rightSection.find(".delete").exists()).toBe(true); 50 | }); 51 | 52 | it("renders all slots simultaneously", () => { 53 | const wrapper = mount(Media, { 54 | slots: { 55 | left: "
Avatar
", 56 | content: "

Main content

", 57 | right: "", 58 | }, 59 | }); 60 | 61 | expect(wrapper.find(".media-left img").exists()).toBe(true); 62 | expect(wrapper.find(".media-content .content").exists()).toBe(true); 63 | expect(wrapper.find(".media-right button").exists()).toBe(true); 64 | }); 65 | 66 | it("renders nested media components", () => { 67 | const wrapper = mount(Media, { 68 | slots: { 69 | content: h("div", [ 70 | h("p", "Parent content"), 71 | h("div", { class: "nested-media" }, [ 72 | h(Media, { 73 | slots: { 74 | left: h("figure", { class: "image" }, [ 75 | h("img", { src: "nested.jpg", alt: "Nested" }), 76 | ]), 77 | content: h("div", { class: "content" }, [ 78 | h("p", "Nested content"), 79 | ]), 80 | }, 81 | }), 82 | ]), 83 | ]), 84 | }, 85 | }); 86 | 87 | expect(wrapper.findAll(".media")).toHaveLength(2); 88 | expect(wrapper.find(".nested-media").exists()).toBe(true); 89 | }); 90 | 91 | it("preserves content structure", () => { 92 | const wrapper = mount(Media, { 93 | slots: { 94 | content: ` 95 |
96 |

Article Title

97 |

Article Subtitle

98 |
Article content
99 |
100 | `, 101 | }, 102 | }); 103 | 104 | const content = wrapper.find(".media-content .content"); 105 | expect(content.find(".title").text()).toBe("Article Title"); 106 | expect(content.find(".subtitle").text()).toBe("Article Subtitle"); 107 | expect(content.find(".text").text()).toBe("Article content"); 108 | }); 109 | 110 | it("handles empty slots gracefully", () => { 111 | const wrapper = mount(Media); 112 | expect(wrapper.find(".media-left").text()).toBe(""); 113 | expect(wrapper.find(".media-content").text()).toBe(""); 114 | expect(wrapper.find(".media-right").text()).toBe(""); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/components/primitives/Modal/Modal.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 40 | -------------------------------------------------------------------------------- /src/components/primitives/Modal/__tests__/Modal.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Modal from "../Modal.vue"; 4 | 5 | describe("Modal", () => { 6 | it("updates v-model value on close button click", async () => { 7 | const wrapper = mount(Modal); 8 | 9 | // by default it active 10 | expect(wrapper.find(".modal").classes()).toContain("is-active"); 11 | 12 | // click close button 13 | await wrapper.find(".modal-close").trigger("click"); 14 | const emitted = wrapper.emitted("update:modelValue"); 15 | expect(emitted).toHaveLength(1); 16 | expect(emitted?.[0]).toEqual([false]); 17 | 18 | // 'is-active' class removed 19 | expect(wrapper.find(".modal").classes()).not.toContain("is-active"); 20 | }); 21 | 22 | it("reacts to v-model prop changes", async () => { 23 | const wrapper = mount(Modal, { 24 | props: { 25 | modelValue: false, 26 | }, 27 | }); 28 | 29 | // invisible 30 | expect(wrapper.find(".modal").classes()).not.toContain("is-active"); 31 | 32 | // @ts-ignore 33 | await wrapper.setProps({ modelValue: true }); 34 | 35 | // visible 36 | expect(wrapper.find(".modal").classes()).toContain("is-active"); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/components/primitives/Progress/Progress.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 79 | -------------------------------------------------------------------------------- /src/components/primitives/Progress/__tests__/Progress.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Progress from "../Progress.vue"; 4 | 5 | describe("Progress", () => { 6 | it("renders with default props", () => { 7 | const wrapper = mount(Progress); 8 | expect(wrapper.find(".progress-wrapper").exists()).toBe(true); 9 | expect(wrapper.find("progress").exists()).toBe(true); 10 | expect(wrapper.find("progress").classes()).toContain("is-darkgrey"); 11 | expect(wrapper.find("progress").attributes("max")).toBe("100"); 12 | }); 13 | 14 | it("applies type class", () => { 15 | const wrapper = mount(Progress, { 16 | props: { 17 | type: "is-primary", 18 | }, 19 | }); 20 | expect(wrapper.find("progress").classes()).toContain("is-primary"); 21 | }); 22 | 23 | it("applies size class", () => { 24 | const wrapper = mount(Progress, { 25 | props: { 26 | size: "is-small", 27 | }, 28 | }); 29 | expect(wrapper.find("progress").classes()).toContain("is-small"); 30 | }); 31 | 32 | it("sets value and max attributes", () => { 33 | const wrapper = mount(Progress, { 34 | props: { 35 | value: 45, 36 | max: 200, 37 | }, 38 | }); 39 | const progress = wrapper.find("progress"); 40 | expect(progress.attributes("value")).toBe("45"); 41 | expect(progress.attributes("max")).toBe("200"); 42 | }); 43 | 44 | it("shows value when showValue is true", () => { 45 | const wrapper = mount(Progress, { 46 | props: { 47 | value: 45, 48 | showValue: true, 49 | }, 50 | }); 51 | expect(wrapper.find(".progress-value").exists()).toBe(true); 52 | expect(wrapper.find(".progress-value").text()).toBe("45"); 53 | }); 54 | 55 | it("formats value as percentage", () => { 56 | const wrapper = mount(Progress, { 57 | props: { 58 | value: 45, 59 | max: 200, 60 | showValue: true, 61 | format: "percent", 62 | }, 63 | }); 64 | expect(wrapper.find(".progress-value").text()).toBe("22.5%"); 65 | }); 66 | 67 | it("respects precision setting with keepTrailingZeroes", () => { 68 | const wrapper = mount(Progress, { 69 | props: { 70 | value: 45, 71 | max: 300, 72 | showValue: true, 73 | format: "percent", 74 | precision: 3, 75 | keepTrailingZeroes: true, 76 | }, 77 | }); 78 | expect(wrapper.find(".progress-value").text()).toBe("15.000%"); 79 | }); 80 | 81 | it("keeps trailing zeroes when specified", () => { 82 | const wrapper = mount(Progress, { 83 | props: { 84 | value: 50, 85 | showValue: true, 86 | precision: 2, 87 | keepTrailingZeroes: true, 88 | }, 89 | }); 90 | expect(wrapper.find(".progress-value").text()).toBe("50.00"); 91 | }); 92 | 93 | it("removes trailing zeroes by default", () => { 94 | const wrapper = mount(Progress, { 95 | props: { 96 | value: 50, 97 | showValue: true, 98 | precision: 2, 99 | }, 100 | }); 101 | expect(wrapper.find(".progress-value").text()).toBe("50"); 102 | }); 103 | 104 | it("handles indeterminate state", async () => { 105 | const wrapper = mount(Progress, { 106 | props: { 107 | value: undefined, 108 | }, 109 | }); 110 | expect(wrapper.find("progress").attributes("value")).toBeUndefined(); 111 | 112 | // @ts-ignore 113 | await wrapper.setProps({ value: 50 }); 114 | expect(wrapper.find("progress").attributes("value")).toBe("50"); 115 | }); 116 | 117 | it("adds more-than-half class when value is >= 50%", () => { 118 | const wrapper = mount(Progress, { 119 | props: { 120 | value: 75, 121 | max: 100, 122 | showValue: true, 123 | }, 124 | }); 125 | expect(wrapper.find(".progress-value").classes()).toContain( 126 | "more-than-half", 127 | ); 128 | }); 129 | 130 | it("does not add more-than-half class when value is < 50%", () => { 131 | const wrapper = mount(Progress, { 132 | props: { 133 | value: 25, 134 | max: 100, 135 | showValue: true, 136 | }, 137 | }); 138 | expect(wrapper.find(".progress-value").classes()).not.toContain( 139 | "more-than-half", 140 | ); 141 | }); 142 | 143 | it("renders slot content instead of value", () => { 144 | const wrapper = mount(Progress, { 145 | props: { 146 | value: 45, 147 | showValue: true, 148 | }, 149 | slots: { 150 | default: "Custom content", 151 | }, 152 | }); 153 | expect(wrapper.find(".progress-value").text()).toBe("Custom content"); 154 | }); 155 | 156 | it("handles decimal values correctly", () => { 157 | const wrapper = mount(Progress, { 158 | props: { 159 | value: 33.333333, 160 | showValue: true, 161 | precision: 2, 162 | }, 163 | }); 164 | expect(wrapper.find(".progress-value").text()).toBe("33.33"); 165 | }); 166 | }); 167 | -------------------------------------------------------------------------------- /src/components/primitives/Select/Select.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 68 | -------------------------------------------------------------------------------- /src/components/primitives/Select/__tests__/Select.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { nextTick } from "vue"; 4 | import Select from "../Select.vue"; 5 | 6 | describe("Select", () => { 7 | it("renders default select", () => { 8 | const wrapper = mount(Select); 9 | expect(wrapper.find(".control").exists()).toBe(true); 10 | expect(wrapper.find(".select").exists()).toBe(true); 11 | expect(wrapper.find("select").exists()).toBe(true); 12 | }); 13 | 14 | it("renders placeholder option when provided", () => { 15 | const wrapper = mount(Select, { 16 | props: { 17 | placeholder: "Select an option", 18 | modelValue: null, 19 | } as const, 20 | }); 21 | const placeholderOption = wrapper.find("option"); 22 | expect(placeholderOption.text()).toBe("Select an option"); 23 | expect(placeholderOption.attributes("disabled")).toBeDefined(); 24 | expect(placeholderOption.attributes("hidden")).toBeDefined(); 25 | }); 26 | 27 | it("applies size classes", () => { 28 | const wrapper = mount(Select, { 29 | props: { 30 | size: "is-small", 31 | } as const, 32 | }); 33 | expect(wrapper.find(".select").classes()).toContain("is-small"); 34 | }); 35 | 36 | it("applies state classes", () => { 37 | const wrapper = mount(Select, { 38 | props: { 39 | expanded: true, 40 | loading: true, 41 | multiple: true, 42 | modelValue: [], 43 | rounded: true, 44 | color: "is-primary", 45 | } as const, 46 | }); 47 | const control = wrapper.find(".control"); 48 | const select = wrapper.find(".select"); 49 | 50 | expect(control.classes()).toContain("is-expanded"); 51 | expect(select.classes()).toContain("is-fullwidth"); 52 | expect(select.classes()).toContain("is-loading"); 53 | expect(select.classes()).toContain("is-multiple"); 54 | expect(select.classes()).toContain("is-rounded"); 55 | expect(select.classes()).toContain("is-primary"); 56 | }); 57 | 58 | it("sets native size attribute", () => { 59 | const wrapper = mount(Select, { 60 | props: { 61 | nativeSize: 5, 62 | } as const, 63 | }); 64 | expect(wrapper.find("select").attributes("size")).toBe("5"); 65 | }); 66 | 67 | it("handles v-model correctly", async () => { 68 | const wrapper = mount(Select, { 69 | props: { 70 | modelValue: "initial", 71 | } as const, 72 | slots: { 73 | default: ` 74 | 75 | 76 | `, 77 | }, 78 | }); 79 | 80 | expect(wrapper.find("select").element.value).toBe("initial"); 81 | 82 | const select = wrapper.find("select"); 83 | await select.setValue("changed"); 84 | await nextTick(); 85 | await nextTick(); 86 | 87 | const emittedEvents = wrapper.emitted("update:modelValue"); 88 | expect(emittedEvents).toBeTruthy(); 89 | expect(emittedEvents?.[emittedEvents.length - 1]).toEqual(["changed"]); 90 | }); 91 | 92 | it("emits blur event", async () => { 93 | const wrapper = mount(Select); 94 | await wrapper.find("select").trigger("blur"); 95 | expect(wrapper.emitted("blur")).toBeTruthy(); 96 | }); 97 | 98 | it("emits focus event", async () => { 99 | const wrapper = mount(Select); 100 | await wrapper.find("select").trigger("focus"); 101 | expect(wrapper.emitted("focus")).toBeTruthy(); 102 | }); 103 | 104 | it("handles multiple selection", async () => { 105 | const wrapper = mount(Select, { 106 | props: { 107 | modelValue: [], 108 | multiple: true, 109 | } as const, 110 | slots: { 111 | default: ` 112 | 113 | 114 | 115 | `, 116 | }, 117 | }); 118 | 119 | const select = wrapper.find("select"); 120 | expect(select.attributes("multiple")).toBeDefined(); 121 | 122 | await select.setValue(["1", "2"]); 123 | await nextTick(); 124 | await nextTick(); 125 | 126 | const emittedEvents = wrapper.emitted("update:modelValue"); 127 | expect(emittedEvents).toBeTruthy(); 128 | expect(emittedEvents?.[emittedEvents.length - 1]).toEqual([["1", "2"]]); 129 | }); 130 | 131 | it("updates when modelValue prop changes", async () => { 132 | const wrapper = mount(Select, { 133 | props: { 134 | modelValue: "initial", 135 | } as const, 136 | slots: { 137 | default: ` 138 | 139 | 140 | `, 141 | }, 142 | }); 143 | // @ts-expect-error 144 | await wrapper.setProps({ modelValue: "updated" }); 145 | await nextTick(); 146 | await nextTick(); 147 | 148 | expect(wrapper.find("select").element.value).toBe("updated"); 149 | }); 150 | 151 | it("inherits attributes", () => { 152 | const wrapper = mount(Select, { 153 | attrs: { 154 | name: "test-select", 155 | required: true, 156 | }, 157 | }); 158 | const select = wrapper.find("select"); 159 | expect(select.attributes("name")).toBe("test-select"); 160 | expect(select.attributes("required")).toBeDefined(); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/components/primitives/Sidebar/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 108 | -------------------------------------------------------------------------------- /src/components/primitives/Slider/Slider.vue: -------------------------------------------------------------------------------- 1 | 44 | 45 | 70 | -------------------------------------------------------------------------------- /src/components/primitives/Slider/__tests__/Slider.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Slider from "../Slider.vue"; 4 | 5 | describe("Slider", () => { 6 | it("renders with default props", () => { 7 | const wrapper = mount(Slider); 8 | const input = wrapper.find("input"); 9 | expect(input.exists()).toBe(true); 10 | expect(input.classes()).toContain("slider"); 11 | expect(input.classes()).toContain("is-fullwidth"); 12 | expect(input.attributes("type")).toBe("range"); 13 | expect(input.attributes("min")).toBe("0"); 14 | expect(input.attributes("max")).toBe("100"); 15 | expect(input.attributes("step")).toBe("1"); 16 | }); 17 | 18 | it("applies type class", () => { 19 | const wrapper = mount(Slider, { 20 | props: { 21 | type: "is-primary", 22 | }, 23 | }); 24 | expect(wrapper.find("input").classes()).toContain("is-primary"); 25 | }); 26 | 27 | it("applies size class", () => { 28 | const wrapper = mount(Slider, { 29 | props: { 30 | size: "is-small", 31 | }, 32 | }); 33 | expect(wrapper.find("input").classes()).toContain("is-small"); 34 | }); 35 | 36 | it("sets min, max and step attributes", () => { 37 | const wrapper = mount(Slider, { 38 | props: { 39 | min: 10, 40 | max: 200, 41 | step: 5, 42 | }, 43 | }); 44 | const input = wrapper.find("input"); 45 | expect(input.attributes("min")).toBe("10"); 46 | expect(input.attributes("max")).toBe("200"); 47 | expect(input.attributes("step")).toBe("5"); 48 | }); 49 | 50 | it("adds rounded class when rounded prop is true", () => { 51 | const wrapper = mount(Slider, { 52 | props: { 53 | rounded: true, 54 | }, 55 | }); 56 | expect(wrapper.find("input").classes()).toContain("is-circle"); 57 | }); 58 | 59 | it("shows tooltip when tooltip prop is true", () => { 60 | const wrapper = mount(Slider, { 61 | props: { 62 | tooltip: true, 63 | modelValue: 50, 64 | }, 65 | }); 66 | expect(wrapper.find("input").classes()).toContain("has-output-tooltip"); 67 | expect(wrapper.find("output").exists()).toBe(true); 68 | expect(wrapper.find("output").text()).toBe("50"); 69 | }); 70 | 71 | it("disables input when disabled prop is true", () => { 72 | const wrapper = mount(Slider, { 73 | props: { 74 | disabled: true, 75 | }, 76 | }); 77 | expect(wrapper.find("input").attributes("disabled")).toBe(""); 78 | }); 79 | 80 | it("sets vertical orientation", () => { 81 | const wrapper = mount(Slider, { 82 | props: { 83 | vertical: true, 84 | }, 85 | }); 86 | expect(wrapper.find("input").attributes("orient")).toBe("vertical"); 87 | }); 88 | 89 | it("emits update:modelValue when value changes", async () => { 90 | const wrapper = mount(Slider, { 91 | props: { 92 | modelValue: 30, 93 | }, 94 | }); 95 | await wrapper.find("input").setValue(45); 96 | const emitted = wrapper.emitted("update:modelValue"); 97 | expect(emitted).toBeTruthy(); 98 | expect(emitted?.[0]).toEqual(["45"]); 99 | }); 100 | 101 | it("updates value when modelValue prop changes", async () => { 102 | const wrapper = mount(Slider, { 103 | props: { 104 | modelValue: 30, 105 | }, 106 | }); 107 | await wrapper.find("input").setValue(60); 108 | expect(wrapper.find("input").element.value).toBe("60"); 109 | }); 110 | 111 | it("updates tooltip position when value changes", async () => { 112 | const wrapper = mount(Slider, { 113 | props: { 114 | modelValue: 50, 115 | tooltip: true, 116 | }, 117 | }); 118 | await wrapper.find("input").setValue(75); 119 | expect(wrapper.find("output").attributes("style")).toContain("left:"); 120 | }); 121 | }); 122 | -------------------------------------------------------------------------------- /src/components/primitives/Switch/Switch.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 73 | -------------------------------------------------------------------------------- /src/components/primitives/Switch/__tests__/Switch.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import { nextTick } from "vue"; 4 | import Switch from "../Switch.vue"; 5 | 6 | describe("Switch", () => { 7 | it("renders default switch", () => { 8 | const wrapper = mount(Switch); 9 | expect(wrapper.find(".switch").exists()).toBe(true); 10 | expect(wrapper.find("input[type='checkbox']").exists()).toBe(true); 11 | expect(wrapper.find(".check").exists()).toBe(true); 12 | expect(wrapper.find(".control-label").exists()).toBe(true); 13 | }); 14 | 15 | it("handles v-model correctly", async () => { 16 | const wrapper = mount(Switch, { 17 | props: { 18 | modelValue: false, 19 | } as const, 20 | }); 21 | 22 | const input = wrapper.find("input"); 23 | expect(input.element.checked).toBe(false); 24 | 25 | await input.setValue(true); 26 | await nextTick(); 27 | await nextTick(); 28 | 29 | const emittedEvents = wrapper.emitted("update:modelValue"); 30 | expect(emittedEvents).toBeTruthy(); 31 | expect(emittedEvents?.[emittedEvents.length - 1]).toEqual([true]); 32 | }); 33 | 34 | it("applies size classes", () => { 35 | const wrapper = mount(Switch, { 36 | props: { 37 | size: "is-small", 38 | } as const, 39 | }); 40 | expect(wrapper.find(".switch").classes()).toContain("is-small"); 41 | }); 42 | 43 | it("applies type and passive classes", () => { 44 | const wrapper = mount(Switch, { 45 | props: { 46 | type: "is-primary", 47 | passiveType: "is-success", 48 | } as const, 49 | }); 50 | const check = wrapper.find(".check"); 51 | expect(check.classes()).toContain("is-primary"); 52 | expect(check.classes()).toContain("is-success-passive"); 53 | }); 54 | 55 | it("applies state classes", () => { 56 | const wrapper = mount(Switch, { 57 | props: { 58 | disabled: true, 59 | rounded: false, 60 | outlined: true, 61 | } as const, 62 | }); 63 | const switchEl = wrapper.find(".switch"); 64 | expect(switchEl.classes()).toContain("is-disabled"); 65 | expect(switchEl.classes()).not.toContain("is-rounded"); 66 | expect(switchEl.classes()).toContain("is-outlined"); 67 | }); 68 | 69 | it("handles custom true/false values", async () => { 70 | const wrapper = mount(Switch, { 71 | props: { 72 | modelValue: "no", 73 | trueValue: "yes", 74 | falseValue: "no", 75 | } as const, 76 | }); 77 | 78 | const input = wrapper.find("input"); 79 | expect(input.element.checked).toBe(false); 80 | 81 | await input.setValue("yes"); 82 | await nextTick(); 83 | await nextTick(); 84 | 85 | const emittedEvents = wrapper.emitted("update:modelValue"); 86 | expect(emittedEvents).toBeTruthy(); 87 | expect(emittedEvents?.[emittedEvents.length - 1]).toEqual(["yes"]); 88 | }); 89 | 90 | it("handles native value", () => { 91 | const wrapper = mount(Switch, { 92 | props: { 93 | nativeValue: "test-value", 94 | } as const, 95 | }); 96 | expect(wrapper.find("input").attributes("value")).toBe("test-value"); 97 | }); 98 | 99 | it("sets HTML attributes", () => { 100 | const wrapper = mount(Switch, { 101 | props: { 102 | name: "test-switch", 103 | required: true, 104 | disabled: true, 105 | } as const, 106 | }); 107 | const input = wrapper.find("input"); 108 | expect(input.attributes("name")).toBe("test-switch"); 109 | expect(input.attributes("required")).toBeDefined(); 110 | expect(input.attributes("disabled")).toBeDefined(); 111 | }); 112 | 113 | it("renders slot content", () => { 114 | const wrapper = mount(Switch, { 115 | slots: { 116 | default: "Switch Label", 117 | }, 118 | }); 119 | expect(wrapper.find(".control-label").text()).toBe("Switch Label"); 120 | }); 121 | 122 | it("updates when modelValue prop changes", async () => { 123 | const wrapper = mount(Switch, { 124 | props: { 125 | modelValue: false, 126 | } as const, 127 | }); 128 | 129 | expect(wrapper.find("input").element.checked).toBe(false); 130 | 131 | // @ts-expect-error - Known issue with setProps types 132 | await wrapper.setProps({ modelValue: true }); 133 | await nextTick(); 134 | await nextTick(); 135 | 136 | expect(wrapper.find("input").element.checked).toBe(true); 137 | }); 138 | 139 | it("stops click event propagation", async () => { 140 | const parentClick = vi.fn(); 141 | const wrapper = mount(Switch, { 142 | attrs: { 143 | onClick: parentClick, 144 | }, 145 | }); 146 | 147 | await wrapper.find("input").trigger("click"); 148 | expect(parentClick).not.toHaveBeenCalled(); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/components/primitives/Tag/Tag.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 88 | -------------------------------------------------------------------------------- /src/components/primitives/Tag/__tests__/Tag.spec.ts: -------------------------------------------------------------------------------- 1 | import { mount } from "@vue/test-utils"; 2 | import { describe, expect, it } from "vitest"; 3 | import Tag from "../Tag.vue"; 4 | 5 | describe("Tag", () => { 6 | it("renders with default props", () => { 7 | const wrapper = mount(Tag, { 8 | slots: { 9 | default: "Tag Text", 10 | }, 11 | }); 12 | expect(wrapper.find(".tag").exists()).toBe(true); 13 | expect(wrapper.text()).toBe("Tag Text"); 14 | }); 15 | 16 | it("applies style props correctly", () => { 17 | const wrapper = mount(Tag, { 18 | props: { 19 | type: "is-primary", 20 | size: "is-medium", 21 | rounded: true, 22 | }, 23 | }); 24 | const tag = wrapper.find(".tag"); 25 | expect(tag.classes()).toContain("is-primary"); 26 | expect(tag.classes()).toContain("is-medium"); 27 | expect(tag.classes()).toContain("is-rounded"); 28 | }); 29 | 30 | it("handles closable prop", () => { 31 | const wrapper = mount(Tag, { 32 | props: { 33 | closable: true, 34 | }, 35 | }); 36 | expect(wrapper.find(".delete").exists()).toBe(true); 37 | }); 38 | 39 | it("emits close event when delete button is clicked", async () => { 40 | const wrapper = mount(Tag, { 41 | props: { 42 | closable: true, 43 | }, 44 | }); 45 | await wrapper.find(".delete").trigger("click"); 46 | expect(wrapper.emitted("close")).toBeTruthy(); 47 | }); 48 | 49 | it("does not emit close event when disabled", async () => { 50 | const wrapper = mount(Tag, { 51 | props: { 52 | closable: true, 53 | disabled: true, 54 | }, 55 | }); 56 | await wrapper.find(".delete").trigger("click"); 57 | expect(wrapper.emitted("close")).toBeFalsy(); 58 | }); 59 | 60 | it("handles attached mode correctly", () => { 61 | const wrapper = mount(Tag, { 62 | props: { 63 | attached: true, 64 | closable: true, 65 | }, 66 | }); 67 | expect(wrapper.find(".tags.has-addons").exists()).toBe(true); 68 | expect(wrapper.find(".is-delete").exists()).toBe(true); 69 | }); 70 | 71 | it("shows ellipsis when ellipsis prop is true", () => { 72 | const wrapper = mount(Tag, { 73 | props: { 74 | ellipsis: true, 75 | }, 76 | slots: { 77 | default: "Long text that should be ellipsized", 78 | }, 79 | }); 80 | expect(wrapper.find(".has-ellipsis").exists()).toBe(true); 81 | }); 82 | 83 | it("handles tabstop correctly", () => { 84 | const wrapper = mount(Tag, { 85 | props: { 86 | closable: true, 87 | tabstop: false, 88 | }, 89 | }); 90 | expect(wrapper.find(".delete").attributes("tabindex")).toBe("false"); 91 | 92 | const wrapperWithTabstop = mount(Tag, { 93 | props: { 94 | closable: true, 95 | tabstop: true, 96 | }, 97 | }); 98 | expect(wrapperWithTabstop.find(".delete").attributes("tabindex")).toBe("0"); 99 | }); 100 | 101 | it("applies aria-label to close button", () => { 102 | const wrapper = mount(Tag, { 103 | props: { 104 | closable: true, 105 | ariaCloseLabel: "Close tag", 106 | }, 107 | }); 108 | expect(wrapper.find(".delete").attributes("aria-label")).toBe("Close tag"); 109 | }); 110 | 111 | it("applies close type class", () => { 112 | const wrapper = mount(Tag, { 113 | props: { 114 | closable: true, 115 | closeType: "is-danger", 116 | }, 117 | }); 118 | expect(wrapper.find(".delete").classes()).toContain("is-danger"); 119 | }); 120 | 121 | it("shows custom close icon when provided", () => { 122 | const wrapper = mount(Tag, { 123 | props: { 124 | attached: true, 125 | closable: true, 126 | closeIcon: "custom-icon", 127 | }, 128 | }); 129 | expect(wrapper.find(".has-delete-icon").exists()).toBe(true); 130 | }); 131 | 132 | it("handles keyboard delete event", async () => { 133 | const wrapper = mount(Tag, { 134 | props: { 135 | closable: true, 136 | }, 137 | }); 138 | await wrapper.find(".delete").trigger("keyup.delete"); 139 | expect(wrapper.emitted("close")).toBeTruthy(); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/components/primitives/Textarea/Textarea.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 |