├── .env ├── favicon.ico ├── static ├── demo.gif ├── logo.png └── softdesk.png ├── styles ├── themes │ ├── soft.scss │ ├── dark.scss │ └── light.scss ├── _variables.scss ├── _normalize.scss └── global.scss ├── src ├── types.js ├── components │ ├── separator.vue │ ├── noResults.vue │ ├── noSelection.vue │ ├── total.vue │ ├── selectAll.vue │ ├── deselectAll.vue │ ├── search.vue │ └── list.vue ├── locales │ ├── tr_TR.json │ ├── en_US.json │ ├── cz_CZ.json │ ├── fr_FR.json │ ├── sk_SK.json │ ├── es_ES.json │ ├── pl_PL.json │ └── pt_BR.json ├── index.js ├── i18n.js ├── utils.js ├── mixin.js ├── sort-by.js ├── vue-select-sides.vue └── modules │ ├── mirror.vue │ └── grouped.vue ├── example ├── main.js ├── components │ ├── mirror-with-selecteds.vue │ ├── mirror-with-disabled.vue │ ├── mirror-placeholder-search.vue │ ├── mirror-only-list.vue │ ├── mirror-sort-list.vue │ ├── grouped-basic.vue │ ├── grouped-with-selecteds.vue │ ├── grouped-only-list.vue │ ├── grouped-sort-selected-first.vue │ ├── grouped-placeholder-search.vue │ ├── grouped-with-disabled.vue │ ├── grouped-sort-list.vue │ └── mirror-basic.vue └── App.vue ├── vue.config.js ├── .eslintrc.js ├── .gitignore ├── docs ├── index.html └── vueSelectSidesExample.css ├── .editorconfig ├── vite.serve.config.js ├── vite.npm.config.js ├── vite.example.config.js ├── package.json ├── dist └── css │ ├── soft.css │ ├── dark.css │ └── light.css ├── README.md └── tests └── sort-by.test.js /.env: -------------------------------------------------------------------------------- 1 | VUE_APP_I18N_LOCALE=en_US 2 | VUE_APP_I18N_FALLBACK_LOCALE=en_US 3 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft4ti/vue-select-sides/HEAD/favicon.ico -------------------------------------------------------------------------------- /static/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft4ti/vue-select-sides/HEAD/static/demo.gif -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft4ti/vue-select-sides/HEAD/static/logo.png -------------------------------------------------------------------------------- /static/softdesk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soft4ti/vue-select-sides/HEAD/static/softdesk.png -------------------------------------------------------------------------------- /styles/themes/soft.scss: -------------------------------------------------------------------------------- 1 | @forward "../_variables.scss"; 2 | @forward "../global.scss"; 3 | @use "../global.scss" as *; 4 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | import mirror from "./modules/mirror.vue"; 2 | import grouped from "./modules/grouped.vue"; 3 | export default { 4 | mirror: mirror, 5 | grouped: grouped, 6 | }; 7 | -------------------------------------------------------------------------------- /example/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import App from "./App.vue"; 3 | import vueSelectSides from "../src/index"; 4 | 5 | const app = createApp(App); 6 | app.use(vueSelectSides); 7 | app.mount("#app"); 8 | -------------------------------------------------------------------------------- /src/components/separator.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 13 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | productionSourceMap: false, 3 | pluginOptions: { 4 | i18n: { 5 | locale: "en_US", 6 | fallbackLocale: "en_US", 7 | localeDir: "locales", 8 | enableInSFC: false, 9 | }, 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /src/locales/tr_TR.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Hepsini Seç", 3 | "deselectAll": "Hepsini Çıkar", 4 | "searchPlaceholder": "Ara...", 5 | "searchNoResult": "Sonuç Bulunamadı...", 6 | "searchParentSelected": "Hiçbiri Seçilmedi...", 7 | "totalSelected": "Hepsi Seçildi" 8 | } 9 | -------------------------------------------------------------------------------- /src/locales/en_US.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Select all", 3 | "deselectAll": "Deselect all", 4 | "searchPlaceholder": "Search...", 5 | "searchNoResult": "No result...", 6 | "searchParentSelected": "No items selected...", 7 | "totalSelected": "Total items selected" 8 | } 9 | -------------------------------------------------------------------------------- /src/locales/cz_CZ.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Vybrat vše ", 3 | "deselectAll": "Odebrat vše ", 4 | "searchPlaceholder": "Hledej...", 5 | "searchNoResult": "Žádný výsledek...", 6 | "searchParentSelected": "Nic není vybráno...", 7 | "totalSelected": "Spolu vybrané" 8 | } -------------------------------------------------------------------------------- /src/locales/fr_FR.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Ajouter tout", 3 | "deselectAll": "Supprimer tout", 4 | "searchPlaceholder": "Recherche...", 5 | "searchNoResult": "Pas de résultat...", 6 | "searchParentSelected": "Sélection vide...", 7 | "totalSelected": "Éléments sélectionnés" 8 | } 9 | -------------------------------------------------------------------------------- /src/locales/sk_SK.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Vybrať všetko", 3 | "deselectAll": "Odobrať všetko", 4 | "searchPlaceholder": "Hľadaj...", 5 | "searchNoResult": "Žiadny výsledok...", 6 | "searchParentSelected": "Nič nie je vybrané...", 7 | "totalSelected": "Spolu vybrané" 8 | } -------------------------------------------------------------------------------- /src/components/noResults.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /src/components/noSelection.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 14 | -------------------------------------------------------------------------------- /src/components/total.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /src/locales/es_ES.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Seleccionar todos", 3 | "deselectAll": "Deseleccionar todos", 4 | "searchPlaceholder": "Buscar...", 5 | "searchNoResult": "Sin resultados...", 6 | "searchParentSelected": "Ningún elemento seleccionado...", 7 | "totalSelected": "Elementos seleccionados" 8 | } 9 | -------------------------------------------------------------------------------- /src/locales/pl_PL.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Wybierz wszystko", 3 | "deselectAll": "Wyczyść zaznaczenie", 4 | "searchPlaceholder": "Szukaj...", 5 | "searchNoResult": "Brak wyników...", 6 | "searchParentSelected": "Nie wybrano żadnych elementów...", 7 | "totalSelected": "Liczba wybranych elementów" 8 | } 9 | -------------------------------------------------------------------------------- /src/locales/pt_BR.json: -------------------------------------------------------------------------------- 1 | { 2 | "selectAll": "Selecionar todos", 3 | "deselectAll": "Remover todos", 4 | "searchPlaceholder": "Pesquisar...", 5 | "searchNoResult": "Sem nenhum resultado...", 6 | "searchParentSelected": "Nenhum item selecionado...", 7 | "totalSelected": "Total de itens selecionados" 8 | } 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | extends: ["plugin:vue/essential", "eslint:recommended"], 7 | rules: { 8 | "no-console": import.meta.env.MODE === "production" ? "error" : "off", 9 | "no-debugger": import.meta.env.MODE === "production" ? "error" : "off", 10 | }, 11 | }; 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist/core/demo.html 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | # Vitest 24 | coverage/ 25 | -------------------------------------------------------------------------------- /src/components/selectAll.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | -------------------------------------------------------------------------------- /src/components/deselectAll.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vueSelectSides demo 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import vueSelectSides from "./vue-select-sides.vue"; 2 | import i18n from "./i18n"; 3 | 4 | const install = (app, options) => { 5 | app.config.globalProperties.defaultOptions = 6 | options && options.constructor.name === "Object" ? options : {}; 7 | 8 | app.use(i18n); 9 | app.component("vueSelectSides", vueSelectSides); 10 | }; 11 | 12 | vueSelectSides.install = install; 13 | 14 | export default vueSelectSides; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | # The JSON files contain newlines inconsistently 13 | [*.json] 14 | insert_final_newline = ignore 15 | 16 | # Minified JavaScript files shouldn't be changed 17 | [**.min.js] 18 | indent_style = ignore 19 | insert_final_newline = ignore 20 | 21 | [*.md] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-size-base: 0.9rem !default; 2 | $border-radius-base: 0.25rem !default; 3 | 4 | $selected-color: #f57f1e !default; 5 | 6 | $white: #fff !default; 7 | $gray: #e1e1e1 !default; 8 | $dark: #242934 !default; 9 | 10 | $default-item-background: #fafafa !default; 11 | $default-item-color-selected: $white !default; 12 | $default-item-background-selected: $selected-color !default; 13 | 14 | $default-text-color: $dark !default; 15 | 16 | $default-footer-text-color: $white !default; 17 | $default-footer-background: $dark !default; 18 | 19 | $badge-background: rgba($dark, 0.15) !default; 20 | -------------------------------------------------------------------------------- /vite.serve.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | build: { 8 | lib: { 9 | entry: path.resolve(__dirname, "example/main.js"), 10 | name: "vueSelectSides", 11 | fileName: (format) => `vueSelectSides.${format}.js`, 12 | formats: ["umd"], 13 | }, 14 | rollupOptions: { 15 | external: ["vue"], 16 | output: { 17 | globals: { 18 | vue: "Vue", 19 | }, 20 | }, 21 | }, 22 | }, 23 | server: { 24 | sourcemap: true, 25 | open: "/docs/index.html", // Abre o navegador automaticamente 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /styles/themes/dark.scss: -------------------------------------------------------------------------------- 1 | // dark.scss - Tema escuro 2 | @use "sass:color"; 3 | 4 | // Define as cores base diretamente (valores hardcoded do _variables.scss) 5 | $selected-color: #242934; 6 | $default-item-background-selected: $selected-color; 7 | $badge-background: color.adjust($selected-color, $lightness: 15%); 8 | 9 | // Configura as variáveis ANTES de importar o global 10 | // Passa as configurações direto pro _variables.scss 11 | @forward "../_variables.scss" with ( 12 | $selected-color: $selected-color, 13 | $default-item-background-selected: $default-item-background-selected, 14 | $badge-background: $badge-background 15 | ); 16 | 17 | // Importa o global normalmente 18 | @forward "../global.scss"; 19 | @use "../global.scss" as *; 20 | -------------------------------------------------------------------------------- /src/components/search.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 31 | -------------------------------------------------------------------------------- /src/i18n.js: -------------------------------------------------------------------------------- 1 | import { createI18n } from "vue-i18n"; 2 | 3 | function loadLocaleMessages() { 4 | const locales = import.meta.glob("./locales/*.json", { 5 | eager: true, 6 | import: "default", 7 | }); 8 | const messages = {}; 9 | 10 | for (const path in locales) { 11 | const matched = path.match(/([A-Za-z0-9-_]+)(?=\.)/i)[0]; 12 | 13 | if (matched && matched.length > 1) { 14 | const locale = matched; 15 | messages[locale] = locales[path]; 16 | } 17 | } 18 | return messages; 19 | } 20 | 21 | const i18n = createI18n({ 22 | locale: import.meta.env.VUE_APP_I18N_LOCALE || "en_US", 23 | fallbackLocale: import.meta.env.VUE_APP_I18N_FALLBACK_LOCALE || "en_US", 24 | messages: loadLocaleMessages(), 25 | }); 26 | 27 | export default i18n; 28 | -------------------------------------------------------------------------------- /vite.npm.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from 'path'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | build: { 14 | lib: { 15 | entry: path.resolve(__dirname, "src/index.js"), 16 | name: "vueSelectSides", 17 | fileName: (format) => `vue-select-sides.${format}.js`, 18 | }, 19 | rollupOptions: { 20 | // Certifique-se de externalizar dependências que não devem ser incluídas no bundle 21 | external: ["vue"], 22 | output: { 23 | // Forneça variáveis globais para usar no build UMD 24 | globals: { 25 | vue: "Vue", 26 | }, 27 | }, 28 | }, 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /example/components/mirror-with-selecteds.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 46 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import sortBy from "./sort-by"; 2 | 3 | const clone = (json) => JSON.parse(JSON.stringify(json)); 4 | 5 | const normalizeText = (text) => { 6 | return text 7 | .normalize("NFD") 8 | .replace(/[\u0300-\u036f]/g, "") 9 | .toLowerCase(); 10 | }; 11 | 12 | const reorder = (vm, data) => { 13 | let orderBy = []; 14 | 15 | if (vm.sortSelectedUp) { 16 | orderBy.push("-selectedDefault"); 17 | } 18 | 19 | if (vm.orderBy) { 20 | if (vm.orderBy.toLowerCase() === "asc") { 21 | orderBy.push("label"); 22 | } 23 | 24 | if (vm.orderBy.toLowerCase() === "desc") { 25 | orderBy.push("-label"); 26 | } 27 | } 28 | 29 | data.sort(sortBy(...orderBy)).map((item) => { 30 | if (item.children) item.children.sort(sortBy(...orderBy)); 31 | return item; 32 | }); 33 | 34 | return data; 35 | }; 36 | 37 | const removeItemArray = (array, value) => { 38 | return array.filter((e) => String(e) !== String(value)); 39 | }; 40 | 41 | export { normalizeText, clone, reorder, removeItemArray }; 42 | -------------------------------------------------------------------------------- /example/components/mirror-with-disabled.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 48 | -------------------------------------------------------------------------------- /example/components/mirror-placeholder-search.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 48 | -------------------------------------------------------------------------------- /example/components/mirror-only-list.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 52 | -------------------------------------------------------------------------------- /styles/themes/light.scss: -------------------------------------------------------------------------------- 1 | // light.scss - Tema claro 2 | @use "sass:color"; 3 | 4 | $selected-color: #e1e1e1; 5 | 6 | $default-text-color: #222; 7 | $default-item-background: #fafafa; 8 | $default-item-color-selected: color.adjust( 9 | $default-text-color, 10 | $lightness: 30% 11 | ); 12 | $default-item-background-selected: color.adjust( 13 | $selected-color, 14 | $lightness: 3% 15 | ); 16 | 17 | $default-footer-text-color: color.adjust($default-text-color, $lightness: 15%); 18 | $default-footer-background: color.adjust( 19 | $default-item-background, 20 | $lightness: -5% 21 | ); 22 | 23 | // Configura o _variables.scss com os valores calculados 24 | @forward "../_variables.scss" with ( 25 | $selected-color: $selected-color, 26 | $default-text-color: $default-text-color, 27 | $default-item-background: $default-item-background, 28 | $default-item-color-selected: $default-item-color-selected, 29 | $default-item-background-selected: $default-item-background-selected, 30 | $default-footer-text-color: $default-footer-text-color, 31 | $default-footer-background: $default-footer-background 32 | ); 33 | 34 | // Importa o global normalmente 35 | @forward "../global.scss"; 36 | @use "../global.scss" as *; 37 | -------------------------------------------------------------------------------- /styles/_normalize.scss: -------------------------------------------------------------------------------- 1 | .vss, 2 | .vss ul, 3 | .vss ul li { 4 | list-style-type: none; 5 | margin: 0px; 6 | } 7 | 8 | .vss ul ul { 9 | padding: 0px; 10 | } 11 | 12 | .vss, 13 | .vss *, 14 | .vss *::before, 15 | .vss *::after { 16 | box-sizing: border-box; 17 | } 18 | 19 | .vss { 20 | display: flex; 21 | align-items: stretch; 22 | align-content: stretch; 23 | justify-content: space-between; 24 | 25 | a { 26 | text-decoration: none; 27 | } 28 | 29 | .vss-span, 30 | .vss-list-ul li { 31 | user-select: none; 32 | } 33 | 34 | .vss-span { 35 | width: 15%; 36 | display: flex; 37 | align-items: center; 38 | justify-content: center; 39 | } 40 | 41 | .vss-list { 42 | width: 100%; 43 | .vss-inner-list { 44 | height: 100%; 45 | display: flex; 46 | flex-direction: column; 47 | 48 | .vss-list-search { 49 | width: 100%; 50 | } 51 | 52 | .vss-list-ul { 53 | overflow-y: auto; 54 | 55 | li { 56 | line-height: 1.5; 57 | 58 | .vss-list-badge { 59 | display: flex; 60 | align-items: center; 61 | justify-content: center; 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /vite.example.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import path from "path"; 4 | 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | define: { 8 | "process.env.NODE_ENV": JSON.stringify("production"), 9 | "process.env.VUE_APP_I18N_LOCALE": JSON.stringify("en_US"), 10 | "process.env.VUE_APP_I18N_FALLBACK_LOCALE": JSON.stringify("en_US"), 11 | }, 12 | build: { 13 | sourcemap: false, 14 | lib: { 15 | entry: path.resolve(__dirname, "example/main.js"), 16 | name: "vueSelectSidesExample", 17 | fileName: (format) => `vueSelectSidesExample.${format}.js`, 18 | formats: ["umd"], 19 | }, 20 | outDir: "docs", 21 | emptyOutDir: false, 22 | rollupOptions: { 23 | external: ["vue"], 24 | output: { 25 | globals: { 26 | vue: "Vue", 27 | }, 28 | assetFileNames: (assetInfo) => { 29 | if ( 30 | assetInfo.names?.includes("style.css") || 31 | assetInfo.names?.includes("vue-select-sides.css") 32 | ) { 33 | return "vueSelectSidesExample.css"; 34 | } 35 | return assetInfo.name; 36 | }, 37 | }, 38 | }, 39 | }, 40 | }); 41 | -------------------------------------------------------------------------------- /src/mixin.js: -------------------------------------------------------------------------------- 1 | import { reorder } from "./utils"; 2 | 3 | export default { 4 | emits: ["update-selected"], 5 | watch: { 6 | modelValue(newItems, oldItems) { 7 | if (JSON.stringify(newItems) !== JSON.stringify(oldItems)) { 8 | this.dataSelected = newItems; 9 | } 10 | }, 11 | dataSelected(newItems, oldItems) { 12 | this.$emit("update-selected", newItems, oldItems); 13 | }, 14 | orderBy() { 15 | this.listLeft = reorder(this, this.dataList); 16 | this.listRight = reorder(this, this.dataList); 17 | }, 18 | list(newItems) { 19 | if (JSON.stringify(newItems) !== JSON.stringify(this.dataListOriginal)) { 20 | this.prepareList(); 21 | this.prepareListLeft(); 22 | } 23 | }, 24 | }, 25 | props: { 26 | placeholderSearchLeft: { 27 | type: [String, Boolean], 28 | }, 29 | placeholderSearchRight: { 30 | type: [String, Boolean], 31 | }, 32 | type: { 33 | type: String, 34 | }, 35 | search: { 36 | type: Boolean, 37 | }, 38 | total: { 39 | type: Boolean, 40 | }, 41 | toggleAll: { 42 | type: Boolean, 43 | }, 44 | orderBy: { 45 | type: String, 46 | }, 47 | sortSelectedUp: { 48 | type: Boolean, 49 | }, 50 | }, 51 | }; 52 | -------------------------------------------------------------------------------- /example/components/mirror-sort-list.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 58 | 59 | 82 | -------------------------------------------------------------------------------- /src/sort-by.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Função que replica o comportamento do sort-by 3 | * Suporta múltiplos critérios de ordenação e ordem reversa com "-" 4 | * Suporta função de mapeamento opcional (último parâmetro) 5 | */ 6 | const sortBy = (...args) => { 7 | // Separa propriedades (strings) da função de mapeamento (function) 8 | const properties = args.filter((arg) => typeof arg === "string"); 9 | const mapFn = args.find((arg) => typeof arg === "function"); 10 | 11 | return (a, b) => { 12 | for (let property of properties) { 13 | let sortOrder = 1; 14 | 15 | // Se começar com "-", ordena decrescente 16 | if (property[0] === "-") { 17 | sortOrder = -1; 18 | property = property.substring(1); 19 | } 20 | 21 | // Pega o valor da propriedade (suporta nested objects) 22 | let valueA = getNestedValue(a, property); 23 | let valueB = getNestedValue(b, property); 24 | 25 | // Aplica a função de mapeamento se existir 26 | if (mapFn) { 27 | valueA = mapFn(property, valueA); 28 | valueB = mapFn(property, valueB); 29 | } 30 | 31 | // Trata undefined/null: undefined vai pro final, null é menor que tudo 32 | const aIsUndefined = valueA === undefined; 33 | const bIsUndefined = valueB === undefined; 34 | 35 | if (aIsUndefined && bIsUndefined) continue; // ambos undefined, próximo critério 36 | if (aIsUndefined) return 1; // A vai pro final 37 | if (bIsUndefined) return -1; // B vai pro final 38 | 39 | // Compara os valores 40 | if (valueA < valueB) return -1 * sortOrder; 41 | if (valueA > valueB) return 1 * sortOrder; 42 | 43 | // Se forem iguais, continua pro próximo critério 44 | } 45 | return 0; 46 | }; 47 | }; 48 | 49 | /** 50 | * Pega valores de propriedades aninhadas (ex: "user.name") 51 | */ 52 | const getNestedValue = (obj, path) => { 53 | return path.split(".").reduce((current, prop) => { 54 | return current?.[prop]; 55 | }, obj); 56 | }; 57 | 58 | export default sortBy; 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-select-sides", 3 | "version": "2.0.4", 4 | "description": "select sides component for vue", 5 | "license": "MIT", 6 | "private": false, 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/soft4ti/vue-select-sides" 10 | }, 11 | "main": "dist/vue-select-sides.umd.js", 12 | "module": "dist/vue-select-sides.es.js", 13 | "exports": { 14 | ".": { 15 | "import": "./dist/vue-select-sides.es.js", 16 | "require": "./dist/vue-select-sides.umd.js" 17 | }, 18 | "./dist/css/*": "./dist/css/*", 19 | "./styles/themes/*": "./styles/themes/*" 20 | }, 21 | "unpkg": "dist/vue-select-sides.umd.js", 22 | "scripts": { 23 | "serve": "vite --config vite.serve.config.js", 24 | "build": "yarn build:example && yarn build:npm && yarn build:scss", 25 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore --fix src", 26 | "i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'", 27 | "build:example": "vite build --config vite.example.config.js", 28 | "build:npm": "vite build --config vite.npm.config.js", 29 | "build:scss": "sass styles/themes:dist/css --style=compressed --no-source-map", 30 | "test": "vitest", 31 | "test:coverage": "vitest run --coverage" 32 | }, 33 | "files": [ 34 | "dist", 35 | "styles" 36 | ], 37 | "keywords": [ 38 | "vue", 39 | "vuejs", 40 | "component", 41 | "select list", 42 | "multiselect", 43 | "multipleselect", 44 | "multiple", 45 | "select", 46 | "select 2 sides", 47 | "multiselect 2 sides" 48 | ], 49 | "devDependencies": { 50 | "@vitejs/plugin-vue": "^6.0.2", 51 | "@vitest/coverage-v8": "4.0.14", 52 | "@vue/compiler-sfc": "^3.5.25", 53 | "babel-eslint": "^10.1.0", 54 | "eslint": "^8.57.1", 55 | "eslint-plugin-vue": "^8.7.1", 56 | "sass-embedded": "^1.93.3", 57 | "vite": "^7.2.4", 58 | "vitest": "^4.0.14", 59 | "vue": "^3.5.25", 60 | "vue-i18n": "^9.14.5" 61 | }, 62 | "eslintConfig": { 63 | "root": true, 64 | "env": { 65 | "node": true 66 | }, 67 | "extends": [ 68 | "plugin:vue/essential", 69 | "eslint:recommended" 70 | ], 71 | "rules": {}, 72 | "parserOptions": { 73 | "parser": "babel-eslint" 74 | } 75 | }, 76 | "browserslist": [ 77 | "> 1%", 78 | "last 2 versions" 79 | ], 80 | "dependencies": {} 81 | } -------------------------------------------------------------------------------- /dist/css/soft.css: -------------------------------------------------------------------------------- 1 | .vss,.vss ul,.vss ul li{list-style-type:none;margin:0px}.vss ul ul{padding:0px}.vss,.vss *,.vss *::before,.vss *::after{box-sizing:border-box}.vss{display:flex;align-items:stretch;align-content:stretch;justify-content:space-between}.vss a{text-decoration:none}.vss .vss-span,.vss .vss-list-ul li{user-select:none}.vss .vss-span{width:15%;display:flex;align-items:center;justify-content:center}.vss .vss-list{width:100%}.vss .vss-list .vss-inner-list{height:100%;display:flex;flex-direction:column}.vss .vss-list .vss-inner-list .vss-list-search{width:100%}.vss .vss-list .vss-inner-list .vss-list-ul{overflow-y:auto}.vss .vss-list .vss-inner-list .vss-list-ul li{line-height:1.5}.vss .vss-list .vss-inner-list .vss-list-ul li .vss-list-badge{display:flex;align-items:center;justify-content:center}.vss *{font-size:.9rem}.vss .vss-span{font-size:1.3rem;color:#e1e1e1}.vss .vss-list .vss-inner-list{box-shadow:0px 0px 10px #e1e1e1;border-radius:.25rem}.vss .vss-list .vss-inner-list .vss-list-search{border:none;padding:12px 14px;border-bottom:2px solid hsl(0,0%,95.2352941176%);border-top-left-radius:.25rem;border-top-right-radius:.25rem;outline:none}.vss .vss-list .vss-inner-list .vss-list-search:focus{border-color:#f57f1e}.vss .vss-list .vss-inner-list .vss-list-ul{padding:8px 10px 10px 10px}.vss .vss-list .vss-inner-list .vss-list-ul li span{display:flex;align-items:center;justify-content:space-between;border-radius:.25rem;color:#242934;padding:5px 12px;margin-top:2px}.vss .vss-list .vss-inner-list .vss-list-ul li span .vss-list-badge{font-size:.5rem;color:#fff;background-color:rgba(36,41,52,.15);padding:2px 4px 0px 4px;border-radius:20px;min-width:14px;height:14px;font-weight:bold}.vss .vss-list .vss-inner-list .vss-list-ul li.active:not(.is-parent)>span{background-color:#f57f1e;color:#fff;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled:not(.is-parent)>span{background-color:hsl(0,0%,83.2352941176%);color:#fff;color:#242934;cursor:default;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled.active:not(.is-parent)>span{background-color:hsl(27.0697674419,91.4893617021%,73.9215686275%);color:#fff}.vss .vss-list .vss-inner-list .vss-list-ul li:not(.is-parent)>span{cursor:pointer;background-color:#fafafa;border:1px solid rgb(244.9,244.9,244.9)}.vss .vss-list .vss-inner-list .vss-list-ul li.no-results>span,.vss .vss-list .vss-inner-list .vss-list-ul li.no-selection>span{cursor:default;background-color:#fafafa;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul>li.is-parent>span{font-weight:bold;padding-left:0px}.vss .vss-list .vss-inner-list .vss-footer{align-items:flex-end;display:flex;flex:1 0 auto}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg{display:flex;justify-content:space-between;align-items:center;padding:0px 12px;height:2rem;width:100%;background-color:#242934;border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg *{color:#fff;font-size:.7rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div{display:flex}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div .vss-footer-separator{margin:0px 6px}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>span{font-size:.6rem;font-weight:bold;color:#fff;background-color:hsl(221.25,18.1818181818%,32.2549019608%);padding:1px 6px 0px 6px;border-radius:20px;min-width:14px;height:14px}.vss .vss-list .vss-total{font-size:.7rem} 2 | -------------------------------------------------------------------------------- /dist/css/dark.css: -------------------------------------------------------------------------------- 1 | .vss,.vss ul,.vss ul li{list-style-type:none;margin:0px}.vss ul ul{padding:0px}.vss,.vss *,.vss *::before,.vss *::after{box-sizing:border-box}.vss{display:flex;align-items:stretch;align-content:stretch;justify-content:space-between}.vss a{text-decoration:none}.vss .vss-span,.vss .vss-list-ul li{user-select:none}.vss .vss-span{width:15%;display:flex;align-items:center;justify-content:center}.vss .vss-list{width:100%}.vss .vss-list .vss-inner-list{height:100%;display:flex;flex-direction:column}.vss .vss-list .vss-inner-list .vss-list-search{width:100%}.vss .vss-list .vss-inner-list .vss-list-ul{overflow-y:auto}.vss .vss-list .vss-inner-list .vss-list-ul li{line-height:1.5}.vss .vss-list .vss-inner-list .vss-list-ul li .vss-list-badge{display:flex;align-items:center;justify-content:center}.vss *{font-size:.9rem}.vss .vss-span{font-size:1.3rem;color:#e1e1e1}.vss .vss-list .vss-inner-list{box-shadow:0px 0px 10px #e1e1e1;border-radius:.25rem}.vss .vss-list .vss-inner-list .vss-list-search{border:none;padding:12px 14px;border-bottom:2px solid hsl(0,0%,95.2352941176%);border-top-left-radius:.25rem;border-top-right-radius:.25rem;outline:none}.vss .vss-list .vss-inner-list .vss-list-search:focus{border-color:#242934}.vss .vss-list .vss-inner-list .vss-list-ul{padding:8px 10px 10px 10px}.vss .vss-list .vss-inner-list .vss-list-ul li span{display:flex;align-items:center;justify-content:space-between;border-radius:.25rem;color:#242934;padding:5px 12px;margin-top:2px}.vss .vss-list .vss-inner-list .vss-list-ul li span .vss-list-badge{font-size:.5rem;color:#fff;background-color:hsl(221.25,18.1818181818%,32.2549019608%);padding:2px 4px 0px 4px;border-radius:20px;min-width:14px;height:14px;font-weight:bold}.vss .vss-list .vss-inner-list .vss-list-ul li.active:not(.is-parent)>span{background-color:#242934;color:#fff;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled:not(.is-parent)>span{background-color:hsl(0,0%,83.2352941176%);color:#fff;color:#242934;cursor:default;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled.active:not(.is-parent)>span{background-color:hsl(221.25,18.1818181818%,37.2549019608%);color:#fff}.vss .vss-list .vss-inner-list .vss-list-ul li:not(.is-parent)>span{cursor:pointer;background-color:#fafafa;border:1px solid rgb(244.9,244.9,244.9)}.vss .vss-list .vss-inner-list .vss-list-ul li.no-results>span,.vss .vss-list .vss-inner-list .vss-list-ul li.no-selection>span{cursor:default;background-color:#fafafa;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul>li.is-parent>span{font-weight:bold;padding-left:0px}.vss .vss-list .vss-inner-list .vss-footer{align-items:flex-end;display:flex;flex:1 0 auto}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg{display:flex;justify-content:space-between;align-items:center;padding:0px 12px;height:2rem;width:100%;background-color:#242934;border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg *{color:#fff;font-size:.7rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div{display:flex}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div .vss-footer-separator{margin:0px 6px}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>span{font-size:.6rem;font-weight:bold;color:#fff;background-color:hsl(221.25,18.1818181818%,32.2549019608%);padding:1px 6px 0px 6px;border-radius:20px;min-width:14px;height:14px}.vss .vss-list .vss-total{font-size:.7rem} 2 | -------------------------------------------------------------------------------- /dist/css/light.css: -------------------------------------------------------------------------------- 1 | .vss,.vss ul,.vss ul li{list-style-type:none;margin:0px}.vss ul ul{padding:0px}.vss,.vss *,.vss *::before,.vss *::after{box-sizing:border-box}.vss{display:flex;align-items:stretch;align-content:stretch;justify-content:space-between}.vss a{text-decoration:none}.vss .vss-span,.vss .vss-list-ul li{user-select:none}.vss .vss-span{width:15%;display:flex;align-items:center;justify-content:center}.vss .vss-list{width:100%}.vss .vss-list .vss-inner-list{height:100%;display:flex;flex-direction:column}.vss .vss-list .vss-inner-list .vss-list-search{width:100%}.vss .vss-list .vss-inner-list .vss-list-ul{overflow-y:auto}.vss .vss-list .vss-inner-list .vss-list-ul li{line-height:1.5}.vss .vss-list .vss-inner-list .vss-list-ul li .vss-list-badge{display:flex;align-items:center;justify-content:center}.vss *{font-size:.9rem}.vss .vss-span{font-size:1.3rem;color:#e1e1e1}.vss .vss-list .vss-inner-list{box-shadow:0px 0px 10px #e1e1e1;border-radius:.25rem}.vss .vss-list .vss-inner-list .vss-list-search{border:none;padding:12px 14px;border-bottom:2px solid hsl(0,0%,95.2352941176%);border-top-left-radius:.25rem;border-top-right-radius:.25rem;outline:none}.vss .vss-list .vss-inner-list .vss-list-search:focus{border-color:#e1e1e1}.vss .vss-list .vss-inner-list .vss-list-ul{padding:8px 10px 10px 10px}.vss .vss-list .vss-inner-list .vss-list-ul li span{display:flex;align-items:center;justify-content:space-between;border-radius:.25rem;color:#222;padding:5px 12px;margin-top:2px}.vss .vss-list .vss-inner-list .vss-list-ul li span .vss-list-badge{font-size:.5rem;color:#fff;background-color:rgba(36,41,52,.15);padding:2px 4px 0px 4px;border-radius:20px;min-width:14px;height:14px;font-weight:bold}.vss .vss-list .vss-inner-list .vss-list-ul li.active:not(.is-parent)>span{background-color:hsl(0,0%,91.2352941176%);color:rgb(110.5,110.5,110.5);border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled:not(.is-parent)>span{background-color:hsl(0,0%,83.2352941176%);color:rgb(110.5,110.5,110.5);color:#222;cursor:default;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled.active:not(.is-parent)>span{background-color:hsl(0,0%,111.2352941176%);color:rgb(110.5,110.5,110.5)}.vss .vss-list .vss-inner-list .vss-list-ul li:not(.is-parent)>span{cursor:pointer;background-color:#fafafa;border:1px solid rgb(244.9,244.9,244.9)}.vss .vss-list .vss-inner-list .vss-list-ul li.no-results>span,.vss .vss-list .vss-inner-list .vss-list-ul li.no-selection>span{cursor:default;background-color:#fafafa;border-color:rgba(0,0,0,0)}.vss .vss-list .vss-inner-list .vss-list-ul>li.is-parent>span{font-weight:bold;padding-left:0px}.vss .vss-list .vss-inner-list .vss-footer{align-items:flex-end;display:flex;flex:1 0 auto}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg{display:flex;justify-content:space-between;align-items:center;padding:0px 12px;height:2rem;width:100%;background-color:hsl(0,0%,93.0392156863%);border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg *{color:rgb(72.25,72.25,72.25);font-size:.7rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div{display:flex}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div .vss-footer-separator{margin:0px 6px}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>span{font-size:.6rem;font-weight:bold;color:rgb(72.25,72.25,72.25);background-color:hsl(0,0%,108.0392156863%);padding:1px 6px 0px 6px;border-radius:20px;min-width:14px;height:14px}.vss .vss-list .vss-total{font-size:.7rem} 2 | -------------------------------------------------------------------------------- /src/components/list.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 154 | -------------------------------------------------------------------------------- /example/components/grouped-basic.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 180 | -------------------------------------------------------------------------------- /example/components/grouped-with-selecteds.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 183 | -------------------------------------------------------------------------------- /styles/global.scss: -------------------------------------------------------------------------------- 1 | @use "sass:color"; 2 | @use "_normalize.scss"; 3 | @use "_variables.scss"; 4 | 5 | .vss { 6 | * { 7 | font-size: variables.$font-size-base; 8 | } 9 | 10 | .vss-span { 11 | font-size: variables.$font-size-base + 0.4rem; 12 | color: variables.$gray; 13 | } 14 | 15 | .vss-list { 16 | .vss-inner-list { 17 | box-shadow: 0px 0px 10px variables.$gray; 18 | border-radius: variables.$border-radius-base; 19 | 20 | .vss-list-search { 21 | border: none; 22 | padding: 12px 14px; 23 | border-bottom: 2px solid color.adjust(variables.$gray, $lightness: 7%); 24 | border-top-left-radius: variables.$border-radius-base; 25 | border-top-right-radius: variables.$border-radius-base; 26 | outline: none; 27 | 28 | &:focus { 29 | border-color: variables.$selected-color; 30 | } 31 | } 32 | 33 | .vss-list-ul { 34 | padding: 8px 10px 10px 10px; 35 | 36 | li { 37 | span { 38 | display: flex; 39 | align-items: center; 40 | justify-content: space-between; 41 | border-radius: variables.$border-radius-base; 42 | color: variables.$default-text-color; 43 | padding: 5px 12px; 44 | margin-top: 2px; 45 | 46 | .vss-list-badge { 47 | font-size: variables.$font-size-base - 0.4; 48 | color: variables.$white; 49 | background-color: variables.$badge-background; 50 | padding: 2px 4px 0px 4px; 51 | border-radius: 20px; 52 | min-width: 14px; 53 | height: 14px; 54 | font-weight: bold; 55 | } 56 | } 57 | 58 | &.active:not(.is-parent) > span { 59 | background-color: variables.$default-item-background-selected; 60 | color: variables.$default-item-color-selected; 61 | border-color: transparent; 62 | } 63 | 64 | &.is-disabled:not(.is-parent) > span { 65 | background-color: color.adjust(variables.$gray, $lightness: -5%); 66 | color: variables.$default-item-color-selected; 67 | color: variables.$default-text-color; 68 | cursor: default; 69 | border-color: transparent; 70 | } 71 | 72 | &.is-disabled.active:not(.is-parent) > span { 73 | background-color: color.adjust( 74 | variables.$default-item-background-selected, 75 | $lightness: 20% 76 | ); 77 | color: variables.$default-item-color-selected; 78 | } 79 | 80 | &:not(.is-parent) > span { 81 | cursor: pointer; 82 | background-color: variables.$default-item-background; 83 | border: 1px solid 84 | color.adjust(variables.$default-item-background, $lightness: -2%); 85 | } 86 | 87 | &.no-results > span, 88 | &.no-selection > span { 89 | cursor: default; 90 | background-color: variables.$default-item-background; 91 | border-color: transparent; 92 | } 93 | } 94 | 95 | // > li:not(.is-parent) > span { 96 | // 97 | // } 98 | 99 | > li.is-parent > span { 100 | font-weight: bold; 101 | padding-left: 0px; 102 | } 103 | } 104 | 105 | .vss-footer { 106 | align-items: flex-end; 107 | display: flex; 108 | flex: 1 0 auto; 109 | 110 | .vss-footer-bg { 111 | display: flex; 112 | justify-content: space-between; 113 | align-items: center; 114 | padding: 0px 12px; 115 | height: 2rem; 116 | width: 100%; 117 | background-color: variables.$default-footer-background; 118 | border-bottom-left-radius: variables.$border-radius-base; 119 | border-bottom-right-radius: variables.$border-radius-base; 120 | 121 | * { 122 | color: variables.$default-footer-text-color; 123 | font-size: variables.$font-size-base - 0.2; 124 | } 125 | 126 | > div { 127 | display: flex; 128 | 129 | .vss-footer-separator { 130 | margin: 0px 6px; 131 | } 132 | } 133 | 134 | > span { 135 | font-size: variables.$font-size-base - 0.3; 136 | font-weight: bold; 137 | color: variables.$default-footer-text-color; 138 | background-color: color.adjust( 139 | variables.$default-footer-background, 140 | $lightness: 15% 141 | ); 142 | padding: 1px 6px 0px 6px; 143 | border-radius: 20px; 144 | min-width: 14px; 145 | height: 14px; 146 | } 147 | } 148 | } 149 | } 150 | 151 | .vss-total { 152 | font-size: variables.$font-size-base - 0.2rem; 153 | } 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /example/components/grouped-only-list.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 186 | -------------------------------------------------------------------------------- /example/components/grouped-sort-selected-first.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 187 | -------------------------------------------------------------------------------- /example/components/grouped-placeholder-search.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 189 | -------------------------------------------------------------------------------- /src/vue-select-sides.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 192 | -------------------------------------------------------------------------------- /example/components/grouped-with-disabled.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 192 | -------------------------------------------------------------------------------- /docs/vueSelectSidesExample.css: -------------------------------------------------------------------------------- 1 | .ex-label-radio[data-v-2c0073d3]{padding:10px 0 2px;display:flex;align-items:center}.ex-label-radio label[data-v-2c0073d3]{display:flex;align-items:center;font-size:14px;margin-right:15px}.ex-label-radio label input[data-v-2c0073d3]{padding:0;margin:0 5px 0 0}pre[data-v-2c0073d3]{height:322px!important}.ex-label-radio[data-v-fcfcfbd0]{padding:10px 0 2px;display:flex;align-items:center}.ex-label-radio label[data-v-fcfcfbd0]{display:flex;align-items:center;font-size:14px;margin-right:15px}.ex-label-radio label input[data-v-fcfcfbd0]{padding:0;margin:0 5px 0 0}pre[data-v-fcfcfbd0]{height:322px!important}.vss,.vss ul,.vss ul li{list-style-type:none;margin:0}.vss ul ul{padding:0}.vss,.vss *,.vss *:before,.vss *:after{box-sizing:border-box}.vss{display:flex;align-items:stretch;align-content:stretch;justify-content:space-between}.vss a{text-decoration:none}.vss .vss-span,.vss .vss-list-ul li{-webkit-user-select:none;user-select:none}.vss .vss-span{width:15%;display:flex;align-items:center;justify-content:center}.vss .vss-list{width:100%}.vss .vss-list .vss-inner-list{height:100%;display:flex;flex-direction:column}.vss .vss-list .vss-inner-list .vss-list-search{width:100%}.vss .vss-list .vss-inner-list .vss-list-ul{overflow-y:auto}.vss .vss-list .vss-inner-list .vss-list-ul li{line-height:1.5}.vss .vss-list .vss-inner-list .vss-list-ul li .vss-list-badge{display:flex;align-items:center;justify-content:center}.vss *{font-size:.9rem}.vss .vss-span{font-size:1.3rem;color:#e1e1e1}.vss .vss-list .vss-inner-list{box-shadow:0 0 10px #e1e1e1;border-radius:.25rem}.vss .vss-list .vss-inner-list .vss-list-search{border:none;padding:12px 14px;border-bottom:2px solid rgb(242.85,242.85,242.85);border-top-left-radius:.25rem;border-top-right-radius:.25rem;outline:none}.vss .vss-list .vss-inner-list .vss-list-search:focus{border-color:#f57f1e}.vss .vss-list .vss-inner-list .vss-list-ul{padding:8px 10px 10px}.vss .vss-list .vss-inner-list .vss-list-ul li span{display:flex;align-items:center;justify-content:space-between;border-radius:.25rem;color:#242934;padding:5px 12px;margin-top:2px}.vss .vss-list .vss-inner-list .vss-list-ul li span .vss-list-badge{font-size:.5rem;color:#fff;background-color:#24293426;padding:2px 4px 0;border-radius:20px;min-width:14px;height:14px;font-weight:700}.vss .vss-list .vss-inner-list .vss-list-ul li.active:not(.is-parent)>span{background-color:#f57f1e;color:#fff;border-color:transparent}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled:not(.is-parent)>span{background-color:#d4d4d4;color:#fff;color:#242934;cursor:default;border-color:transparent}.vss .vss-list .vss-inner-list .vss-list-ul li.is-disabled.active:not(.is-parent)>span{background-color:#f9b780;color:#fff}.vss .vss-list .vss-inner-list .vss-list-ul li:not(.is-parent)>span{cursor:pointer;background-color:#fafafa;border:1px solid rgb(244.9,244.9,244.9)}.vss .vss-list .vss-inner-list .vss-list-ul li.no-results>span,.vss .vss-list .vss-inner-list .vss-list-ul li.no-selection>span{cursor:default;background-color:#fafafa;border-color:transparent}.vss .vss-list .vss-inner-list .vss-list-ul>li.is-parent>span{font-weight:700;padding-left:0}.vss .vss-list .vss-inner-list .vss-footer{align-items:flex-end;display:flex;flex:1 0 auto}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg{display:flex;justify-content:space-between;align-items:center;padding:0 12px;height:2rem;width:100%;background-color:#242934;border-bottom-left-radius:.25rem;border-bottom-right-radius:.25rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg *{color:#fff;font-size:.7rem}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div{display:flex}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>div .vss-footer-separator{margin:0 6px}.vss .vss-list .vss-inner-list .vss-footer .vss-footer-bg>span{font-size:.6rem;font-weight:700;color:#fff;background-color:#434d61;padding:1px 6px 0;border-radius:20px;min-width:14px;height:14px}.vss .vss-list .vss-total{font-size:.7rem}.vss{height:300px}header{margin:30px 0;width:100%;display:flex;align-items:center;flex-direction:column}header img{max-height:120px;margin-bottom:10px}header h2,header h3{margin:5px}header a{color:#f57f1e;text-decoration:none}.the-body{width:80%;margin:0 auto;display:flex}.the-body.affixed nav{margin-top:0;position:fixed;top:30px}.the-body.affixed section{margin-left:15rem}.the-body nav{margin-top:30px;background-color:#fafafa;border-radius:.25rem;width:15rem;height:calc(100vh - 60px);overflow-y:auto}.the-body nav>div{padding:25px 30px}.the-body nav ul{list-style:none;margin:0 0 15px;padding:0}.the-body nav ul li{line-height:1.5}.the-body nav ul li a{text-decoration:none;color:#242934}.the-body nav ul li a:hover{color:#f57f1e}.the-body nav ul>ul{padding-left:15px}.the-body section{padding-left:2rem;width:74%}.ex{font-family:Avenir,Helvetica,Arial,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.ex hr{border-style:solid;border-color:transparent;border-top-color:#f1f1f1;margin:35px 0 0}.ex .ex-section{padding-top:30px}.ex .ex-section .ex-section-title{display:flex;align-items:center;justify-content:space-between}.ex .ex-section .ex-section-title a{text-decoration:none;background-color:#f57f1e;padding:10px;border-radius:.25rem;color:#fff;font-size:.9rem}.ex .ex-section .ex-lib{display:flex}.ex .ex-section .ex-lib .vss{width:65%}.ex .ex-section .ex-lib .ex-result{margin-left:2.5rem;width:35%}.ex .ex-section .ex-lib .ex-result h4{margin:0}.ex .ex-section .ex-lib .ex-result pre{height:250px;overflow-y:auto;background:#f1f1f1;padding:10px;border-radius:5px} 2 | -------------------------------------------------------------------------------- /example/components/grouped-sort-list.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 195 | 196 | 219 | -------------------------------------------------------------------------------- /src/modules/mirror.vue: -------------------------------------------------------------------------------- 1 | 65 | 66 | 241 | -------------------------------------------------------------------------------- /src/modules/grouped.vue: -------------------------------------------------------------------------------- 1 | 77 | 78 | 359 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Vue Select Sides 3 |

4 |

Vue Select Sides

5 | 6 |

7 | 8 | npm 9 | 10 | 11 | npm 12 | 13 | 14 | npm 15 | 16 |

17 | 18 | A component for Vue.js to select double-sided data. The customer can select one or more items and ship them from side to side. Values can be displayed grouped or ungrouped. 19 | 20 | **From version v2.0.0 it is only compatible with Vue 3.** 21 | For Vue 2, see version v1.1.6. 22 | 23 |

24 | Demo - Vue Select Sides 25 |

26 | 27 | ## [Live DEMO](https://soft4ti.github.io/vue-select-sides/index.html?v=20201113113945) 28 | 29 | ## Installation 30 | 31 | First install it using: 32 | 33 | ```bash 34 | npm install --save vue-select-sides 35 | ``` 36 | 37 | or 38 | 39 | ```bash 40 | yarn add vue-select-sides 41 | ``` 42 | 43 | ## Usage with Vue 3 44 | 45 | ### Component Registration 46 | 47 | **Local component:** 48 | 49 | ```js 50 | 53 | 54 | 61 | ``` 62 | 63 | Or using Options API: 64 | 65 | ```js 66 | import vueSelectSides from "vue-select-sides"; 67 | 68 | export default { 69 | components: { 70 | vueSelectSides, 71 | }, 72 | }; 73 | ``` 74 | 75 | **Global component:** 76 | 77 | ```javascript 78 | // main.js 79 | import { createApp } from "vue"; 80 | import App from "./App.vue"; 81 | import vueSelectSides from "vue-select-sides"; 82 | 83 | const app = createApp(App); 84 | 85 | // Optional: Set global locale 86 | app.use(vueSelectSides, { 87 | locale: "en_US", // Default locale 88 | }); 89 | 90 | app.component("vue-select-sides", vueSelectSides); 91 | app.mount("#app"); 92 | ``` 93 | 94 | **Script tag (UMD):** 95 | 96 | ```html 97 | 98 | ``` 99 | 100 | ### Import a Theme 101 | 102 | You have three pre-built themes available: 103 | 104 | **Using SCSS (recommended):** 105 | 106 | ```scss 107 | // Soft theme (default - orange accent) 108 | @use "vue-select-sides/styles/themes/soft.scss" as *; 109 | 110 | // Dark theme 111 | @use "vue-select-sides/styles/themes/dark.scss" as *; 112 | 113 | // Light theme 114 | @use "vue-select-sides/styles/themes/light.scss" as *; 115 | ``` 116 | 117 | **Customizing the Soft theme:** 118 | 119 | ```scss 120 | // Override default variables 121 | @use "vue-select-sides/styles/themes/soft.scss" with ( 122 | $selected-color: #ff0000, 123 | $default-item-background: #f0f0f0, 124 | $border-radius-base: 0.5rem 125 | ) as *; 126 | ``` 127 | 128 | **Using CSS (pre-compiled):** 129 | 130 | ```js 131 | // In your main.js or component 132 | import "vue-select-sides/dist/css/soft.css"; 133 | // or 134 | import "vue-select-sides/dist/css/dark.css"; 135 | // or 136 | import "vue-select-sides/dist/css/light.css"; 137 | ``` 138 | 139 | ## Component Types 140 | 141 | The component has support for two types: `mirror` and `grouped`. 142 | 143 | ### Grouped 144 | 145 | Warning: `v-model` must be of type `Object` 146 | 147 | ```js 148 | 155 | 156 | 198 | ``` 199 | 200 | ### Mirror 201 | 202 | Warning: `v-model` must be of type `Array` 203 | 204 | ```js 205 | 212 | 213 | 239 | ``` 240 | 241 | ## Language/Locales 242 | 243 | List of locales available for the plugin: 244 | 245 | - `en_US` - [English] - Default 246 | - `pt_BR` - [Portuguese] - Contributed by @juliorosseti 247 | - `es_ES` - [Spanish] - Contributed by @etrepat 248 | - `fr_FR` - [French] - Contributed by @MajuTo 249 | - `tr_TR` - [Turkish] - Contributed by @Abdulsametileri 250 | - `pl_PL` - [Polish] - Contributed by @jzapal 251 | - `cz_CZ` - [Czech] - Contributed by @DuchVladimir 252 | - `sk_SK` - [Slovak] - Contributed by @DuchVladimir 253 | 254 | ### Set Global Locale 255 | 256 | ```javascript 257 | // main.js 258 | import { createApp } from "vue"; 259 | import vueSelectSides from "vue-select-sides"; 260 | 261 | const app = createApp(App); 262 | 263 | app.use(vueSelectSides, { 264 | locale: "pt_BR", 265 | }); 266 | 267 | app.component("vue-select-sides", vueSelectSides); 268 | ``` 269 | 270 | ## Props 271 | 272 | These are all the props you can pass to the component: 273 | 274 | | name | type | example | notes | 275 | | ----------------------------- | ------------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | 276 | | v-model | `Array` or `Object` | `["xyz"]` or `{xyz: ["abc", "def"]}` | Use **Object** if type is grouped else uses **Array** | 277 | | type | `String` | `grouped` or `mirror` | | 278 | | list | `Array` | `[{ value: "xyz", label: "Label xyz 01", disabled: true/false }]` | You can add the `children` key to type `grouped` | 279 | | search | `Boolean` | `true` or `false` | To show/hide search input. Default is visible (true) | 280 | | total | `Boolean` | `true` or `false` | To show/hide total selected in footer. Default is visible (true) | 281 | | toggle-all | `Boolean` | `true` or `false` | To show/hide toggle in footer. Default is visible (true) | 282 | | sort-selected-up | `Boolean` | `true` or `false` | Show first the pre-selected. Default does not visible (false). _Available only grouped type_ | 283 | | order-by | `String` | `asc` or `desc` | Show first the pre-selected. Default is natural order | 284 | | ~~lang~~ (deprecated in v1.1) | ~~`String`~~ | ~~`en_US`, `pt_BR`, `es_ES` or `fr_FR`~~ | ~~Language default. Default is en_US~~
Use `Set Global Locale` | 285 | | placeholder-search-left | `String` | "Yay! Search items..." | Placeholder on the left search field. Default is "" | 286 | | placeholder-search-right | `String` | "Or search children items..." | Placeholder on the right search field. Default is "" | 287 | 288 | ## Available SCSS Variables for Customization 289 | 290 | When using `@use` with the soft theme, you can override these variables: 291 | 292 | ```scss 293 | $font-size-base: 0.9rem; 294 | $border-radius-base: 0.25rem; 295 | $selected-color: #f57f1e; 296 | $white: #fff; 297 | $gray: #e1e1e1; 298 | $dark: #242934; 299 | $default-item-background: #fafafa; 300 | $default-item-color-selected: $white; 301 | $default-item-background-selected: $selected-color; 302 | $default-text-color: $dark; 303 | $default-footer-text-color: $white; 304 | $default-footer-background: $dark; 305 | $badge-background: rgba($dark, 0.15); 306 | ``` 307 | 308 | Example: 309 | 310 | ```scss 311 | @use "vue-select-sides/styles/themes/soft.scss" with ( 312 | $selected-color: #3498db, 313 | $border-radius-base: 8px, 314 | $font-size-base: 1rem 315 | ) as *; 316 | ``` 317 | 318 | ## Bugs and Feature Requests 319 | 320 | If your problem or idea is not addressed yet, please open a new issue. 321 | 322 | ## Sponsor / Creator 323 | 324 | 325 | Softdesk - Sponsor 326 | 327 | 328 | ## Contribution / Development 329 | 330 | ### Install Dependencies 331 | 332 | ```bash 333 | yarn install 334 | ``` 335 | 336 | ### Dev Server 337 | 338 | ```bash 339 | yarn run serve 340 | ``` 341 | 342 | ### Build 343 | 344 | ```bash 345 | yarn run build 346 | ``` 347 | 348 | ### Run Tests 349 | 350 | ```bash 351 | yarn test 352 | ``` 353 | 354 | ## Donate 355 | 356 | You can help with a donation on Paypal 357 | 358 | ## License 359 | 360 | Vue select sides is open-sourced software licensed under the MIT license. 361 | -------------------------------------------------------------------------------- /example/App.vue: -------------------------------------------------------------------------------- 1 | 222 | 223 | 281 | 282 | 428 | -------------------------------------------------------------------------------- /tests/sort-by.test.js: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import sortBy from "../src/sort-by"; // ajuste caso o path real seja outro 3 | 4 | describe("Sort()", () => { 5 | it("returns a function", () => { 6 | expect(typeof sortBy()).toBe("function"); 7 | }); 8 | }); 9 | 10 | describe("Sort(prop)", () => { 11 | it("sorts an array of objects using given property name", () => { 12 | const array = [ 13 | { x: 4, y: 1, z: { a: 2 } }, 14 | { x: 3, y: 1, z: { a: 3 } }, 15 | { x: 2, y: 3, z: { a: 3 } }, 16 | { x: 1, y: 2, z: { a: 1 } }, 17 | ]; 18 | 19 | array.sort(sortBy("x")); 20 | 21 | expect(array[0]).toEqual({ x: 1, y: 2, z: { a: 1 } }); 22 | expect(array[3]).toEqual({ x: 4, y: 1, z: { a: 2 } }); 23 | }); 24 | 25 | it("sorts case-insensitively by applying a function", () => { 26 | const array = [ 27 | { name: "Hummingbird" }, 28 | { name: "swallow" }, 29 | { name: "Finch" }, 30 | { name: "Sparrow" }, 31 | { name: "cuckoos" }, 32 | ]; 33 | 34 | array.sort(sortBy("name")); 35 | 36 | expect(array.map((a) => a.name)).toEqual([ 37 | "Finch", 38 | "Hummingbird", 39 | "Sparrow", 40 | "cuckoos", 41 | "swallow", 42 | ]); 43 | 44 | array.sort( 45 | sortBy("name", (key, value) => { 46 | return key === "name" ? value.toLowerCase() : value; 47 | }) 48 | ); 49 | 50 | expect(array.map((a) => a.name)).toEqual([ 51 | "cuckoos", 52 | "Finch", 53 | "Hummingbird", 54 | "Sparrow", 55 | "swallow", 56 | ]); 57 | }); 58 | }); 59 | 60 | describe("Sort(prop, prop)", () => { 61 | it("sorts using multiple properties", () => { 62 | const array = [ 63 | { x: 4, y: 1 }, 64 | { x: 3, y: 1 }, 65 | { x: 2, y: 3 }, 66 | { x: 1, y: 2 }, 67 | ]; 68 | 69 | array.sort(sortBy("y", "x")); 70 | 71 | expect(array[0]).toEqual({ x: 3, y: 1 }); 72 | expect(array[3]).toEqual({ x: 2, y: 3 }); 73 | }); 74 | }); 75 | 76 | describe("Sort(-prop)", () => { 77 | it("reverses sort order", () => { 78 | const array = [ 79 | { x: 4, y: 1 }, 80 | { x: 3, y: 1 }, 81 | { x: 2, y: 3 }, 82 | { x: 1, y: 2 }, 83 | ]; 84 | 85 | array.sort(sortBy("-y", "x")); 86 | 87 | expect(array[0]).toEqual({ x: 2, y: 3 }); 88 | expect(array[3]).toEqual({ x: 4, y: 1 }); 89 | }); 90 | }); 91 | 92 | describe("Sort(prop.prop)", () => { 93 | it("sorts nested properties", () => { 94 | const array = [ 95 | { x: 4, y: 1, z: { a: 2 } }, 96 | { x: 3, y: 1, z: { a: 3 } }, 97 | { x: 2, y: 3, z: { a: 3 } }, 98 | { x: 1, y: 2, z: { a: 1 } }, 99 | ]; 100 | 101 | array.sort(sortBy("z.a", "y")); 102 | 103 | expect(array[0]).toEqual({ x: 1, y: 2, z: { a: 1 } }); 104 | expect(array[3]).toEqual({ x: 2, y: 3, z: { a: 3 } }); 105 | }); 106 | }); 107 | 108 | describe("Sort(-prop.prop)", () => { 109 | it("reverse sorts nested properties", () => { 110 | const array = [ 111 | { x: 4, y: 1, z: { a: 2 } }, 112 | { x: 3, y: 1, z: { a: 3 } }, 113 | { x: 2, y: 3, z: { a: 3 } }, 114 | { x: 1, y: 2, z: { a: 1 } }, 115 | ]; 116 | 117 | array.sort(sortBy("-z.a", "y")); 118 | 119 | expect(array[0]).toEqual({ x: 3, y: 1, z: { a: 3 } }); 120 | expect(array[3]).toEqual({ x: 1, y: 2, z: { a: 1 } }); 121 | }); 122 | }); 123 | 124 | describe("Edge Cases", () => { 125 | it("handles empty arrays", () => { 126 | const array = []; 127 | array.sort(sortBy("x")); 128 | expect(array).toEqual([]); 129 | }); 130 | 131 | it("handles arrays with one element", () => { 132 | const array = [{ x: 1 }]; 133 | array.sort(sortBy("x")); 134 | expect(array).toEqual([{ x: 1 }]); 135 | }); 136 | 137 | it("handles objects with all equal values", () => { 138 | const array = [ 139 | { x: 1, id: "a" }, 140 | { x: 1, id: "b" }, 141 | { x: 1, id: "c" }, 142 | ]; 143 | 144 | array.sort(sortBy("x")); 145 | 146 | // Deve manter a ordem original (stable sort) 147 | expect(array[0].id).toBe("a"); 148 | expect(array[1].id).toBe("b"); 149 | expect(array[2].id).toBe("c"); 150 | }); 151 | }); 152 | 153 | describe("Undefined and Null Values", () => { 154 | it("handles undefined values in properties", () => { 155 | const array = [ 156 | { x: 3, y: 2 }, 157 | { x: 1 }, // y é undefined 158 | { x: 2, y: 1 }, 159 | { x: 4, y: 3 }, 160 | ]; 161 | 162 | array.sort(sortBy("y", "x")); 163 | 164 | // undefined deve ser tratado como "maior" e ir pro final 165 | expect(array[0]).toEqual({ x: 2, y: 1 }); 166 | expect(array[1]).toEqual({ x: 3, y: 2 }); 167 | expect(array[2]).toEqual({ x: 4, y: 3 }); 168 | expect(array[3]).toEqual({ x: 1 }); // undefined vai pro final 169 | }); 170 | 171 | it("handles null values in properties", () => { 172 | const array = [ 173 | { x: 3, y: 2 }, 174 | { x: 1, y: null }, 175 | { x: 2, y: 1 }, 176 | { x: 4, y: 3 }, 177 | ]; 178 | 179 | array.sort(sortBy("y", "x")); 180 | 181 | // null deve ser tratado como menor que qualquer número 182 | expect(array[0]).toEqual({ x: 1, y: null }); 183 | expect(array[1]).toEqual({ x: 2, y: 1 }); 184 | expect(array[2]).toEqual({ x: 3, y: 2 }); 185 | expect(array[3]).toEqual({ x: 4, y: 3 }); 186 | }); 187 | 188 | it("handles mix of null, undefined and values", () => { 189 | const array = [{ x: 3 }, { x: 1, y: null }, { x: 2, y: 5 }, { x: 4 }]; 190 | 191 | array.sort(sortBy("y", "x")); 192 | 193 | expect(array[0]).toEqual({ x: 1, y: null }); // null primeiro 194 | expect(array[1]).toEqual({ x: 2, y: 5 }); // valor real 195 | // undefined vai pro final 196 | expect(array[2].x).toBe(3); 197 | expect(array[3].x).toBe(4); 198 | }); 199 | }); 200 | 201 | describe("Mixed Data Types", () => { 202 | it("handles mix of numbers and strings", () => { 203 | const array = [{ x: "10" }, { x: 2 }, { x: "3" }, { x: 1 }]; 204 | 205 | array.sort(sortBy("x")); 206 | 207 | // Comparação lexicográfica: 1 < 2 < "10" < "3" 208 | expect(array[0]).toEqual({ x: 1 }); 209 | expect(array[1]).toEqual({ x: 2 }); 210 | expect(array[2]).toEqual({ x: "10" }); 211 | expect(array[3]).toEqual({ x: "3" }); 212 | }); 213 | 214 | it("handles boolean values", () => { 215 | const array = [ 216 | { x: true, name: "A" }, 217 | { x: false, name: "B" }, 218 | { x: true, name: "C" }, 219 | { x: false, name: "D" }, 220 | ]; 221 | 222 | array.sort(sortBy("x", "name")); 223 | 224 | expect(array[0].name).toBe("B"); // false < true 225 | expect(array[1].name).toBe("D"); 226 | expect(array[2].name).toBe("A"); 227 | expect(array[3].name).toBe("C"); 228 | }); 229 | 230 | it("handles Date objects", () => { 231 | const array = [ 232 | { date: new Date("2024-03-15") }, 233 | { date: new Date("2024-01-10") }, 234 | { date: new Date("2024-12-20") }, 235 | ]; 236 | 237 | array.sort(sortBy("date")); 238 | 239 | expect(array[0].date.getMonth()).toBe(0); // Janeiro 240 | expect(array[1].date.getMonth()).toBe(2); // Março 241 | expect(array[2].date.getMonth()).toBe(11); // Dezembro 242 | }); 243 | }); 244 | 245 | describe("Deep Nested Properties", () => { 246 | it("sorts deeply nested properties (3+ levels)", () => { 247 | const array = [ 248 | { a: { b: { c: { d: 4 } } } }, 249 | { a: { b: { c: { d: 1 } } } }, 250 | { a: { b: { c: { d: 3 } } } }, 251 | { a: { b: { c: { d: 2 } } } }, 252 | ]; 253 | 254 | array.sort(sortBy("a.b.c.d")); 255 | 256 | expect(array[0].a.b.c.d).toBe(1); 257 | expect(array[3].a.b.c.d).toBe(4); 258 | }); 259 | 260 | it("handles missing intermediate nested properties", () => { 261 | const array = [ 262 | { a: { b: { c: 3 } } }, 263 | { a: { b: null } }, // b existe mas é null 264 | { a: {} }, // b não existe 265 | { a: { b: { c: 1 } } }, 266 | ]; 267 | 268 | array.sort(sortBy("a.b.c")); 269 | 270 | // Valores válidos devem vir primeiro 271 | expect(array[0].a.b.c).toBe(1); 272 | expect(array[1].a.b.c).toBe(3); 273 | }); 274 | }); 275 | 276 | describe("Multiple Properties with Mixed Order", () => { 277 | it("sorts with alternating asc/desc orders", () => { 278 | const array = [ 279 | { x: 1, y: 1, z: 1 }, 280 | { x: 2, y: 1, z: 2 }, 281 | { x: 1, y: 2, z: 1 }, 282 | { x: 2, y: 2, z: 2 }, 283 | ]; 284 | 285 | array.sort(sortBy("-x", "y", "-z")); 286 | 287 | expect(array[0]).toEqual({ x: 2, y: 1, z: 2 }); // x=2(desc), y=1(asc), z=2(desc) 288 | expect(array[1]).toEqual({ x: 2, y: 2, z: 2 }); // x=2(desc), y=2(asc), z=2(desc) 289 | expect(array[2]).toEqual({ x: 1, y: 1, z: 1 }); 290 | expect(array[3]).toEqual({ x: 1, y: 2, z: 1 }); 291 | }); 292 | }); 293 | 294 | describe("Map Function Advanced Cases", () => { 295 | it("applies map function to multiple properties", () => { 296 | const array = [ 297 | { first: "John", last: "DOE" }, 298 | { first: "jane", last: "Smith" }, 299 | { first: "Bob", last: "anderson" }, 300 | ]; 301 | 302 | array.sort( 303 | sortBy("last", "first", (key, value) => { 304 | return value.toLowerCase(); 305 | }) 306 | ); 307 | 308 | expect(array[0].last).toBe("anderson"); 309 | expect(array[1].last).toBe("DOE"); 310 | expect(array[2].last).toBe("Smith"); 311 | }); 312 | 313 | it("map function with nested properties", () => { 314 | const array = [ 315 | { user: { name: "CHARLIE" } }, 316 | { user: { name: "alice" } }, 317 | { user: { name: "Bob" } }, 318 | ]; 319 | 320 | array.sort( 321 | sortBy("user.name", (key, value) => { 322 | return key === "user.name" ? value.toLowerCase() : value; 323 | }) 324 | ); 325 | 326 | expect(array[0].user.name).toBe("alice"); 327 | expect(array[1].user.name).toBe("Bob"); 328 | expect(array[2].user.name).toBe("CHARLIE"); 329 | }); 330 | 331 | it("map function with numeric string conversion", () => { 332 | const array = [{ version: "10.2" }, { version: "2.1" }, { version: "1.9" }]; 333 | 334 | // Ordena como strings (lexicográfico) 335 | const copy1 = [...array]; 336 | copy1.sort(sortBy("version")); 337 | expect(copy1[0].version).toBe("1.9"); 338 | expect(copy1[1].version).toBe("10.2"); 339 | expect(copy1[2].version).toBe("2.1"); 340 | 341 | // Ordena como números (com map function) 342 | const copy2 = [...array]; 343 | copy2.sort( 344 | sortBy("version", (key, value) => { 345 | return parseFloat(value); 346 | }) 347 | ); 348 | expect(copy2[0].version).toBe("1.9"); 349 | expect(copy2[1].version).toBe("2.1"); 350 | expect(copy2[2].version).toBe("10.2"); 351 | }); 352 | }); 353 | 354 | describe("Special Characters and Unicode", () => { 355 | it("sorts strings with accents and special characters", () => { 356 | const array = [ 357 | { name: "Östberg" }, 358 | { name: "Ødegård" }, 359 | { name: "Öberg" }, 360 | { name: "Olsen" }, 361 | ]; 362 | 363 | array.sort(sortBy("name")); 364 | 365 | // Ordem Unicode/lexicográfica 366 | expect(array[0].name).toBe("Olsen"); 367 | // Os outros dependem da implementação Unicode 368 | }); 369 | 370 | it("sorts with emojis", () => { 371 | const array = [{ icon: "🦊" }, { icon: "🐱" }, { icon: "🐶" }]; 372 | 373 | // Não deve dar erro 374 | expect(() => { 375 | array.sort(sortBy("icon")); 376 | }).not.toThrow(); 377 | }); 378 | }); 379 | 380 | describe("Performance and Stability", () => { 381 | it("handles large arrays efficiently", () => { 382 | const array = Array.from({ length: 1000 }, (_, i) => ({ 383 | x: Math.floor(Math.random() * 100), 384 | y: i, 385 | })); 386 | 387 | const start = Date.now(); 388 | array.sort(sortBy("x", "y")); 389 | const duration = Date.now() - start; 390 | 391 | // Deve completar em tempo razoável (< 100ms) 392 | expect(duration).toBeLessThan(100); 393 | 394 | // Verifica que está ordenado 395 | for (let i = 1; i < array.length; i++) { 396 | expect(array[i].x).toBeGreaterThanOrEqual(array[i - 1].x); 397 | } 398 | }); 399 | 400 | it("maintains stable sort for equal values", () => { 401 | const array = [ 402 | { x: 1, id: 0 }, 403 | { x: 1, id: 1 }, 404 | { x: 1, id: 2 }, 405 | { x: 1, id: 3 }, 406 | { x: 1, id: 4 }, 407 | ]; 408 | 409 | array.sort(sortBy("x")); 410 | 411 | // JavaScript Array.sort é stable desde ES2019 412 | expect(array.map((item) => item.id)).toEqual([0, 1, 2, 3, 4]); 413 | }); 414 | }); 415 | 416 | describe("Edge Cases with sortBy() without arguments", () => { 417 | it("returns a valid comparator even without arguments", () => { 418 | const array = [3, 1, 2]; 419 | const comparator = sortBy(); 420 | 421 | expect(typeof comparator).toBe("function"); 422 | 423 | // Deve retornar 0 sempre (todos iguais) 424 | expect(comparator({ x: 1 }, { x: 2 })).toBe(0); 425 | }); 426 | }); 427 | -------------------------------------------------------------------------------- /example/components/mirror-basic.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 1056 | --------------------------------------------------------------------------------