├── .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 |
2 |
3 | ‹ ›
4 |
5 |
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 |
2 |
3 | {{ $t("searchNoResult") }}
4 |
5 |
6 |
7 |
14 |
--------------------------------------------------------------------------------
/src/components/noSelection.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ $t("searchParentSelected") }}
4 |
5 |
6 |
7 |
14 |
--------------------------------------------------------------------------------
/src/components/total.vue:
--------------------------------------------------------------------------------
1 |
2 | {{ value }}
3 |
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 |
2 |
3 | {{ $t("selectAll") }}
4 |
5 |
6 |
7 |
17 |
--------------------------------------------------------------------------------
/src/components/deselectAll.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | {{ $t("deselectAll") }}
4 |
5 |
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 |
2 |
3 |
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 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
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 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
14 |
15 |
48 |
--------------------------------------------------------------------------------
/example/components/mirror-placeholder-search.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
Result
12 |
{{ selected }}
13 |
14 |
15 |
16 |
17 |
48 |
--------------------------------------------------------------------------------
/example/components/mirror-only-list.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
12 |
Result
13 |
{{ selected }}
14 |
15 |
16 |
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 |
2 |
24 |
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 |
2 |
3 |
10 |
11 | {{ item.label }}
12 |
13 | {{ item.totalChildrenSelected }}
14 |
15 |
16 |
17 |
18 |
25 |
26 | {{ children.label }}
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
154 |
--------------------------------------------------------------------------------
/example/components/grouped-basic.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
14 |
15 |
180 |
--------------------------------------------------------------------------------
/example/components/grouped-with-selecteds.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
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 |
2 |
3 |
11 |
12 |
Result
13 |
{{ selected }}
14 |
15 |
16 |
17 |
18 |
186 |
--------------------------------------------------------------------------------
/example/components/grouped-sort-selected-first.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
Result
12 |
{{ selected }}
13 |
14 |
15 |
16 |
17 |
187 |
--------------------------------------------------------------------------------
/example/components/grouped-placeholder-search.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
10 |
11 |
Result
12 |
{{ selected }}
13 |
14 |
15 |
16 |
17 |
189 |
--------------------------------------------------------------------------------
/src/vue-select-sides.vue:
--------------------------------------------------------------------------------
1 |
2 |
17 |
18 |
19 |
192 |
--------------------------------------------------------------------------------
/example/components/grouped-with-disabled.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
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 |
2 |
24 |
25 |
26 |
195 |
196 |
219 |
--------------------------------------------------------------------------------
/src/modules/mirror.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
19 |
31 |
32 |
33 |
34 |
35 |
36 |
42 |
50 |
61 |
62 |
63 |
64 |
65 |
66 |
241 |
--------------------------------------------------------------------------------
/src/modules/grouped.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
11 |
17 |
38 |
39 |
40 |
41 |
42 |
43 |
49 |
55 |
73 |
74 |
75 |
76 |
77 |
78 |
359 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Vue Select Sides
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
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 |
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 |
55 |
60 |
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 |
149 |
154 |
155 |
156 |
198 | ```
199 |
200 | ### Mirror
201 |
202 | Warning: `v-model` must be of type `Array`
203 |
204 | ```js
205 |
206 |
211 |
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 |
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 |
2 |
3 |
4 |
5 |
6 |
7 | Vue select sides
8 | Examples
9 |
10 | {{ repo }}
11 |
12 |
13 |
14 |
15 |
16 |
55 |
56 |
57 |
66 |
67 |
79 |
80 |
81 |
90 |
91 |
92 |
101 |
102 |
103 |
115 |
116 |
117 |
129 |
130 |
131 |
140 |
141 |
142 |
154 |
155 |
156 |
165 |
166 |
167 |
176 |
177 |
178 |
190 |
191 |
192 |
204 |
205 |
206 |
218 |
219 |
220 |
221 |
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 |
2 |
3 |
8 |
9 |
Result
10 |
{{ selected }}
11 |
12 |
13 |
14 |
15 |
1056 |
--------------------------------------------------------------------------------