├── assets ├── vdt-1.png ├── vdt-2.png └── vdt-3.png ├── src ├── components │ ├── EntriesInfo │ │ ├── EntriesInfo.scss │ │ ├── EntriesInfo.ts │ │ └── EntriesInfo.vue │ ├── Table │ │ ├── TableCellSelectable.scss │ │ ├── TableCell.vue │ │ ├── TableCell.ts │ │ ├── TableCellSelectable.vue │ │ ├── TableCellEditable.scss │ │ ├── TableCellSelectable.ts │ │ ├── Table.ts │ │ ├── TableCellEditable.ts │ │ ├── Table.scss │ │ ├── TableCellEditable.vue │ │ └── Table.vue │ ├── ActionButtons │ │ ├── ActionButtons.scss │ │ ├── ActionButtons.vue │ │ └── ActionButtons.ts │ ├── SearchFilter │ │ ├── SearchFilter.scss │ │ ├── SearchFilter.ts │ │ └── SearchFilter.vue │ ├── SortableColumn │ │ ├── SortingIndex.scss │ │ ├── SortingIcon.vue │ │ ├── SortingIndex.vue │ │ └── SortingIcon.scss │ ├── ExportData │ │ ├── ExportData.scss │ │ ├── ExportData.vue │ │ └── ExportData.ts │ ├── PerPage │ │ ├── PerPage.scss │ │ ├── PerPage.vue │ │ └── PerPage.ts │ ├── DataTable.vue │ ├── Pagination │ │ ├── Pagination.ts │ │ ├── Pagination.scss │ │ └── Pagination.vue │ ├── DataTable.scss │ └── DataTable.ts ├── shims-vue.d.ts ├── demo │ ├── CellList.vue │ ├── CellImage.vue │ ├── App.css │ ├── App.vue │ └── App.ts ├── const.ts ├── lang │ ├── en.ts │ ├── pt-br.ts │ └── es.ts ├── lang.ts ├── dev.ts ├── main.ts ├── types.d.ts ├── parser │ └── index.ts └── utils.ts ├── .npmignore ├── .editorconfig ├── .prettierignore ├── tests ├── render.test.ts ├── lang.test.ts ├── utils.test.ts ├── editableCell.test.ts ├── customColumn.test.ts ├── customComponent.test.ts ├── filter.test.ts ├── parser.test.ts ├── common.ts ├── sorting.test.ts └── pagination.test.ts ├── .github ├── dependabot.yml └── workflows │ ├── tests.yml │ └── demo.yml ├── vite.config.dev.ts ├── tsconfig.node.json ├── .gitignore ├── index.html ├── .prettierrc ├── vite.config.ts ├── tsconfig.json ├── LICENSE ├── eslint.config.js ├── package.json ├── CHANGELOG.md └── README.md /assets/vdt-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/vue-data-table/HEAD/assets/vdt-1.png -------------------------------------------------------------------------------- /assets/vdt-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/vue-data-table/HEAD/assets/vdt-2.png -------------------------------------------------------------------------------- /assets/vdt-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/uwla/vue-data-table/HEAD/assets/vdt-3.png -------------------------------------------------------------------------------- /src/components/EntriesInfo/EntriesInfo.scss: -------------------------------------------------------------------------------- 1 | .vdt-info { 2 | grid-area: info; 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | src/ 3 | tests/ 4 | .git* 5 | .editorconfig 6 | vite.config.ts 7 | tsconfig.* -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | root = true 3 | insert_final_newline = false 4 | indent_size = 4 5 | indent_style = space -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ 3 | build/ 4 | assets/ 5 | index.html 6 | .github/ 7 | *.md 8 | *.min.js 9 | -------------------------------------------------------------------------------- /src/components/Table/TableCellSelectable.scss: -------------------------------------------------------------------------------- 1 | .vdt-cell-selectable { 2 | width: 1.5em; 3 | height: 1.5em; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/ActionButtons/ActionButtons.scss: -------------------------------------------------------------------------------- 1 | .vdt-action-buttons { 2 | display: flex; 3 | justify-content: center; 4 | grid-gap: 0.3em; 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Table/TableCell.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /src/components/SearchFilter/SearchFilter.scss: -------------------------------------------------------------------------------- 1 | .vdt-search { 2 | grid-area: search; 3 | display: flex; 4 | align-items: center; 5 | gap: 0.5em; 6 | margin-top: 0; 7 | } 8 | -------------------------------------------------------------------------------- /src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.vue" { 2 | import type { DefineComponent } from "vue" 3 | const component: DefineComponent<{}, {}, any> 4 | export default component 5 | } 6 | -------------------------------------------------------------------------------- /tests/render.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest" 2 | import { testRowsMatchData, data } from "./common" 3 | 4 | test("it shows the correct data on the table", async () => { 5 | testRowsMatchData(data) 6 | }) 7 | -------------------------------------------------------------------------------- /src/components/EntriesInfo/EntriesInfo.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtEntriesInfo", 5 | props: { entriesInfoText: { type: String, required: true } }, 6 | }) 7 | -------------------------------------------------------------------------------- /src/components/SearchFilter/SearchFilter.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtSearchFilter", 5 | props: { searchText: String, search: String }, 6 | emits: ["set-search"], 7 | }) 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | commit-message: 8 | prefix: "chore(deps)" 9 | prefix-development: "chore(deps-dev)" 10 | -------------------------------------------------------------------------------- /src/components/EntriesInfo/EntriesInfo.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /vite.config.dev.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import vue from "@vitejs/plugin-vue" 3 | 4 | const basePath = process.env.APP_BASE ?? "/" 5 | 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | base: basePath, 9 | build: { outDir: "demo" }, 10 | }) 11 | -------------------------------------------------------------------------------- /src/components/Table/TableCell.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtTableCell", 5 | props: { 6 | columnKey: { type: String, required: true }, 7 | data: { type: Object, required: true }, 8 | }, 9 | }) 10 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/components/SortableColumn/SortingIndex.scss: -------------------------------------------------------------------------------- 1 | .vdt-sorting-index { 2 | margin: 0 0 0 5px; 3 | font-size: 80%; 4 | border: 1px solid #ccc; 5 | border-radius: 50%; 6 | height: 1em; 7 | width: 1em; 8 | display: inline-flex; 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | -------------------------------------------------------------------------------- /src/demo/CellList.vue: -------------------------------------------------------------------------------- 1 | 11 | 14 | -------------------------------------------------------------------------------- /src/components/ExportData/ExportData.scss: -------------------------------------------------------------------------------- 1 | .vdt-export { 2 | display: flex; 3 | align-items: center; 4 | padding-left: 4px; 5 | grid-area: download; 6 | 7 | select { 8 | margin: 0 6px; 9 | max-width: 70px; 10 | } 11 | 12 | span { 13 | white-space: nowrap; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/demo/CellImage.vue: -------------------------------------------------------------------------------- 1 | 8 | 11 | 17 | -------------------------------------------------------------------------------- /src/components/Table/TableCellSelectable.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | import type { ColumnType, SortingMode } from "./types" 2 | 3 | export const SORTING_MODE = { ASC: "asc", DESC: "desc", NONE: "none" } as { 4 | [key: string]: SortingMode 5 | } 6 | 7 | export const COLUMN_TYPE = { 8 | NUMERIC: "numeric", 9 | STRING: "string", 10 | ARRAY: "array", 11 | OTHER: "other", 12 | } as { [key: string]: ColumnType } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /demo 5 | 6 | # backup files 7 | *.bak 8 | 9 | # local env files 10 | .env.local 11 | .env.*.local 12 | 13 | # Log files 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | pnpm-debug.log* 18 | 19 | # Editor directories and files 20 | .idea 21 | .vscode 22 | .vim 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw 28 | tags -------------------------------------------------------------------------------- /src/components/SortableColumn/SortingIcon.vue: -------------------------------------------------------------------------------- 1 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vue Data Table Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/SortableColumn/SortingIndex.vue: -------------------------------------------------------------------------------- 1 | 6 | 14 | 15 | -------------------------------------------------------------------------------- /src/components/SearchFilter/SearchFilter.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/components/Table/TableCellEditable.scss: -------------------------------------------------------------------------------- 1 | .vdt-cell-editable { 2 | .view-cell, 3 | .edit-cell { 4 | display: flex; 5 | gap: 0.3em; 6 | flex-wrap: nowrap; 7 | justify-content: space-between; 8 | } 9 | .view-cell { 10 | align-items: flex-start; 11 | } 12 | .edit-cell { 13 | align-items: stretch; 14 | } 15 | } 16 | 17 | .vdt-cell-editable svg { 18 | vertical-align: text-bottom; 19 | } 20 | -------------------------------------------------------------------------------- /src/components/PerPage/PerPage.scss: -------------------------------------------------------------------------------- 1 | .vdt-perpage { 2 | display: flex; 3 | align-items: center; 4 | grid-area: perPage; 5 | margin-top: 0; 6 | 7 | select { 8 | margin: 0 5px; 9 | height: auto !important; 10 | padding: 0.25rem 0.5rem 0.25rem 0.25rem; 11 | line-height: 1; 12 | color: #495057; 13 | background: #fff; 14 | border: 1px solid #ced4da; 15 | border-radius: 0.25rem; 16 | display: inline-block; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/components/ActionButtons/ActionButtons.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSameLine": false, 4 | "bracketSpacing": true, 5 | "endOfLine": "lf", 6 | "experimentalOperatorPosition": "end", 7 | "htmlWhitespaceSensitivity": "ignore", 8 | "objectWrap": "collapse", 9 | "printWidth": 80, 10 | "proseWrap": "preserve", 11 | "quoteProps": "consistent", 12 | "semi": false, 13 | "singleAttributePerLine": true, 14 | "singleQuote": false, 15 | "tabWidth": 4, 16 | "trailingComma": "es5", 17 | "useTabs": false, 18 | "vueIndentScriptAndStyle": false 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Automated tests 2 | 3 | on: 4 | push: 5 | branches: [ "vue3", "vue2" ] 6 | pull_request: 7 | branches: [ "vue3", "vue2" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | node-version: [16.x, 18.x, 20.x] 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Use Node.js ${{ matrix.node-version }} 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: 'npm' 22 | - run: npm install 23 | - run: npm run build 24 | - run: npm run test -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite" 2 | import vue from "@vitejs/plugin-vue" 3 | 4 | export default defineConfig({ 5 | plugins: [vue()], 6 | build: { 7 | lib: { 8 | entry: "./src/main.ts", 9 | name: "vue-data-table", 10 | fileName: "vue-data-table", 11 | }, 12 | rollupOptions: { 13 | external: ["vue"], 14 | output: { 15 | globals: { vue: "Vue" }, 16 | exports: "named" /** Disable warning for default imports */, 17 | }, 18 | }, 19 | }, 20 | test: { environment: "jsdom" }, 21 | }) 22 | -------------------------------------------------------------------------------- /src/components/Table/TableCellSelectable.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, reactive } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtTableCellSelectable", 5 | props: { data: { type: Object, required: true } }, 6 | emits: ["userEvent"], 7 | data: () => { 8 | return reactive({ selected: false }) 9 | }, 10 | methods: { 11 | handleChange() { 12 | this.$emit("userEvent", { 13 | action: "select", 14 | data: this.data, 15 | selected: this.selected, 16 | checked: this.selected, // avoid confusion 17 | }) 18 | }, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /src/components/PerPage/PerPage.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/lang/en.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageDict } from "../types" 2 | 3 | export default { 4 | perPageText: "Show :entries entries", 5 | perPageAllText: "ALL", 6 | infoText: "Showing :first to :last of :total entries", 7 | infoAllText: "Showing all entries", 8 | infoFilteredText: 9 | "Showing :first to :last of :filtered (filtered from :total entries)", 10 | nextButtonText: "Next", 11 | previousButtonText: "Previous", 12 | paginationSearchText: "Go to page", 13 | paginationSearchButtonText: "GO", 14 | searchText: "search:", 15 | emptyTableText: "No matching records found", 16 | downloadText: "export as:", 17 | downloadButtonText: "DOWNLOAD", 18 | } as LanguageDict 19 | -------------------------------------------------------------------------------- /src/components/ActionButtons/ActionButtons.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtActionButtons", 5 | props: { 6 | actions: { 7 | type: Array as () => string[], 8 | default: () => ["view", "edit", "delete"], 9 | }, 10 | actionIcons: { 11 | type: Object as () => { [key: string]: string }, 12 | default: () => ({ view: "👁️", edit: "✏️", delete: "🗑️" }), 13 | }, 14 | data: Object, 15 | }, 16 | emits: ["userEvent"], 17 | methods: { 18 | triggerAction(action: string) { 19 | this.$emit("userEvent", { action: action, data: this.data }) 20 | }, 21 | }, 22 | }) 23 | -------------------------------------------------------------------------------- /src/lang/pt-br.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageDict } from "../types" 2 | 3 | export default { 4 | perPageText: "Exibindo :entries dados", 5 | perPageAllText: "TODOS", 6 | infoText: "Exibindo :first até :last de :total dados", 7 | infoAllText: "Exibindo todos os dados", 8 | infoFilteredText: 9 | "Exibindo :first até :last de :filtered (filtrado de :total dados)", 10 | nextButtonText: "Próximo", 11 | previousButtonText: "Anterior", 12 | paginationSearchText: "Ir para página", 13 | paginationSearchButtonText: "IR", 14 | searchText: "pesquisar:", 15 | emptyTableText: "Nenhum dado correspondente à pesquisa foi encontrado", 16 | downloadText: "exportar como:", 17 | downloadButtonText: "BAIXAR", 18 | } as LanguageDict 19 | -------------------------------------------------------------------------------- /src/lang/es.ts: -------------------------------------------------------------------------------- 1 | import type { LanguageDict } from "../types" 2 | 3 | export default { 4 | perPageText: "Mostrando :entries datos", 5 | perPageAllText: "TODOS", 6 | infoText: "Mostrando :first hasta :last de :total datos", 7 | infoAllText: "Mostrando todos los datos", 8 | infoFilteredText: 9 | "Mostrando :first hasta :last de :filtered (filtrado de :total datos)", 10 | nextButtonText: "Siguiente", 11 | previousButtonText: "Anterior", 12 | paginationSearchText: "Ir a la página", 13 | paginationSearchButtonText: "IR", 14 | searchText: "buscar:", 15 | emptyTableText: "No se encontraron datos que coincidan con la búsqueda", 16 | downloadText: "exportar cómo:", 17 | downloadButtonText: "DESCARGAR", 18 | } as LanguageDict 19 | -------------------------------------------------------------------------------- /src/components/SortableColumn/SortingIcon.scss: -------------------------------------------------------------------------------- 1 | .vdt-sorting-icon { 2 | display: flex; 3 | margin-left: auto; 4 | padding-left: 0.3em; 5 | 6 | .icon { 7 | width: 0.75em; 8 | display: block; 9 | 10 | &.asc::after { 11 | content: "\2193"; 12 | } 13 | 14 | &.desc::after { 15 | content: "\2191"; 16 | } 17 | 18 | &.asc::after, 19 | &.desc::after { 20 | line-height: 1; 21 | display: block; 22 | color: black; 23 | opacity: 0.5; 24 | } 25 | 26 | [data-sorting="asc"] &.asc::after { 27 | opacity: 1; 28 | } 29 | 30 | [data-sorting="desc"] &.desc::after { 31 | opacity: 1; 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/components/ExportData/ExportData.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/components/PerPage/PerPage.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtPerPage", 5 | props: { 6 | perPageText: { type: String, required: true }, 7 | perPageAllText: { type: String, required: true }, 8 | currentPerPage: { type: [Number, String], required: true }, 9 | perPageSizes: { type: Array, required: true }, 10 | }, 11 | emits: ["set-per-page"], 12 | computed: { 13 | textBeforeOptions() { 14 | return (this.perPageText.split(":entries")[0] || "").trim() 15 | }, 16 | textAfterOptions() { 17 | return (this.perPageText.split(":entries")[1] || "").trim() 18 | }, 19 | }, 20 | methods: { 21 | stringNotEmpty(string: string) { 22 | return string !== "" 23 | }, 24 | }, 25 | }) 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "jsx": "preserve", 13 | "declaration": true, 14 | "declarationDir": "dist/", 15 | "emitDeclarationOnly": true, 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": false, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/*.ts", "src/**/*.vue"], 24 | "exclude": ["src/demo/*", "src/dev.ts"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Table/Table.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtTable", 5 | props: { 6 | tableClass: String, 7 | columns: Array as () => any[], 8 | data: Array, 9 | dataDisplayed: Array as () => any[], 10 | dataFiltered: Array as () => any[], 11 | emptyTableText: String, 12 | footerComponent: [Object, String], 13 | isEmpty: Boolean, 14 | isLoading: Boolean, 15 | loadingComponent: [Object, String], 16 | numberOfColumns: Number, 17 | sortingIconComponent: [Object, String], 18 | sortingIndexComponent: [Object, String], 19 | }, 20 | emits: ["user-event", "sort-column"], 21 | methods: { 22 | // Propagate upwards an event from a user custom component 23 | emitUserEvent(payload: any) { 24 | this.$emit("user-event", payload) 25 | }, 26 | }, 27 | }) 28 | -------------------------------------------------------------------------------- /.github/workflows/demo.yml: -------------------------------------------------------------------------------- 1 | name: Build demo app 2 | 3 | on: 4 | push: 5 | branches: ["vue3"] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: write 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | steps: 15 | - name: Checkout code 16 | uses: actions/checkout@v3 17 | 18 | - name: Set up Node.js 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22' 22 | 23 | - name: Install dependencies 24 | run: npm install 25 | 26 | - name: Build demo 27 | run: APP_BASE=/vue-data-table/demo npm run build:demo 28 | 29 | - name: Deploy 30 | uses: peaceiris/actions-gh-pages@v4 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | publish_branch: demo 34 | publish_dir: ./demo 35 | destination_dir: demo 36 | commit_message: update demo -------------------------------------------------------------------------------- /src/components/Table/TableCellEditable.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent, reactive } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtTableCellEditable", 5 | props: { 6 | data: { type: Object, required: true }, 7 | columnKey: { type: String, required: true }, 8 | }, 9 | emits: ["userEvent"], 10 | data: () => { 11 | return reactive({ isEditing: false, text: "" }) 12 | }, 13 | methods: { 14 | edit() { 15 | this.text = this.data[this.columnKey] 16 | this.isEditing = true 17 | }, 18 | finishEditing(confirmation: boolean) { 19 | this.isEditing = false 20 | 21 | if (confirmation === false) return 22 | 23 | this.$emit("userEvent", { 24 | action: "updateCell", 25 | data: this.data, 26 | key: this.columnKey, 27 | value: this.text, 28 | }) 29 | }, 30 | }, 31 | }) 32 | -------------------------------------------------------------------------------- /src/demo/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Arial; 3 | } 4 | 5 | main { 6 | margin: 1px auto; 7 | padding: 32px; 8 | display: block; 9 | max-width: 1200px; 10 | } 11 | 12 | .minwidth { 13 | width: 1px; 14 | text-align: center; 15 | } 16 | 17 | h1 { 18 | text-align: center; 19 | margin-bottom: 4rem; 20 | } 21 | 22 | main > h2 { 23 | margin-top: 3rem; 24 | } 25 | 26 | .p-dialog-mask { 27 | padding-left: 1.5em; 28 | padding-right: 1.5em; 29 | } 30 | 31 | .form-group { 32 | margin-bottom: 1em; 33 | } 34 | 35 | .form-group label { 36 | display: block; 37 | margin-bottom: 0.5em; 38 | } 39 | 40 | .form-buttons { 41 | display: flex; 42 | gap: 0.5em; 43 | } 44 | 45 | form textarea, 46 | form input, 47 | form .p-treeselect { 48 | width: 100%; 49 | } 50 | 51 | form select { 52 | width: 100%; 53 | display: block; 54 | padding: 0.75em; 55 | } 56 | 57 | .btn-group { 58 | display: flex; 59 | gap: 0.5em; 60 | } 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /src/lang.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The following block of code is used to automatically register the 3 | * lang files. It will recursively scan the lang directory and 4 | * register them with their "basename". 5 | */ 6 | import en from "./lang/en" 7 | import es from "./lang/es" 8 | import ptBr from "./lang/pt-br" 9 | import type { 10 | LanguageDict, 11 | LanguageDictKey, 12 | LanguageDictVal, 13 | LanguageName, 14 | Translation, 15 | } from "./types" 16 | 17 | const translations = { "pt-br": ptBr, "en": en, "es": es } as Translation 18 | 19 | /* utility for the user to change or add new translations */ 20 | const languageServiceProvider = { 21 | setLang(lang: LanguageName, translation: LanguageDict) { 22 | translations[lang] = translation 23 | }, 24 | removeLang(lang: LanguageName) { 25 | delete translations[lang] 26 | }, 27 | setLangText( 28 | lang: LanguageName, 29 | key: LanguageDictKey, 30 | text: LanguageDictVal 31 | ) { 32 | translations[lang][key] = text 33 | }, 34 | } 35 | 36 | export { languageServiceProvider, translations, translations as default } 37 | -------------------------------------------------------------------------------- /src/components/DataTable.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/dev.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue" 2 | 3 | // Prime Vue 4 | import "primevue/resources/themes/lara-light-teal/theme.css" 5 | import PrimeVue from "primevue/config" 6 | import Button from "primevue/button" 7 | import InputText from "primevue/inputtext" 8 | import Textarea from "primevue/textarea" 9 | import Select from "primevue/treeselect" 10 | import Dialog from "primevue/dialog" 11 | 12 | // Import local components. 13 | import { components } from "./main" 14 | import App from "./demo/App.vue" 15 | import CellList from "./demo/CellList.vue" 16 | import CellImage from "./demo/CellImage.vue" 17 | 18 | // Create the application. 19 | const app = createApp(App) 20 | 21 | // Register local components. 22 | for (const name in components) { 23 | app.component(name, components[name]) 24 | } 25 | app.component("CellList", CellList) 26 | app.component("CellImage", CellImage) 27 | 28 | // Register PrimeVue components. 29 | app.use(PrimeVue) 30 | app.component("Button", Button) 31 | app.component("InputText", InputText) 32 | app.component("Textarea", Textarea) 33 | app.component("Select", Select) 34 | app.component("Dialog", Dialog) 35 | 36 | // Mount the app. 37 | app.mount("#app") 38 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.ts: -------------------------------------------------------------------------------- 1 | import { defineComponent } from "vue" 2 | 3 | export default defineComponent({ 4 | name: "VdtPagination", 5 | props: { 6 | paginationSearchButtonText: String, 7 | paginationSearchText: String, 8 | previousButtonText: String, 9 | nextButtonText: String, 10 | isFirstPage: Boolean, 11 | isLastPage: Boolean, 12 | numberOfPages: Number, 13 | previousPage: Number, 14 | currentPage: Number, 15 | nextPage: Number, 16 | pagination: Array, 17 | }, 18 | emits: ["set-page"], 19 | setup() { 20 | return { pageToGo: 1 } 21 | }, 22 | watch: { 23 | currentPage(value) { 24 | this.pageToGo = value 25 | }, 26 | pageToGo(value: number) { 27 | if (value > (this.numberOfPages || 0)) { 28 | return this.numberOfPages 29 | } 30 | if (value < 1) { 31 | return 1 32 | } 33 | return value 34 | }, 35 | }, 36 | methods: { 37 | setCurrentPage(page: any) { 38 | this.$emit("set-page", Number(page)) 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import VueDataTable from "./components/DataTable.vue" 2 | import VdtTableCell from "./components/Table/TableCell.vue" 3 | import VdtTableCellEditable from "./components/Table/TableCellEditable.vue" 4 | import VdtTableCellSelectable from "./components/Table/TableCellSelectable.vue" 5 | import VdtActionButtons from "./components/ActionButtons/ActionButtons.vue" 6 | import VdtSortingIcon from "./components/SortableColumn/SortingIcon.vue" 7 | import VdtSortingIndex from "./components/SortableColumn/SortingIndex.vue" 8 | 9 | const components: { [key: string]: any } = { 10 | "vdt": VueDataTable, 11 | "vdt-cell": VdtTableCell, 12 | "vdt-cell-editable": VdtTableCellEditable, 13 | "vdt-cell-selectable": VdtTableCellSelectable, 14 | "vdt-actions": VdtActionButtons, 15 | "vdt-action-buttons": VdtActionButtons, 16 | "vue-data-table": VueDataTable, 17 | "vdt-sorting-icon": VdtSortingIcon, 18 | "vdt-sorting-index": VdtSortingIndex, 19 | } 20 | 21 | function install(app: any) { 22 | for (const componentName in components) 23 | app.component(componentName, components[componentName]) 24 | } 25 | 26 | const plugin = { install } 27 | 28 | export { components, plugin, plugin as default } 29 | -------------------------------------------------------------------------------- /src/components/ExportData/ExportData.ts: -------------------------------------------------------------------------------- 1 | import exportFromJSON, { ExportType } from "export-from-json" 2 | import jsPDF from "jspdf" 3 | import { defineComponent } from "vue" 4 | 5 | export default defineComponent({ 6 | name: "VdtExportData", 7 | props: { 8 | data: Array, 9 | allowedExports: Array, 10 | downloadButtonText: String, 11 | downloadFileName: String, 12 | downloadText: String, 13 | }, 14 | setup() { 15 | return { selectedExport: "" } 16 | }, 17 | watch: { 18 | allowedExports: { 19 | handler(value) { 20 | this.selectedExport = value[0] 21 | }, 22 | immediate: true, 23 | }, 24 | }, 25 | methods: { 26 | download() { 27 | if (this.selectedExport === "pdf") { 28 | return this.downloadPdf() 29 | } 30 | exportFromJSON({ 31 | data: this.data as object, 32 | fileName: this.downloadFileName, 33 | exportType: this.selectedExport as ExportType, 34 | }) 35 | }, 36 | downloadPdf() { 37 | const doc = new jsPDF("landscape", "pt", "a4") 38 | const table = (this.$refs.el as any).parentNode.querySelector( 39 | "table" 40 | ) 41 | const { downloadFileName } = this 42 | doc.html(table, { 43 | callback: function (doc) { 44 | doc.save(downloadFileName) 45 | }, 46 | x: 10, 47 | y: 10, 48 | }) 49 | }, 50 | }, 51 | }) 52 | -------------------------------------------------------------------------------- /tests/lang.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { mount } from "@vue/test-utils" 3 | import { translations } from "../src/lang" 4 | import VueDataTable from "../src/components/DataTable.vue" 5 | 6 | test("test table text matches language", function () { 7 | const text2cssSelector = { 8 | downloadButtonText: ".vdt-export button", 9 | downloadText: ".vdt-export span", 10 | emptyTableText: ".vdt-empty-body", 11 | // infoFilteredText: ".vdt-info", 12 | infoText: ".vdt-info", 13 | nextButtonText: ".vdt-page-item:last-child", 14 | paginationSearchButtonText: ".vdt-pagination-search button", 15 | paginationSearchText: ".vdt-pagination-search span", 16 | // perPageText: ".vdt-perpage", 17 | previousButtonText: ".vdt-page-item:first-child", 18 | searchText: ".vdt-search span", 19 | } as any 20 | 21 | // text each language 22 | for (const lang in translations) { 23 | const translation = translations[lang] 24 | const wrapper = mount(VueDataTable, { 25 | props: { data: [], columnKeys: [], lang: lang }, 26 | }) 27 | 28 | for (const textKey in text2cssSelector) { 29 | const selector = text2cssSelector[textKey] 30 | let text = translation[textKey as LanguageDictKey] as string 31 | 32 | // some text have placeholders for the number of rows in the table, 33 | // which in this case is zero 34 | text = text.replace(/:(last|first|total|entries)/g, "0") 35 | 36 | expect(wrapper.find(selector).text()).toBe(text) 37 | } 38 | } 39 | }) 40 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // ───────────────────────────────────────────────────────────────────────────── 2 | // TYPE DEFINITIONS 3 | 4 | export type VueComponent = string | any 5 | export type VueComponentProps = { [key: string]: any } 6 | 7 | export type CompareFn = (a: any, b: any) => number 8 | export type SortingMode = "asc" | "desc" | "none" 9 | export type ColumnType = "numeric" | "string" | "array" | "other" 10 | export type Column = { 11 | compareFunction: CompareFn 12 | component: VueComponent 13 | componentProps: VueComponentProps 14 | collapsed: boolean 15 | collapsible: boolean 16 | displayIndex: number 17 | editable: boolean 18 | key: string 19 | id: number 20 | searchable: boolean 21 | searchFunction: (data: Cell, search: string, key: string) => boolean 22 | sortable: boolean 23 | sortingIndex: number 24 | sortingMode: SortingMode 25 | title: string 26 | type: string 27 | } 28 | 29 | export type LanguageName = string 30 | export type LanguageDictKey = 31 | | "downloadButtonText" 32 | | "downloadText" 33 | | "emptyTableText" 34 | | "infoFilteredText" 35 | | "infoText" 36 | | "infoAllText" 37 | | "nextButtonText" 38 | | "paginationSearchButtonText" 39 | | "paginationSearchText" 40 | | "perPageText" 41 | | "perPageAllText" 42 | | "previousButtonText" 43 | | "searchText" 44 | export type LanguageDictVal = string 45 | export type LanguageDict = Record 46 | export type Translation = Record 47 | 48 | export type Cell = { [key: string]: [String, Number, Array, Object] } 49 | export type Data = Cell[] 50 | -------------------------------------------------------------------------------- /tests/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { 3 | stringReplaceFromArray, 4 | arraySafeSort, 5 | safeCompare, 6 | } from "../src/utils" 7 | import type { CompareFn } from "../src/types" 8 | 9 | test("test string replacement", function () { 10 | const str = "showing :first: to :last: entries of :total: rows" 11 | const searchValues = [":first:", ":last:", ":total:"] 12 | const replacements = ["10", "20", "100"] 13 | const result = stringReplaceFromArray(str, searchValues, replacements) 14 | expect(result).toBe("showing 10 to 20 entries of 100 rows") 15 | }) 16 | 17 | test("test safe sort", function () { 18 | let arr = [2, 45, null, 10, 20, null, 15] 19 | let res: (number | null)[] 20 | let f: CompareFn = (a: any, b: any) => a - b 21 | let g: CompareFn = (a: any, b: any) => b - a 22 | res = arraySafeSort(arr, f) 23 | expect(res).toEqual([2, 10, 15, 20, 45, null, null]) 24 | res = arraySafeSort(arr, g) 25 | expect(res).toEqual([45, 20, 15, 10, 2, null, null]) 26 | 27 | // keyed 28 | arr = [ 29 | { n: 2 }, 30 | { n: 45 }, 31 | { n: null }, 32 | { n: 10 }, 33 | { n: 20 }, 34 | { n: null }, 35 | { n: 15 }, 36 | ] as any[] 37 | f = safeCompare(f) 38 | g = safeCompare(g) 39 | const f2 = (a: any, b: any) => f(a.n, b.n) 40 | const g2 = (a: any, b: any) => g(a.n, b.n) 41 | res = arraySafeSort(arr, f2).map((x: any) => x.n) 42 | expect(res).toEqual([2, 10, 15, 20, 45, null, null]) 43 | res = arraySafeSort(arr, g2).map((x: any) => x.n) 44 | expect(res).toEqual([45, 20, 15, 10, 2, null, null]) 45 | }) 46 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import eslint from "@eslint/js" 2 | import eslintConfigPrettier from "eslint-config-prettier" 3 | import eslintPluginVue from "eslint-plugin-vue" 4 | import globals from "globals" 5 | import typescriptEslint from "typescript-eslint" 6 | 7 | export default typescriptEslint.config( 8 | { 9 | ignores: [ 10 | "**/*.d.ts", 11 | "**/coverage", 12 | "**/dist", 13 | "assets/", 14 | "src/dev.ts", 15 | "tests/**/*", 16 | ], 17 | }, 18 | { 19 | extends: [ 20 | eslint.configs.recommended, 21 | ...typescriptEslint.configs.recommended, 22 | ...eslintPluginVue.configs["flat/recommended"], 23 | ], 24 | files: ["**/*.{ts,vue}"], 25 | languageOptions: { 26 | ecmaVersion: "latest", 27 | sourceType: "module", 28 | globals: globals.browser, 29 | parserOptions: { parser: typescriptEslint.parser }, 30 | }, 31 | rules: { 32 | "@typescript-eslint/no-explicit-any": "warn", // Allow the use of `any` 33 | "@typescript-eslint/no-unused-vars": [ 34 | "error", 35 | { 36 | args: "all", 37 | argsIgnorePattern: "^_", 38 | varsIgnorePattern: "^_", 39 | caughtErrorsIgnorePattern: "^_", 40 | }, 41 | ], 42 | "vue/multi-word-component-names": "off", // Disable multi-word component names rule 43 | "vue/require-default-prop": "off", // Disable requiring default props 44 | "vue/no-v-html": "off", // Allow v-html usage (if needed) 45 | }, 46 | }, 47 | eslintConfigPrettier 48 | ) 49 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.scss: -------------------------------------------------------------------------------- 1 | .vdt-pagination { 2 | margin-left: auto; 3 | grid-area: pagination; 4 | display: flex; 5 | align-items: center; 6 | flex-wrap: nowrap; 7 | 8 | & > :first-child { 9 | margin-right: 10px; 10 | } 11 | 12 | & > * { 13 | margin-bottom: 10px !important; 14 | } 15 | } 16 | 17 | .vdt-pagination-items { 18 | margin: 0; 19 | padding: 1px; 20 | overflow: auto; 21 | display: flex; 22 | } 23 | 24 | .vdt-pagination-search { 25 | display: flex; 26 | align-items: center; 27 | white-space: nowrap; 28 | gap: 0.5em; 29 | input { 30 | width: 70px; 31 | } 32 | } 33 | 34 | .vdt-page-item { 35 | user-select: none; 36 | } 37 | 38 | .vdt-pagination { 39 | display: flex; 40 | padding-left: 0; 41 | list-style: none; 42 | } 43 | 44 | .vdt-page-link { 45 | position: relative; 46 | display: block; 47 | cursor: pointer; 48 | padding: 0.5rem 0.75rem; 49 | margin-left: -1px; 50 | line-height: 1.25; 51 | color: #007bff; 52 | background-color: #fff; 53 | border: 1px solid #dee2e6; 54 | cursor: point; 55 | 56 | &:hover { 57 | z-index: 2; 58 | color: #0056b3; 59 | text-decoration: none; 60 | background-color: #e9ecef; 61 | border-color: #dee2e6; 62 | } 63 | } 64 | 65 | .vdt-page-item:first-child .vdt-page-link { 66 | border-top-left-radius: 0.25rem; 67 | border-bottom-left-radius: 0.25rem; 68 | } 69 | 70 | .vdt-page-item:last-child .vdt-page-link { 71 | border-top-right-radius: 0.25rem; 72 | border-bottom-right-radius: 0.25rem; 73 | } 74 | 75 | .vdt-page-item.disabled .vdt-page-link { 76 | color: #6c757d; 77 | pointer-events: none; 78 | background-color: #fff; 79 | border-color: #dee2e6; 80 | } 81 | 82 | .vdt-page-item.active .vdt-page-link { 83 | z-index: 3; 84 | color: #fff; 85 | background-color: #007bff; 86 | border-color: #007bff; 87 | } 88 | -------------------------------------------------------------------------------- /src/components/Pagination/Pagination.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /tests/editableCell.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { click, data, n, testRowsMatchData, wrapper } from "./common" 3 | 4 | test("it can edit editable cells", async () => { 5 | // the column keys 6 | const columnKeys = ["name", "gender", "job"] 7 | 8 | // update props 9 | await wrapper.setProps({ 10 | data, 11 | columns: columnKeys.map(key => ({ key, editable: true })), 12 | perPageSizes: [n], 13 | }) 14 | 15 | // first, make sure it matches the data 16 | testRowsMatchData(data) 17 | 18 | // the current event 19 | let currentEvent = 0 20 | 21 | // test editing three columns 22 | for (let j = 1; j <= 3; j += 1) { 23 | const cells = wrapper.findAll(`tbody tr td:nth-child(${j})`) as any 24 | 25 | // test editing the first two rows 26 | for (let i = 0; i <= 2; i += 1) { 27 | const cell = cells[i] 28 | const editBtn = cell.find(".vdt-action-edit") 29 | expect(editBtn.exists()).toBe(true) 30 | 31 | // input should be hidden by default 32 | let input = cell.find("input") 33 | expect(input.exists()).toBe(false) 34 | 35 | // click button, which shows input to edit the value 36 | await click(editBtn) 37 | input = cell.find("input") 38 | expect(input.exists()).toBe(true) 39 | 40 | // set value 41 | await input.setValue("new value") 42 | 43 | // new event 44 | const confirmBtn = cell.find(".vdt-action-confirm") 45 | await click(confirmBtn) 46 | 47 | // the events to be emitted 48 | const events = wrapper.emitted("userEvent") as any 49 | 50 | // increment event counter and, assert event was emitted 51 | currentEvent += 1 52 | expect(events.length).toBe(currentEvent) 53 | 54 | // get the event payload 55 | const event = events[currentEvent - 1] 56 | const payload = event[0] 57 | 58 | // assert payload matches the expect format 59 | expect(payload).toMatchObject({ 60 | action: "updateCell", 61 | value: "new value", 62 | key: columnKeys[j - 1], 63 | data: data[i], 64 | }) 65 | } 66 | } 67 | }) 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@uwlajs/vue-data-table", 3 | "description": "Vue plugin to easily create fully-featured data tables", 4 | "version": "2.3.2", 5 | "author": "uwla", 6 | "private": false, 7 | "license": "MIT", 8 | "type": "module", 9 | "typings": "dist/vue-data-table.d.ts", 10 | "scripts": { 11 | "build:ts": "vue-tsc && mv dist/main.d.ts vue-data-table.d.ts", 12 | "build:js": "vite build && mv vue-data-table.d.ts dist/", 13 | "build": "npm run build:ts && npm run build:js", 14 | "build:demo": "vite build --config vite.config.dev.ts", 15 | "dev": "vite --config vite.config.dev.ts", 16 | "format": "prettier --write .", 17 | "format:check": "prettier --check .", 18 | "lint": "eslint .", 19 | "lint:fix": "eslint . --fix", 20 | "test": "vitest run" 21 | }, 22 | "main": "./dist/vue-data-table.cjs", 23 | "module": "./dist/vue-data-table.js", 24 | "exports": { 25 | ".": { 26 | "import": { 27 | "types": "./dist/vue-data-table.d.ts", 28 | "default": "./dist/vue-data-table.js" 29 | }, 30 | "require": { 31 | "default": "./dist/vue-data-table.umd.cjs", 32 | "types": "./dist/vue-data-table.d.ts" 33 | } 34 | }, 35 | "./dist/style.css": "./dist/style.css" 36 | }, 37 | "dependencies": { 38 | "export-from-json": "^1.7.3", 39 | "jspdf": "^2.5.1", 40 | "vue": "^3.3.4" 41 | }, 42 | "devDependencies": { 43 | "@eslint/js": "^9.20.0", 44 | "@faker-js/faker": "^8.1.0", 45 | "@vitejs/plugin-vue": "^4.2.3", 46 | "@vue/test-utils": "^2.4.1", 47 | "eslint": "^9.20.1", 48 | "eslint-config-prettier": "^10.0.1", 49 | "eslint-plugin-prettier": "^5.2.3", 50 | "eslint-plugin-vue": "^9.32.0", 51 | "globals": "^15.15.0", 52 | "jsdom": "^22.1.0", 53 | "prettier": "^3.5.1", 54 | "primevue": "^3.49.1", 55 | "sass": "^1.69.5", 56 | "sweetalert2": "^11.10.6", 57 | "typescript": "^5.0.2", 58 | "typescript-eslint": "^8.24.1", 59 | "vite": "^4.4.5", 60 | "vitest": "^1.4.0", 61 | "vue-tsc": "^2.0.7" 62 | }, 63 | "repository": { 64 | "type": "git", 65 | "url": "https://github.com/uwla/vue-data-table" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/customColumn.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "vitest" 2 | import { arraySafeSort } from "../src/utils" 3 | import { 4 | click, 5 | col, 6 | data, 7 | ROLES, 8 | searchInput, 9 | testRowsMatchData, 10 | wrapper, 11 | } from "./common" 12 | 13 | // ──────────────────────────────────────────────────────────────────────────────── 14 | // CUSTOM COLUMN 15 | 16 | test("it sets custom order for columns", async () => { 17 | await wrapper.setProps({ 18 | columns: [ 19 | { key: "gender", displayIndex: 2 }, 20 | { key: "job" }, 21 | { key: "name", displayIndex: 1 }, 22 | ], 23 | }) 24 | testRowsMatchData(data) 25 | }) 26 | 27 | test("it uses custom comparison function", async () => { 28 | const fn = (a: any, b: any) => a.name.length - b.name.length 29 | await wrapper.setProps({ 30 | columns: [ 31 | { key: "name", compareFunction: fn }, 32 | { key: "gender" }, 33 | { key: "job" }, 34 | ], 35 | }) 36 | 37 | // sort by first column using custom comparison function 38 | await click(col(1)) 39 | let copy = arraySafeSort(data, fn) 40 | testRowsMatchData(copy) 41 | 42 | // sort again, which just reverses the sort 43 | await click(col(1)) 44 | copy = arraySafeSort(data, (a: any, b: any) => fn(b, a)) 45 | testRowsMatchData(copy) 46 | 47 | // click the button again cancels sorting 48 | await click(col(1)) 49 | testRowsMatchData(data) 50 | }) 51 | 52 | test("it uses custom search function", async () => { 53 | const fn = (data: any, search: any) => data.roles.includes(search) 54 | 55 | // update props 56 | await wrapper.setProps({ 57 | columns: [ 58 | { key: "name" }, 59 | { key: "gender" }, 60 | { key: "job" }, 61 | { 62 | title: "Roles", 63 | component: "CustomComponent2", 64 | searchable: true, 65 | searchFunction: fn, 66 | }, 67 | ], 68 | defaultColumn: { searchable: false }, 69 | }) 70 | 71 | // test custom search 72 | const searchValues = ROLES 73 | for (const search of searchValues) { 74 | await searchInput.setValue(search) 75 | const copy = data.filter((x: any) => x.roles.includes(search)) 76 | testRowsMatchData(copy) 77 | } 78 | await searchInput.setValue("") 79 | }) 80 | -------------------------------------------------------------------------------- /src/components/Table/Table.scss: -------------------------------------------------------------------------------- 1 | .vdt-table { 2 | overflow: auto; 3 | max-height: 80vh; 4 | width: 100%; 5 | grid-area: table; 6 | 7 | table { 8 | border-collapse: collapse; 9 | margin: 0; 10 | } 11 | 12 | .table { 13 | width: 100%; 14 | margin-bottom: 1rem; 15 | color: #212529; 16 | } 17 | 18 | .table thead th { 19 | vertical-align: bottom; 20 | border-bottom: 2px solid #dee2e6; 21 | } 22 | 23 | .table td, 24 | .table th { 25 | padding: 0.75rem; 26 | vertical-align: top; 27 | border-top: 1px solid #dee2e6; 28 | } 29 | 30 | .table-striped tbody tr:nth-of-type(odd) { 31 | background-color: rgba(0, 0, 0, 0.05); 32 | } 33 | 34 | .table-hover tbody tr:hover { 35 | color: #212529; 36 | background-color: rgba(0, 0, 0, 0.075); 37 | } 38 | 39 | .vdt-column { 40 | white-space: nowrap; 41 | background-color: white; 42 | position: sticky; 43 | padding: 1rem 0.75rem; 44 | border: none !important; 45 | box-shadow: 46 | inset 0px 11px 0.75px -10px #ddd, 47 | inset 0px -11px 0.75px -10px #ddd; 48 | line-height: 1; 49 | top: 0; 50 | user-select: none; 51 | 52 | &[data-sortable="true"] { 53 | cursor: pointer; 54 | span:hover { 55 | color: #3490dc; 56 | } 57 | } 58 | 59 | &[data-collapsed="true"] { 60 | width: 1px; 61 | } 62 | } 63 | 64 | .vdt-column-collapse { 65 | color: #000; 66 | cursor: pointer; 67 | position: relative; 68 | span { 69 | position: absolute; 70 | top: 2em; 71 | left: -1.5em; 72 | background: #fff; 73 | border: 1px solid #ccc; 74 | border-radius: 4px; 75 | padding: 4px; 76 | display: none; 77 | z-index: 50; 78 | } 79 | &:hover { 80 | color: #00f; 81 | span { 82 | display: block; 83 | } 84 | } 85 | } 86 | 87 | .vdt-column-center span { 88 | text-align: center; 89 | width: 100%; 90 | display: block; 91 | } 92 | 93 | .vdt-column-content { 94 | display: flex; 95 | align-items: center; 96 | justify-content: space-between; 97 | gap: 5px; 98 | } 99 | 100 | .vdt-empty-body { 101 | text-align: center; 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /tests/customComponent.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { click, data, jobs, n, names, wrapper } from "./common" 3 | 4 | // ──────────────────────────────────────────────────────────────────────────────── 5 | // CUSTOM COMPONENT 6 | 7 | test("it renders custom components", async () => { 8 | // use custom component (defined in common.ts) in the first column 9 | await wrapper.setProps({ 10 | columns: [ 11 | { title: "Person info", component: "CustomComponent1" }, 12 | { key: "gender" }, 13 | ], 14 | perPageSizes: [n], 15 | }) 16 | 17 | // for some reason, computed properties are not updating fast enough 18 | // for the tests to run, so it is necessary to set this data property 19 | await wrapper.setData({ currentPerPage: n }) 20 | 21 | // get the text to test, which is within by bold and italic tags 22 | const _names = wrapper.findAll("tbody td b").map(t => t.text()) 23 | const _jobs = wrapper.findAll("tbody td i").map(t => t.text()) 24 | 25 | expect(_names).toEqual(names) 26 | expect(_jobs).toEqual(jobs) 27 | }) 28 | 29 | test("it emits user events from custom components", async () => { 30 | await wrapper.setProps({ 31 | columns: [ 32 | { key: "name" }, 33 | { key: "gender" }, 34 | { key: "job" }, 35 | { title: "actions", component: "vdt-action-buttons" }, 36 | ], 37 | }) 38 | 39 | // which buttons to click 40 | const clickedButtons = [ 41 | [2, "view"], 42 | [5, "edit"], 43 | [7, "edit"], 44 | [10, "delete"], 45 | ] 46 | 47 | // click many buttons 48 | for (const clicked of clickedButtons) { 49 | const row = clicked[0] 50 | const action = clicked[1] 51 | const selector = `tr:nth-child(${row}) .vdt-action-${action}` 52 | await click(wrapper.find(selector)) 53 | } 54 | 55 | // Wait until $emits have been handled 56 | await wrapper.vm.$nextTick() 57 | 58 | // get the event object 59 | const event = wrapper.emitted("userEvent") as any 60 | 61 | // assert event has been emitted 62 | expect(event).toBeTruthy() 63 | 64 | // assert event count 65 | expect(event.length).toBe(clickedButtons.length) 66 | 67 | // the current event 68 | let currentEvent = 0 69 | 70 | for (const clicked of clickedButtons) { 71 | // determine the payload 72 | const row = clicked[0] as any 73 | const action = clicked[1] as any 74 | const payload = [ 75 | { action: action, data: { ...data[row - 1], _key: row - 1 } }, 76 | ] 77 | 78 | // assert payload 79 | expect(event[currentEvent]).toEqual(payload) 80 | 81 | // increment counter 82 | currentEvent += 1 83 | } 84 | }) 85 | -------------------------------------------------------------------------------- /src/parser/index.ts: -------------------------------------------------------------------------------- 1 | import { searchNumericColumn, searchStringColumn, toTitleCase } from "../utils" 2 | import { SORTING_MODE } from "../const" 3 | import translations from "../lang" 4 | import type { Column, ColumnType, LanguageDict, LanguageName } from "../types" 5 | 6 | // default column to all instances of VDT 7 | export const globalDefaultColumn = { 8 | component: "vdt-cell", 9 | componentProps: {}, 10 | displayIndex: 1000, 11 | searchable: true, 12 | sortable: true, 13 | editable: false, 14 | collapsible: false, 15 | collapsed: false, 16 | type: "string", 17 | } as Column 18 | 19 | const type2searchFunction: Partial< 20 | Record 21 | > = { string: searchStringColumn, numeric: searchNumericColumn } 22 | 23 | export function parseColumnProps(props: any) { 24 | // extract the columns. If not set, columns are derived from columnKeys 25 | let columns: Column[] 26 | if (props.columns) columns = props.columns 27 | else if (props.columnKeys) 28 | columns = props.columnKeys.map((key: string) => ({ key })) as Column[] 29 | else throw new Error("Neither columns or columnKeys is defined in props.") 30 | 31 | // extract the local default column 32 | const defaultColumn = props.defaultColumn || {} 33 | 34 | // merge default column with the columns 35 | columns = columns.map(function (column: Column, i: number) { 36 | column = { ...column } 37 | const { key } = column 38 | 39 | // if component not set, need to pass the key to the default component 40 | if (column.component == null) column.componentProps = { columnKey: key } 41 | 42 | // by default, columns with custom components are not sortable or searchable 43 | if (column.component != null) { 44 | column.searchable = column.searchable || false 45 | column.sortable = column.sortable || false 46 | } 47 | 48 | // editable cell 49 | if (column.editable) column.component = "vdt-cell-editable" 50 | 51 | // merge the column with the default values 52 | column = { ...globalDefaultColumn, ...defaultColumn, ...column } 53 | 54 | // some default values are dynamically computed 55 | const type = column.type as ColumnType 56 | column.title = column.title || toTitleCase(key) 57 | column.searchFunction = 58 | column.searchFunction || type2searchFunction[type] 59 | 60 | // options below are used internally 61 | // shall not be overwritten by the user 62 | column.sortingIndex = -1 63 | column.sortingMode = SORTING_MODE.NONE 64 | column.id = i 65 | 66 | return column 67 | }) 68 | 69 | /* order the columns by the index, so the user can 70 | set a custom order for the columns to be displayed */ 71 | columns.sort(function (a: Column, b: Column) { 72 | return a.displayIndex - b.displayIndex 73 | }) 74 | 75 | // finally, return the parsed columns 76 | return columns 77 | } 78 | 79 | export function parseTextProps(props: any): LanguageDict { 80 | const lang = props.lang as LanguageName 81 | const text = props.text as LanguageDict 82 | return { ...translations[lang], ...text } 83 | } 84 | -------------------------------------------------------------------------------- /tests/filter.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import translations from "../src/lang" 3 | import { stringReplaceFromArray } from "../src/utils" 4 | import { data, n, searchInput, testRowsMatchData, wrapper } from "./common" 5 | 6 | test("it filters data", async () => { 7 | const searchValues = ["Engineer", "Executive", "Designer", "Manager"] 8 | for (const search of searchValues) { 9 | await searchInput.setValue(search) 10 | const copy = data.filter((x: any) => x.job.includes(search)) 11 | testRowsMatchData(copy) 12 | } 13 | 14 | // clear the field afterwards 15 | await searchInput.setValue("") 16 | testRowsMatchData(data) 17 | }) 18 | 19 | test("it shows correct text for filtered data", async () => { 20 | const searchValues = ["Engineer", "Executive"] 21 | for (const search of searchValues) { 22 | await searchInput.setValue(search) 23 | 24 | // test the text of filtered data 25 | const copy = data.filter((x: any) => x.job.includes(search)) 26 | const m = copy.length 27 | const f = m > 0 ? 1 : 0 28 | let text = translations["en"]["infoFilteredText"] 29 | const placeholders = [":first", ":last", ":filtered", ":total"] 30 | text = stringReplaceFromArray(text, placeholders, [f, m, m, n]) 31 | expect(wrapper.find(".vdt-info").text()).toBe(text) 32 | } 33 | 34 | // clear the field afterwards 35 | await searchInput.setValue("") 36 | }) 37 | 38 | test("it filters data on multiple columns", async () => { 39 | const searchValues = ["na", "si", "te"] 40 | for (const search of searchValues) { 41 | await searchInput.setValue(search) 42 | const copy = data.filter(function (x: any) { 43 | return ( 44 | x.name.toLowerCase().includes(search) || 45 | x.job.toLowerCase().includes(search) 46 | ) 47 | }) 48 | testRowsMatchData(copy) 49 | } 50 | 51 | await searchInput.setValue("") 52 | }) 53 | 54 | test("it filters only searchable columns", async () => { 55 | await wrapper.setProps({ 56 | columns: [ 57 | { key: "name" }, 58 | { key: "gender", searchable: false }, 59 | { key: "job", searchable: false }, 60 | ], 61 | }) 62 | 63 | // if the gender column were searchable, 64 | // then all rows would match because they all contain 'Male' or 'Female' 65 | const searchValues = ["fe", "ma", "le"] 66 | for (const search of searchValues) { 67 | await searchInput.setValue(search) 68 | const copy = data.filter((x: any) => 69 | x.name.toLowerCase().includes(search) 70 | ) 71 | testRowsMatchData(copy) 72 | } 73 | 74 | // now try it with the default column set to not sort 75 | await wrapper.setProps({ 76 | columns: [{ key: "name" }, { key: "gender" }, { key: "job" }], 77 | defaultColumn: { searchable: false }, 78 | }) 79 | 80 | for (const search of searchValues) { 81 | await searchInput.setValue(search) 82 | 83 | // empty table will show a single row: "no records found" message 84 | expect(wrapper.findAll("tbody tr").length).toBe(1) 85 | } 86 | 87 | // clear the field afterwards 88 | await searchInput.setValue("") 89 | 90 | // reset columns 91 | await wrapper.setProps({ 92 | columns: [{ key: "name" }, { key: "gender" }, { key: "job" }], 93 | defaultColumn: { searchable: true }, 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /tests/parser.test.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from "vitest" 2 | import { globalDefaultColumn, parseColumnProps } from "../src/parser" 3 | 4 | test("test parsing columns", function () { 5 | const columns = [ 6 | { key: "name" }, 7 | { key: "mail", title: "Email address" }, 8 | { key: "age", type: "number" }, 9 | { key: "gender", searchable: false }, 10 | { key: "phone_number", sortable: false }, 11 | { key: "job", editable: true }, 12 | ] 13 | const defaultColumn = globalDefaultColumn 14 | const parsed = parseColumnProps({ columns, defaultColumn }) 15 | 16 | // 17 | expect(typeof parsed).toBe(typeof columns) 18 | expect(parsed.length).toBe(columns.length) 19 | 20 | // name column 21 | expect(parsed[0].title).toBe("Name") 22 | expect(parsed[0].type).toBe("string") 23 | expect(parsed[0].sortable).toBe(defaultColumn.sortable) 24 | expect(parsed[0].searchable).toBe(defaultColumn.searchable) 25 | 26 | // email column 27 | expect(parsed[1].title).toBe("Email address") 28 | 29 | // age column 30 | expect(parsed[2].title).toBe("Age") 31 | expect(parsed[2].type).toBe("number") 32 | 33 | // gender column 34 | expect(parsed[3].searchable).toBe(false) 35 | 36 | // phone number column 37 | expect(parsed[4].title).toBe("Phone Number") 38 | expect(parsed[4].sortable).toBe(false) 39 | 40 | // 41 | expect(parsed[5].editable).toBe(true) 42 | expect(parsed[5].component).toEqual("vdt-cell-editable") 43 | }) 44 | 45 | test("test parsing columns with custom default column", function () { 46 | const columns = [ 47 | { key: "name" }, 48 | { key: "mail" }, 49 | { key: "age", sortable: true, searchable: true }, 50 | { key: "gender" }, 51 | { key: "phone_number" }, 52 | ] 53 | const defaultColumn = { searchable: false, sortable: false } 54 | const parsed = parseColumnProps({ columns, defaultColumn }) 55 | 56 | // 57 | expect(typeof parsed).toBe(typeof columns) 58 | expect(parsed.length).toBe(columns.length) 59 | 60 | for (let i = 0; i < parsed.length; i += 1) { 61 | if (parsed[i].key == "age") continue 62 | expect(parsed[i].sortable).toBe(defaultColumn.sortable) 63 | expect(parsed[i].searchable).toBe(defaultColumn.searchable) 64 | } 65 | 66 | // age column 67 | expect(parsed[2].sortable).toBe(true) 68 | expect(parsed[2].searchable).toBe(true) 69 | }) 70 | 71 | test("test parsing column keys", function () { 72 | const columnKeys = [ 73 | "first_name", 74 | "phone_number", 75 | "streetName", 76 | "companyName", 77 | ] 78 | const parsed = parseColumnProps({ columnKeys }) as any 79 | 80 | expect(typeof parsed).toBe(typeof columnKeys) 81 | expect(parsed.length).toBe(columnKeys.length) 82 | 83 | // test title of the columns 84 | expect(parsed[0].title).toBe("First Name") 85 | expect(parsed[1].title).toBe("Phone Number") 86 | expect(parsed[2].title).toBe("Street Name") 87 | expect(parsed[3].title).toBe("Company Name") 88 | 89 | // test fields 90 | const defaultColumn = globalDefaultColumn as any 91 | for (const col of parsed) { 92 | for (const key in defaultColumn) { 93 | // skip object fields 94 | if (typeof defaultColumn[key] === "object") continue 95 | 96 | // verify only scalar fields 97 | expect(col[key]).toBe(defaultColumn[key]) 98 | } 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /src/components/Table/TableCellEditable.vue: -------------------------------------------------------------------------------- 1 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /tests/common.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "vitest" 2 | import { mount } from "@vue/test-utils" 3 | import { faker } from "@faker-js/faker" 4 | import { components } from "../src/main" 5 | import VueDataTable from "../src/components/DataTable.vue" 6 | import { defineComponent, createVNode } from "vue" 7 | 8 | //////////////////////////////////////////////////////////////////////////////// 9 | // DATA 10 | 11 | // number of fake entries 12 | // It must be greater than 300 for some "searched terms" to appear in the 13 | // dataset, otherwise the tests for "searching data" will be meaningless 14 | export const n = 400 15 | 16 | // aliases to make it less verbose to create multiple fake data 17 | const gen = (fn: any) => faker.helpers.multiple(fn, { count: n }) 18 | const subset = (arr: any) => 19 | faker.helpers.arrayElements(arr, { min: 1, max: arr.length }) 20 | 21 | // a custom data source for faking data 22 | export const ROLES = ["admin", "chief", "staff", "manager", "executive", "user"] 23 | 24 | // generate fake data 25 | export const names = gen(faker.person.fullName) 26 | export const jobs = gen(faker.person.jobTitle) 27 | export const genders = gen(faker.person.sex) 28 | export const roles = gen(() => subset(ROLES)) 29 | 30 | // create an object data array with the fake data 31 | export const data = [] as any 32 | for (let i = 0; i < n; i++) 33 | data.push({ 34 | name: names[i], 35 | job: jobs[i], 36 | gender: genders[i], 37 | roles: roles[i], 38 | }) 39 | 40 | // The component to test 41 | const CustomComponent1 = defineComponent({ 42 | props: { data: { type: Object, required: true } }, 43 | render() { 44 | return createVNode("p", null, [ 45 | createVNode("b", null, this.data.name), 46 | " works as ", 47 | createVNode("i", null, this.data.job), 48 | ]) 49 | }, 50 | }) 51 | 52 | const CustomComponent2 = defineComponent({ 53 | props: { data: { type: Object, required: true } }, 54 | render() { 55 | return createVNode( 56 | "ul", 57 | null, 58 | this.data.roles.map((role: any) => createVNode("li", null, role)) 59 | ) 60 | }, 61 | }) 62 | 63 | // mount the component 64 | export const wrapper = mount(VueDataTable, { 65 | global: { 66 | components: { ...components, CustomComponent1, CustomComponent2 }, 67 | }, 68 | props: { 69 | data: data, 70 | columns: [{ key: "name" }, { key: "gender" }, { key: "job" }], 71 | perPageSizes: [n], // this should render all rows in the table 72 | }, 73 | }) 74 | 75 | // some aliases 76 | export const searchInput = wrapper.find(".vdt-search input") 77 | export const paginationBtn = wrapper.find(".vdt-pagination-search button") 78 | export const paginationInput = wrapper.find(".vdt-pagination-search input") 79 | export const col = (i: any) => wrapper.find(`.vdt-column:nth-child(${i})`) 80 | 81 | //////////////////////////////////////////////////////////////////////////////// 82 | // HELPERS 83 | 84 | // click on something 85 | export async function click(el: any) { 86 | el.trigger("click") 87 | } 88 | 89 | // get the text of the given row 90 | export function rowText(i: any) { 91 | return wrapper 92 | .findAll(`tbody td:nth-child(${i})`) 93 | .map((el: any) => el.text()) 94 | } 95 | 96 | // check the rows match the given data 97 | export function testRowsMatchData(data: any) { 98 | if (data.length == 0) return 99 | expect(rowText(1)).toEqual(data.map((x: any) => x.name)) 100 | expect(rowText(2)).toEqual(data.map((x: any) => x.gender)) 101 | expect(rowText(3)).toEqual(data.map((x: any) => x.job)) 102 | } 103 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## Vue3 releases 4 | 5 | ### v2.1.0 6 | 7 | - feat: add option to show all entries in per-page 8 | - feat: auto-hide pagination if no pages 9 | 10 | ### v2.0.1 11 | 12 | - fix: TS7016 error 13 | 14 | ### v2.0.0 15 | 16 | - feat: add vue3 support 17 | 18 | ## Vue2 releases 19 | 20 | ### v1.2.4 21 | 22 | - fix: columns with empty cells not being sorted correctly 23 | 24 | ### v1.2.3 25 | 26 | - fix: plugin crashed when searching text on empty cells 27 | - css: enhance display of editable cells 28 | 29 | ### v1.2.2 30 | 31 | - feat: add built-in editable cells 32 | 33 | ### v1.1.2 34 | 35 | - fix: property `columns` object array being changed by child component 36 | 37 | ### v1.1.1 38 | 39 | - fix: unsupported JS syntax sugar 40 | 41 | ### v1.1.0 42 | 43 | - feat: add support for user-provided search function 44 | - feat: add built-in Action Buttons column 45 | - feat: easy access to events emitted from user-defined cell components 46 | 47 | ### v1.0.0 48 | 49 | - initial release after refactoring of legacy code 50 | 51 | ## Vue2 legacy releases 52 | 53 | ### v3.3.3 (legacy) 54 | 55 | - css: removes Bootstrap as dependency, replacing it with our own custom CSS 56 | 57 | ### v3.3.2 (legacy) 58 | 59 | - feat: add option to display user-provided Loading component (useful if data has not been loaded) 60 | 61 | ### v3.2.2 (legacy) 62 | 63 | - fix: first table entry was incorrect on page 2 and onwards 64 | - fix: empty filename when downloading data 65 | - feat: export only filtered entries instead of all data entries 66 | 67 | ### v3.2.1 (legacy) 68 | 69 | - feat: makes it possible to access column data from user-provided custom components 70 | 71 | ### v3.1.1 (legacy) 72 | 73 | - feat: add option for user-provided table footer component 74 | 75 | ### v3.0.1 (legacy) 76 | 77 | - fix: user-provided custom components not rendered properly 78 | 79 | ### v3.0.0 (legacy) 80 | 81 | - break: interface changes 82 | 83 | ### v2.3.4 (legacy) 84 | 85 | - feat: add support for user-provided vue components 86 | - feat: add support for user-provided sorting function for individual columns 87 | - feat: add Language Service Provider to set global language settings 88 | - fix: numeric data sorted as string not as numbers 89 | 90 | ### v2.2.3 (legacy) 91 | 92 | - fix: custom file name for exported file not working 93 | 94 | ### v2.2.2 (legacy) 95 | 96 | - feat: allows setting the positions of components (pagination, search input, etc) via CSS' grid 97 | - fix: default per-page value not working 98 | 99 | ### v2.1.1 (legacy) 100 | 101 | - feat: allows display of HTML via `unsafeHTML` option (false by default, feature requested by users of the plugin) 102 | - fix: incorrect display of Action Buttons 103 | - fix: column sorting not working 104 | 105 | ### v2.0.0 (legacy) 106 | 107 | - break: remove vuex from deps 108 | - break: interface changes 109 | - css: overall improvements 110 | - fix: crashes when sorting empty cells 111 | - fix: crashes if user inputs out-of-range `go to page` value (keep it in range) 112 | 113 | ### v1.4.7 (legacy) 114 | 115 | - fix: column not being sorted on first click 116 | 117 | ### v1.4.5 (legacy) 118 | 119 | - feat: add option to select default sorting mode 120 | - feat: add option to use HTML code in column title 121 | 122 | ### v1.3.4 (legacy) 123 | 124 | - feat: add `columnKeys` option, a syntax sugar for defining columns 125 | 126 | ### v1.2.4 (legacy) 127 | 128 | - feat: add button to export data to csv/xml/json/txt file 129 | 130 | ### v1.1.4 (legacy) 131 | 132 | - css: overall improvements 133 | 134 | ### v1.1.2 (legacy) 135 | 136 | - feat: add `go to page` button in pagination 137 | 138 | ### v1.0.1 (legacy) 139 | 140 | - fix: remove unused code bundled with deployed code 141 | 142 | ### v1.0.0 (legacy) 143 | 144 | - initial release 145 | - feat: pagination 146 | - feat: text searching 147 | - feat: column sorting 148 | - feat: display stats 149 | - feat: empty table text 150 | - feat: customizable text 151 | - feat: lang support (en, es, pt-br) 152 | -------------------------------------------------------------------------------- /src/components/DataTable.scss: -------------------------------------------------------------------------------- 1 | .vue-data-table { 2 | display: grid; 3 | width: 100%; 4 | grid-template-columns: 25% 25% 25% 25%; 5 | 6 | & > .vdt-search, 7 | .vdt-pagination, 8 | .vdt-export { 9 | margin-left: auto; 10 | } 11 | 12 | @media (min-width: 1401px) { 13 | grid-template-areas: 14 | "perPage search search search" 15 | "table table table table" 16 | "info pagination pagination download"; 17 | } 18 | 19 | @media (min-width: 1051px) AND (max-width: 1400px) { 20 | grid-template-areas: 21 | "perPage search search search" 22 | "table table table table" 23 | "info pagination pagination pagination" 24 | ". . download download"; 25 | } 26 | 27 | @media (min-width: 851px) AND (max-width: 1050px) { 28 | grid-template-areas: 29 | "perPage search search search" 30 | "table table table table" 31 | "pagination pagination pagination pagination" 32 | "info info download download"; 33 | } 34 | 35 | @medi max-width: 800px) { 36 | & > .vdt-pagination { 37 | flex-wrap: wrap; 38 | } 39 | } 40 | 41 | @media (min-width: 651px) AND (max-width: 850px) { 42 | grid-template-areas: 43 | "perPage search search search" 44 | "table table table table" 45 | "pagination pagination pagination pagination" 46 | "info info info info" 47 | "download download download download"; 48 | } 49 | 50 | @media (max-width: 650px) { 51 | grid-template-areas: 52 | "search search search search" 53 | "perPage perPage perPage perPage " 54 | "table table table table" 55 | "pagination pagination pagination pagination" 56 | "info info info info" 57 | "download download download download"; 58 | 59 | & > .vdt-perpage { 60 | margin-left: auto; 61 | } 62 | } 63 | 64 | & > div { 65 | margin-top: 1rem; 66 | max-width: 100%; 67 | } 68 | } 69 | 70 | // ----------------------------------------------------------------------------- 71 | // GENERIC CLASSES 72 | 73 | .vdt-input { 74 | background-color: #fff; 75 | border: 1px solid #ced4da; 76 | border-radius: 0.25rem; 77 | color: #495057; 78 | display: inline-block; 79 | line-height: 1.5; 80 | margin: 0; 81 | outline: none; 82 | padding: 0.25rem 0.5rem; 83 | width: 100%; 84 | 85 | &:focus { 86 | background-color: #fff; 87 | border-color: #80bdff; 88 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 89 | color: #495057; 90 | outline: 0; 91 | -webkit-box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); 92 | } 93 | } 94 | 95 | // Buttons 96 | 97 | .vdt-btn { 98 | border: 1px solid transparent; 99 | border-radius: 0.25rem; 100 | cursor: pointer; 101 | color: #fff; 102 | display: inline-block; 103 | font-size: 1rem; 104 | font-weight: 400; 105 | line-height: 1.5; 106 | padding: 0.375rem 0.75rem; 107 | text-align: center; 108 | vertical-align: middle; 109 | } 110 | 111 | .vdt-action-view, 112 | .vdt-action-confirm { 113 | border: 1px solid #28a745; 114 | background-color: #28a745; 115 | &:hover { 116 | background-color: #218838; 117 | border-color: #1e7e34; 118 | } 119 | } 120 | 121 | .vdt-action-edit, 122 | .vdt-btn-primary { 123 | border: 1px solid #007bff; 124 | background-color: #007bff; 125 | &:hover { 126 | background-color: #0069d9; 127 | border-color: #0062cc; 128 | } 129 | } 130 | 131 | .vdt-action-cancel { 132 | color: #fff; 133 | background-color: #dc3545; 134 | border-color: #dc3545; 135 | &:hover { 136 | background-color: #c82333; 137 | border-color: #bd2130; 138 | } 139 | } 140 | 141 | .vdt-action-delete { 142 | border: 1px solid #343a40; 143 | background-color: #343a40; 144 | &:hover { 145 | background-color: #23272b; 146 | border-color: #1d2124; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/components/Table/Table.vue: -------------------------------------------------------------------------------- 1 | 108 | 109 | 110 | -------------------------------------------------------------------------------- /src/demo/App.vue: -------------------------------------------------------------------------------- 1 |