├── .eslintignore ├── .eslintrc.cjs ├── .github ├── renovate.json └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── setupTests.ts ├── src ├── app.d.ts ├── app.html ├── index.d.ts ├── lib │ ├── Pagination.svelte │ ├── Pagination.test.ts │ ├── SortIcon.svelte │ ├── Table.svelte │ ├── Table.test.ts │ ├── index.d.ts │ ├── index.ts │ ├── types.ts │ └── utils │ │ └── sort │ │ ├── compareBool.ts │ │ ├── compareDyn.ts │ │ ├── compareNum.ts │ │ ├── compareString.ts │ │ ├── sortWith.ts │ │ ├── toReversed.ts │ │ └── toSorted.ts └── routes │ └── +page.svelte ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], 5 | plugins: ['svelte3', '@typescript-eslint'], 6 | ignorePatterns: ['*.cjs'], 7 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 8 | settings: { 9 | 'svelte3/typescript': () => require('typescript') 10 | }, 11 | parserOptions: { 12 | sourceType: 'module', 13 | ecmaVersion: 2020 14 | }, 15 | env: { 16 | browser: true, 17 | es2017: true, 18 | node: true 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "group:all" 5 | ], 6 | "timezone": "Europe/Oslo", 7 | "schedule": ["every weekend"] 8 | } 9 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | push: 7 | branches: 8 | - main 9 | workflow_dispatch: 10 | 11 | jobs: 12 | build-test: 13 | name: Test and build 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: actions/setup-node@v3 18 | with: 19 | node-version: 18 20 | cache: 'npm' 21 | - run: npm ci 22 | - run: npm test 23 | - run: npm run build 24 | 25 | publish-npm: 26 | name: Release 27 | needs: build-test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v3 32 | with: 33 | fetch-depth: 0 34 | - name: Setup Node.js 35 | uses: actions/setup-node@v3 36 | with: 37 | node-version: 18 38 | registry-url: https://registry.npmjs.org/ 39 | cache: 'npm' 40 | - name: Install dependencies 41 | run: npm ci 42 | - name: Create release 43 | run: npm run semantic-release 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 3.0.0 4 | 5 | - migrate to kit/package 6 | - generate types out of component files 7 | - split sortBy and sortFn props 8 | - allow scrollable table body with _fixed_ head 9 | 10 | ## 2.0.3 11 | 12 | - add scope for th 13 | - add classes for even/odd rows 14 | - fix `undefined` as classes when property isn't set 15 | 16 | ## 2.0.2 17 | 18 | - update docs on asyncPagination, sorting and types 19 | - simplify sortBy prop for columns 20 | - set SvelteTableColumn interface to receive (optional) generic type that sets types for sortBy arguments 21 | - use `const columns: SvelteTableColumn[] = ...` for better auto-completion 22 | - update dependencies 23 | 24 | ## 2.0.1 25 | 26 | - use slots instead of props 27 | - update docs 28 | - use slots for expanding rows 29 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | First off, thank you for considering contributing to Svelte Table. It's people like you that make it such a great tool. 4 | 5 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests. 6 | 7 | # Ground Rules 8 | 9 | Responsibilities 10 | * Create issues for any major changes and enhancements that you wish to make. Discuss things transparently and get community feedback. 11 | * Keep feature versions as small as possible, preferably one new feature per version. 12 | * Be welcoming to newcomers and encourage diverse new contributors from all backgrounds. 13 | 14 | # Your First Contribution 15 | Unsure where to begin contributing to the repo? You can start by looking through these beginner and help-wanted issues: 16 | Beginner issues - issues which should only require a few lines of code, and a test or two. 17 | Help wanted issues - issues which should be a bit more involved than beginner issues. 18 | 19 | At this point, you're ready to make your changes! Feel free to ask for help; everyone is a beginner at first :smile_cat: 20 | 21 | If a maintainer asks you to "rebase" your PR, they're saying that a lot of code has changed, and that you need to update your branch so it's easier to merge. 22 | 23 | # Getting started 24 | * Remember running tests (or adding new ones that are neccessary) - npm test 25 | 26 | For something that is bigger than a one or two line fix: 27 | 28 | 1. Create your own fork of the code 29 | 2. Do the changes in your fork 30 | 3. If you like the change and think the project could use it: 31 | * Be sure you have followed the code style for the project. 32 | * Send a pull request indicating that you have a CLA on file. 33 | 34 | 35 | Small contributions such as fixing spelling errors, where the content is small enough to not be considered intellectual property, can be submitted by a contributor as a patch, without a CLA. 36 | 37 | As a rule of thumb, changes are obvious fixes if they do not introduce any new functionality or creative thinking. As long as the change does not affect functionality, some likely examples include the following: 38 | * Spelling / grammar fixes 39 | * Typo correction, white space and formatting changes 40 | * Comment clean up 41 | * Bug fixes that change default return values or error codes stored in constants 42 | * Adding logging messages or debugging output 43 | * Changes to ‘metadata’ files like Gemfile, .gitignore, build scripts, etc. 44 | * Moving source files from one directory or package to another 45 | 46 | # How to report a bug 47 | When filing an issue, please use this template: 48 | 49 | > 1. What version of TypeScript, Svelte and @hurtigruten/svelte-table are you using? 50 | > 2. What did you do? 51 | > 3. What did you expect to see? 52 | > 4. What did you see instead? 53 | > 5. Include a code snippet using [Svelte REPL](https://svelte.dev/repl/): 54 | 55 | # How to suggest a feature or enhancement 56 | If you find yourself wishing for a feature that doesn't exist in Svelte Talbe, you are probably not alone. There are bound to be others out there with similar needs. Open an issue on our issues list on GitHub which describes the feature you would like to see, why you need it, and how it should work. 57 | 58 | # Code, commit message and labeling conventions 59 | We use eslint, remember to npm install packages to get it working 60 | Please consider using [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/#summary) 61 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Hurtigruten Pluss AS 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Svelte Table 2 | 3 | [Documentation](https://hurtigruten.github.io/svelte-table/) 4 | 5 | [Live Demo](https://svelte.dev/repl/235af12d2e8a4d5991a19f77e1cbfd24?version=3.48.0) 6 | 7 | ## Quick start 8 | 9 | ```bash 10 | npm i @hurtigruten/svelte-table@2.0.4 --save 11 | ``` 12 | 13 | ### Simple example 14 | 15 | ```html 16 | 17 | ``` 18 | 19 | ### Table with custom components 20 | 21 | ```html 22 | 23 | {column.title} 24 | {cell} 25 | 26 | ``` 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hurtigruten/svelte-table", 3 | "version": "0.0.0-development", 4 | "author": "Hurtigruten", 5 | "license": "MIT", 6 | "description": "A simple, sortable svelte table component", 7 | "keywords": [ 8 | "svelte", 9 | "sveltejs", 10 | "table" 11 | ], 12 | "scripts": { 13 | "dev": "vite dev", 14 | "build": "vite build && npm run package", 15 | "preview": "vite preview", 16 | "test": "vitest run", 17 | "test:watch": "vitest", 18 | "package": "svelte-kit sync && svelte-package && publint", 19 | "prepublishOnly": "npm run package", 20 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 21 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 22 | "test:unit": "vitest", 23 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 24 | "format": "prettier --plugin-search-dir . --write .", 25 | "semantic-release": "semantic-release" 26 | }, 27 | "exports": { 28 | ".": { 29 | "types": "./dist/index.d.ts", 30 | "svelte": "./dist/index.js" 31 | } 32 | }, 33 | "files": [ 34 | "dist" 35 | ], 36 | "peerDependencies": { 37 | "svelte": "^3.54.0" 38 | }, 39 | "devDependencies": { 40 | "@semantic-release/git": "10.0.1", 41 | "@sveltejs/adapter-auto": "^2.0.0", 42 | "@sveltejs/kit": "^1.5.0", 43 | "@sveltejs/package": "^2.0.0", 44 | "@typescript-eslint/eslint-plugin": "^5.45.0", 45 | "@typescript-eslint/parser": "^5.45.0", 46 | "eslint": "^8.28.0", 47 | "eslint-config-prettier": "^8.5.0", 48 | "eslint-plugin-svelte3": "^4.0.0", 49 | "prettier": "^2.8.0", 50 | "prettier-plugin-svelte": "^2.8.1", 51 | "publint": "^0.1.9", 52 | "semantic-release": "21.0.1", 53 | "svelte": "^3.54.0", 54 | "svelte-check": "^3.0.1", 55 | "tslib": "^2.4.1", 56 | "typescript": "^5.0.0", 57 | "vite": "^4.2.0", 58 | "vitest": "^0.25.3" 59 | }, 60 | "svelte": "./dist/index.js", 61 | "types": "./dist/index.d.ts", 62 | "bugs": { 63 | "url": "https://github.com/hurtigruten/svelte-table/issues" 64 | }, 65 | "type": "module", 66 | "dependencies": { 67 | "@testing-library/jest-dom": "^5.16.5", 68 | "@testing-library/svelte": "^3.2.2", 69 | "jsdom": "^21.1.1" 70 | }, 71 | "release": { 72 | "branches": [ 73 | "main", 74 | "next" 75 | ], 76 | "plugins": [ 77 | "@semantic-release/git" 78 | ] 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'vitest'; 2 | import matchers from '@testing-library/jest-dom/matchers'; 3 | import '@testing-library/jest-dom/extend-expect'; 4 | 5 | expect.extend(matchers); 6 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | import type { expect, describe, it } from 'vitest'; 4 | 5 | declare global { 6 | interface ImportMeta { 7 | vitest: null | { 8 | expect: typeof expect; 9 | describe: typeof describe; 10 | it: typeof it; 11 | }; 12 | } 13 | 14 | namespace App { 15 | // interface Error {} 16 | // interface Locals {} 17 | // interface PageData {} 18 | // interface Platform {} 19 | } 20 | } 21 | 22 | export {}; 23 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | import { SvelteComponent } from 'svelte'; 2 | 3 | interface SvelteTableColumn { 4 | key: string; 5 | title: string; 6 | sortable?: boolean; 7 | sortBy?: (a: T, b: T) => number; 8 | } 9 | 10 | interface SvelteTableProps { 11 | columns: SvelteTableColumn[]; 12 | rows: T[]; 13 | classes?: Partial< 14 | Record< 15 | | 'table' 16 | | 'thead' 17 | | 'headtr' 18 | | 'th' 19 | | 'tbody' 20 | | 'tr' 21 | | 'tr-expanded' 22 | | 'td' 23 | | 'cell' 24 | | 'helpButton' 25 | | 'sortingButton' 26 | | 'paginationContainer' 27 | | 'paginationInfo' 28 | | 'paginationButtons', 29 | string 30 | > 31 | >; 32 | isSortable?: boolean; 33 | rowsPerPage?: number; 34 | currentPage?: number; 35 | asyncPagination?: boolean; 36 | from?: number; 37 | to?: number; 38 | totalItems?: number; 39 | totalPages?: number; 40 | } 41 | 42 | interface PaginationProps { 43 | classes?: Partial< 44 | Record< 45 | 'paginationContainer' | 'paginationInfo' | 'paginationButtons', 46 | string 47 | > 48 | >; 49 | totalItems: number; 50 | from: number; 51 | to: number; 52 | nextPage: () => void; 53 | prevPage: () => void; 54 | lastPage: () => void; 55 | firstPage: () => void; 56 | enabled: { 57 | firstPage: boolean; 58 | prevPage: boolean; 59 | nextPage: boolean; 60 | lastPage: boolean; 61 | }; 62 | } 63 | 64 | declare class SvelteTable extends SvelteComponent { 65 | $$prop_def: SvelteTableProps; 66 | } 67 | 68 | declare class Pagination extends SvelteComponent { 69 | $$prop_def: PaginationProps; 70 | } 71 | 72 | export { Pagination, SvelteTable, SvelteTableColumn, SvelteTableProps }; 73 | -------------------------------------------------------------------------------- /src/lib/Pagination.svelte: -------------------------------------------------------------------------------- 1 | 16 | 17 | 59 | 60 | 67 | -------------------------------------------------------------------------------- /src/lib/Pagination.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import { render, fireEvent } from '@testing-library/svelte'; 5 | import { vi } from 'vitest'; 6 | 7 | import Pagination from './Pagination.svelte'; 8 | 9 | const noop = () => undefined; 10 | 11 | describe('Pagination', () => { 12 | it('should display total results and pagination page info', () => { 13 | const { getByText, getAllByRole } = render(Pagination, { 14 | from: 0, 15 | to: 10, 16 | nextPage: noop, 17 | prevPage: noop, 18 | firstPage: noop, 19 | lastPage: noop, 20 | totalItems: 25, 21 | enabled: { 22 | prevPage: true, 23 | firstPage: true, 24 | nextPage: true, 25 | lastPage: true 26 | } 27 | }); 28 | 29 | expect(getByText('0-10 of 25')).toBeInTheDocument(); 30 | expect(getAllByRole('button')).toHaveLength(4); 31 | }); 32 | 33 | it('should invoke pagination methods when button clicked', async () => { 34 | const prevPage = vi.fn(); 35 | const nextPage = vi.fn(); 36 | const firstPage = vi.fn(); 37 | const lastPage = vi.fn(); 38 | 39 | const { getByText, getAllByRole } = render(Pagination, { 40 | from: 0, 41 | to: 10, 42 | nextPage, 43 | prevPage, 44 | firstPage, 45 | lastPage, 46 | totalItems: 25, 47 | enabled: { 48 | prevPage: true, 49 | firstPage: true, 50 | nextPage: true, 51 | lastPage: true 52 | } 53 | }); 54 | 55 | await fireEvent.click(getByText('First')); 56 | expect(firstPage).toBeCalledTimes(1); 57 | await fireEvent.click(getByText('Prev')); 58 | expect(prevPage).toBeCalledTimes(1); 59 | await fireEvent.click(getByText('Next')); 60 | expect(nextPage).toBeCalledTimes(1); 61 | await fireEvent.click(getByText('Last')); 62 | expect(lastPage).toBeCalledTimes(1); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/lib/SortIcon.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 | 14 | 19 | 24 | 25 | -------------------------------------------------------------------------------- /src/lib/Table.svelte: -------------------------------------------------------------------------------- 1 | 131 | 132 |
133 | 134 | 135 | {#if $$slots.headTr} 136 | 144 | {:else} 145 | 146 | {#each columns as column, columnIndex} 147 | {@const ariaSort = 148 | lastSortedTitle === column.title 149 | ? sortDescending 150 | ? 'descending' 151 | : 'ascending' 152 | : 'none'} 153 | 189 | {/each} 190 | 191 | {/if} 192 | 193 | 194 | {#each filteredRows as row, rowIndex} 195 | {@const isExpanded = row.isExpanded} 196 | {@const isEven = rowIndex % 2 === 0} 197 | dispatch('clickRow', { event, row, rowIndex })} 203 | > 204 | {#each columns as column, columnIndex} 205 | 238 | {/each} 239 | 240 | {#if row.isExpanded} 241 | dispatch('clickRow', { row, rowIndex })} {row} /> 242 | {/if} 243 | {:else} 244 | 245 | {/each} 246 | 247 |
{ 157 | dispatch('clickCol', { event, column, columnIndex }); 158 | }} 159 | > 160 | {#if $$slots.head} 161 | 168 | {:else if isSortable && column.sortable !== false} 169 | {#if $$slots.sortButton} 170 | 176 | {:else} 177 | 184 | {/if} 185 | {:else} 186 | {column.title} 187 | {/if} 188 |
{ 208 | if (event.key === 'Enter') { 209 | dispatch('clickCol', { event, column, columnIndex }); 210 | dispatch('clickCell', { 211 | event, 212 | column, 213 | columnIndex, 214 | row, 215 | rowIndex, 216 | cell: column.key(row) 217 | }); 218 | } 219 | }} 220 | on:click={(event) => { 221 | dispatch('clickCol', { event, column, columnIndex }); 222 | dispatch('clickCell', { 223 | event, 224 | column, 225 | columnIndex, 226 | row, 227 | rowIndex, 228 | cell: column.key(row) 229 | }); 230 | }} 231 | > 232 | {#if $$slots.cell} 233 | 234 | {:else} 235 | {column.key(row)} 236 | {/if} 237 |
248 | {#if rowsPerPage && totalPages > 1} 249 | {#if $$slots.pagination} 250 | 265 | {:else} 266 | 267 | {/if} 268 | {/if} 269 |
270 | 271 | 325 | -------------------------------------------------------------------------------- /src/lib/Table.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @vitest-environment jsdom 3 | */ 4 | import type { Column } from './types'; 5 | import { render, fireEvent } from '@testing-library/svelte'; 6 | import { vi } from 'vitest'; 7 | 8 | import Table from './Table.svelte'; 9 | 10 | const noop = () => undefined; 11 | 12 | describe('Table', () => { 13 | it('should render a simple table', () => { 14 | const rows = [ 15 | { name: 'Adam', age: 30, location: 'Norway', active: true }, 16 | { name: 'Sarah', age: 25, location: 'USA', active: true }, 17 | { name: 'James', age: 45, location: 'UK', active: true } 18 | ] as const; 19 | 20 | const columns: readonly Column<(typeof rows)[number]>[] = [ 21 | { key: (x) => x.name, title: 'Name' }, 22 | { key: (x) => x.location, title: 'Place' }, 23 | { key: (x) => x.age, title: 'Years' } 24 | ]; 25 | 26 | const { getAllByRole } = render(Table, { 27 | columns, 28 | rows 29 | }); 30 | 31 | expect(getAllByRole('cell')[0]).toHaveTextContent('Adam'); 32 | expect(getAllByRole('cell')[1]).toHaveTextContent('Norway'); 33 | expect(getAllByRole('cell')[2]).toHaveTextContent('30'); 34 | expect(getAllByRole('cell')[3]).toHaveTextContent('Sarah'); 35 | expect(getAllByRole('cell')[4]).toHaveTextContent('USA'); 36 | expect(getAllByRole('cell')[5]).toHaveTextContent('25'); 37 | expect(getAllByRole('cell')[6]).toHaveTextContent('James'); 38 | expect(getAllByRole('cell')[7]).toHaveTextContent('UK'); 39 | expect(getAllByRole('cell')[8]).toHaveTextContent('45'); 40 | }); 41 | 42 | it('should sort columns', async () => { 43 | const rows = [ 44 | { name: 'Adam', age: 30, location: 'Norway', active: true }, 45 | { name: 'Sarah', age: 25, location: 'USA', active: true }, 46 | { name: 'James', age: 45, location: 'UK', active: true } 47 | ] as const; 48 | 49 | const columns: readonly Column<(typeof rows)[number]>[] = [ 50 | { key: (x) => x.name, title: 'Name' }, 51 | { key: (x) => x.location, title: 'Place' }, 52 | { key: (x) => x.age, title: 'Years' } 53 | ]; 54 | 55 | const { getAllByRole, getByText } = render(Table, { 56 | columns, 57 | rows 58 | }); 59 | 60 | await fireEvent.click(getByText('Years')); 61 | expect(getAllByRole('cell')[0]).toHaveTextContent('Sarah'); 62 | expect(getAllByRole('cell')[1]).toHaveTextContent('USA'); 63 | expect(getAllByRole('cell')[2]).toHaveTextContent('25'); 64 | expect(getAllByRole('cell')[3]).toHaveTextContent('Adam'); 65 | expect(getAllByRole('cell')[4]).toHaveTextContent('Norway'); 66 | expect(getAllByRole('cell')[5]).toHaveTextContent('30'); 67 | expect(getAllByRole('cell')[6]).toHaveTextContent('James'); 68 | expect(getAllByRole('cell')[7]).toHaveTextContent('UK'); 69 | expect(getAllByRole('cell')[8]).toHaveTextContent('45'); 70 | 71 | await fireEvent.click(getByText('Years')); 72 | expect(getAllByRole('cell')[0]).toHaveTextContent('James'); 73 | expect(getAllByRole('cell')[1]).toHaveTextContent('UK'); 74 | expect(getAllByRole('cell')[2]).toHaveTextContent('45'); 75 | expect(getAllByRole('cell')[3]).toHaveTextContent('Adam'); 76 | expect(getAllByRole('cell')[4]).toHaveTextContent('Norway'); 77 | expect(getAllByRole('cell')[5]).toHaveTextContent('30'); 78 | expect(getAllByRole('cell')[6]).toHaveTextContent('Sarah'); 79 | expect(getAllByRole('cell')[7]).toHaveTextContent('USA'); 80 | expect(getAllByRole('cell')[8]).toHaveTextContent('25'); 81 | 82 | await fireEvent.click(getByText('Name')); 83 | expect(getAllByRole('cell')[0]).toHaveTextContent('Adam'); 84 | expect(getAllByRole('cell')[1]).toHaveTextContent('Norway'); 85 | expect(getAllByRole('cell')[2]).toHaveTextContent('30'); 86 | expect(getAllByRole('cell')[3]).toHaveTextContent('James'); 87 | expect(getAllByRole('cell')[4]).toHaveTextContent('UK'); 88 | expect(getAllByRole('cell')[5]).toHaveTextContent('45'); 89 | expect(getAllByRole('cell')[6]).toHaveTextContent('Sarah'); 90 | expect(getAllByRole('cell')[7]).toHaveTextContent('USA'); 91 | expect(getAllByRole('cell')[8]).toHaveTextContent('25'); 92 | 93 | await fireEvent.click(getByText('Name')); 94 | expect(getAllByRole('cell')[0]).toHaveTextContent('Sarah'); 95 | expect(getAllByRole('cell')[1]).toHaveTextContent('USA'); 96 | expect(getAllByRole('cell')[2]).toHaveTextContent('25'); 97 | expect(getAllByRole('cell')[3]).toHaveTextContent('James'); 98 | expect(getAllByRole('cell')[4]).toHaveTextContent('UK'); 99 | expect(getAllByRole('cell')[5]).toHaveTextContent('45'); 100 | expect(getAllByRole('cell')[6]).toHaveTextContent('Adam'); 101 | expect(getAllByRole('cell')[7]).toHaveTextContent('Norway'); 102 | expect(getAllByRole('cell')[8]).toHaveTextContent('30'); 103 | 104 | await fireEvent.click(getByText('Place')); 105 | expect(getAllByRole('cell')[0]).toHaveTextContent('Adam'); 106 | expect(getAllByRole('cell')[1]).toHaveTextContent('Norway'); 107 | expect(getAllByRole('cell')[2]).toHaveTextContent('30'); 108 | expect(getAllByRole('cell')[3]).toHaveTextContent('James'); 109 | expect(getAllByRole('cell')[4]).toHaveTextContent('UK'); 110 | expect(getAllByRole('cell')[5]).toHaveTextContent('45'); 111 | expect(getAllByRole('cell')[6]).toHaveTextContent('Sarah'); 112 | expect(getAllByRole('cell')[7]).toHaveTextContent('USA'); 113 | expect(getAllByRole('cell')[8]).toHaveTextContent('25'); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /src/lib/index.d.ts: -------------------------------------------------------------------------------- 1 | import { SvelteComponent } from 'svelte'; 2 | 3 | interface SvelteTableColumn { 4 | key: string; 5 | title: string; 6 | sortable?: boolean; 7 | sortBy?: (a: T, b: T) => number; 8 | } 9 | 10 | interface SvelteTableProps { 11 | columns: SvelteTableColumn[]; 12 | rows: T[]; 13 | classes?: Partial< 14 | Record< 15 | | 'table' 16 | | 'thead' 17 | | 'headtr' 18 | | 'th' 19 | | 'tbody' 20 | | 'tr' 21 | | 'tr-expanded' 22 | | 'td' 23 | | 'cell' 24 | | 'helpButton' 25 | | 'sortingButton' 26 | | 'paginationContainer' 27 | | 'paginationInfo' 28 | | 'paginationButtons', 29 | string 30 | > 31 | >; 32 | isSortable?: boolean; 33 | rowsPerPage?: number; 34 | currentPage?: number; 35 | asyncPagination?: boolean; 36 | from?: number; 37 | to?: number; 38 | totalItems?: number; 39 | totalPages?: number; 40 | } 41 | 42 | interface PaginationProps { 43 | classes?: Partial< 44 | Record< 45 | 'paginationContainer' | 'paginationInfo' | 'paginationButtons', 46 | string 47 | > 48 | >; 49 | totalItems: number; 50 | from: number; 51 | to: number; 52 | nextPage: () => void; 53 | prevPage: () => void; 54 | lastPage: () => void; 55 | firstPage: () => void; 56 | enabled: { 57 | firstPage: boolean; 58 | prevPage: boolean; 59 | nextPage: boolean; 60 | lastPage: boolean; 61 | }; 62 | } 63 | 64 | declare class SvelteTable extends SvelteComponent { 65 | $$prop_def: SvelteTableProps; 66 | } 67 | 68 | declare class Pagination extends SvelteComponent { 69 | $$prop_def: PaginationProps; 70 | } 71 | 72 | export { Pagination, SvelteTable, SvelteTableColumn, SvelteTableProps }; 73 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Table } from './Table.svelte'; 2 | export { default as Pagination } from './Pagination.svelte'; 3 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | export interface Column { 2 | title: string; 3 | key: (a: T) => string | number | boolean; 4 | sort?: (a: T, b: T) => number; 5 | sortable?: boolean; 6 | } 7 | 8 | export type ColumnClickEvent = CustomEvent<{ 9 | event: Event; 10 | column: Column; 11 | columnIndex: number; 12 | }>; 13 | 14 | export type RowClickEvent = CustomEvent<{ event?: Event; row: T; rowIndex: number }>; 15 | 16 | export type CellClickEvent = CustomEvent<{ 17 | event: Event; 18 | row: T; 19 | rowIndex: number; 20 | column: Column; 21 | columnIndex: number; 22 | cell: string | number | boolean; 23 | }>; 24 | -------------------------------------------------------------------------------- /src/lib/utils/sort/compareBool.ts: -------------------------------------------------------------------------------- 1 | export const compareBool = (a: boolean, _b?: boolean) => (a ? -1 : 1); 2 | 3 | if (import.meta.vitest) { 4 | const { describe, it, expect } = import.meta.vitest; 5 | 6 | describe('compareBool', () => { 7 | it('should return -1 if a is false', () => { 8 | expect(compareBool(true)).toEqual(-1); 9 | }); 10 | it('should return 1 if a is false', () => { 11 | expect(compareBool(false)).toEqual(1); 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/utils/sort/compareDyn.ts: -------------------------------------------------------------------------------- 1 | import { compareBool } from './compareBool'; 2 | import { compareNum } from './compareNum'; 3 | import { compareString } from './compareString'; 4 | 5 | export const compareDyn = (a: T, b: T) => { 6 | if (typeof a === 'boolean') return compareBool(a); 7 | if (typeof a === 'number' && typeof b === 'number') return compareNum(a, b); 8 | 9 | return compareString(String(a), String(b)); 10 | }; 11 | 12 | if (import.meta.vitest) { 13 | const { describe, it, expect } = import.meta.vitest; 14 | 15 | describe('compareDyn', () => { 16 | it('should use bool strategy', () => { 17 | expect(compareDyn(true, false)).toEqual(-1); 18 | expect(compareDyn(false, true)).toEqual(1); 19 | }); 20 | it('should use number strategy', () => { 21 | expect(compareDyn(3, 3)).toEqual(0); 22 | expect(compareDyn(1, 5)).toEqual(-1); 23 | expect(compareDyn(5, 1)).toEqual(1); 24 | }); 25 | it('should use string strategy', () => { 26 | expect(compareDyn('A', 'Z')).toEqual(-1); 27 | expect(compareDyn('Z', 'A')).toEqual(1); 28 | expect(compareDyn('B', 'B')).toEqual(0); 29 | }); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/utils/sort/compareNum.ts: -------------------------------------------------------------------------------- 1 | export const compareNum = (a: number, b: number) => { 2 | if (a === b) return 0; 3 | return a > b ? 1 : -1; 4 | }; 5 | 6 | if (import.meta.vitest) { 7 | const { describe, it, expect } = import.meta.vitest; 8 | 9 | describe('compareString', () => { 10 | it('should return -1 if a is less than b', () => { 11 | expect(compareNum(1, 3)).toEqual(-1); 12 | }); 13 | it('should return 0 if a is same as b', () => { 14 | expect(compareNum(3, 3)).toEqual(0); 15 | }); 16 | it('should return 1 if a is higher than b', () => { 17 | expect(compareNum(5, 1)).toEqual(1); 18 | }); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/utils/sort/compareString.ts: -------------------------------------------------------------------------------- 1 | export const compareString = (a: string, b: string) => a.localeCompare(b); 2 | 3 | if (import.meta.vitest) { 4 | const { describe, it, expect } = import.meta.vitest; 5 | 6 | describe('compareString', () => { 7 | it('should return -1 if a is less than b', () => { 8 | expect(compareString('Alphabet', 'Blphabet')).toEqual(-1); 9 | }); 10 | it('should return 0 if a is same as b', () => { 11 | expect(compareString('Alphabet', 'Alphabet')).toEqual(0); 12 | }); 13 | it('should return 1 if a is higher than b', () => { 14 | expect(compareString('Blphabet', 'Alphabet')).toEqual(1); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/sort/sortWith.ts: -------------------------------------------------------------------------------- 1 | import { toSorted } from './toSorted'; 2 | import { compareDyn } from './compareDyn'; 3 | 4 | export const sortWith = ( 5 | collection: readonly T[], 6 | predicate: (a: T) => number | string | boolean 7 | ) => { 8 | return toSorted(collection, (a, b) => compareDyn(predicate(a), predicate(b))); 9 | }; 10 | 11 | if (import.meta.vitest) { 12 | const { describe, it, expect } = import.meta.vitest; 13 | 14 | const collection = [ 15 | { name: 'Kate', isActive: true, age: 23 }, 16 | { name: 'Jared', isActive: false, age: 15 }, 17 | { name: 'Adam', isActive: true, age: 12 }, 18 | { name: 'Bogdan', isActive: false, age: 66 } 19 | ] as const; 20 | 21 | describe('sortWith', () => { 22 | it('should sort by predicate (string)', () => { 23 | const result = sortWith(collection, (obj) => obj.name); 24 | 25 | expect(result).not.toBe(collection); 26 | expect(result).toEqual([ 27 | { name: 'Adam', isActive: true, age: 12 }, 28 | { name: 'Bogdan', isActive: false, age: 66 }, 29 | { name: 'Jared', isActive: false, age: 15 }, 30 | { name: 'Kate', isActive: true, age: 23 } 31 | ]); 32 | }); 33 | 34 | it('should sort by predicate (boolean)', () => { 35 | const result = sortWith(collection, (obj) => obj.isActive); 36 | 37 | expect(result).not.toBe(collection); 38 | expect(result).toEqual([ 39 | { name: 'Adam', isActive: true, age: 12 }, 40 | { name: 'Kate', isActive: true, age: 23 }, 41 | { name: 'Jared', isActive: false, age: 15 }, 42 | { name: 'Bogdan', isActive: false, age: 66 } 43 | ]); 44 | }); 45 | 46 | it('should sort by predicate (int)', () => { 47 | const result = sortWith(collection, (obj) => obj.age); 48 | 49 | expect(result).not.toBe(collection); 50 | expect(sortWith(collection, (obj) => obj.age)).toEqual([ 51 | { name: 'Adam', isActive: true, age: 12 }, 52 | { name: 'Jared', isActive: false, age: 15 }, 53 | { name: 'Kate', isActive: true, age: 23 }, 54 | { name: 'Bogdan', isActive: false, age: 66 } 55 | ]); 56 | }); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /src/lib/utils/sort/toReversed.ts: -------------------------------------------------------------------------------- 1 | // This is a polyfill for an API that reverts an array without mutating it 2 | // https://www.npmjs.com/package/array.prototype.toReverted 3 | 4 | export const toReverted = (arr: readonly T[]) => [...arr].reverse(); 5 | 6 | if (import.meta.vitest) { 7 | const { describe, it, expect } = import.meta.vitest; 8 | 9 | describe('toReverted', () => { 10 | it('should revert the order without mutating the input', () => { 11 | const input = ['z', 'b', 'c', 'a']; 12 | 13 | expect(toReverted(input)).toEqual(['a', 'c', 'b', 'z']); 14 | expect(toReverted(input)).not.toEqual(input); 15 | }); 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/sort/toSorted.ts: -------------------------------------------------------------------------------- 1 | // This is a polyfill for an API that sorts an array without mutating it 2 | // https://github.com/tc39/proposal-change-array-by-copy 3 | 4 | export const toSorted = ( 5 | arr: readonly T[], 6 | sortFn: Parameters<(typeof Array)['prototype']['sort']>[0] = undefined 7 | ) => [...arr].sort(sortFn); 8 | 9 | if (import.meta.vitest) { 10 | const { describe, it, expect } = import.meta.vitest; 11 | 12 | describe('toSorted', () => { 13 | it('should sort an array without sortFn', () => { 14 | const input = ['z', 'b', 'c', 'a']; 15 | 16 | expect(toSorted(input)).toEqual(['a', 'b', 'c', 'z']); 17 | expect(toSorted(input)).not.toEqual(input); 18 | }); 19 | 20 | it('should sort numbers', () => { 21 | const input = [100, 5, 25, 1]; 22 | const sortFn = (a: number, b: number) => a - b; 23 | 24 | expect(toSorted(input, sortFn)).toEqual([1, 5, 25, 100]); 25 | expect(toSorted(input, sortFn)).not.toEqual(input); 26 | }); 27 | it('should not mutate the array', () => { 28 | const input = [1, 2, 3, 4, 5]; 29 | const sortFn = (a: number, b: number) => b - a; 30 | 31 | expect(toSorted(input, sortFn)).toEqual([5, 4, 3, 2, 1]); 32 | expect(toSorted(input, sortFn)).not.toEqual(input); 33 | }); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 20 | 21 |

Welcome to your library project

22 |

Create your package using @sveltejs/package and preview/showcase your work with SvelteKit

23 |

Visit kit.svelte.dev to read the documentation

24 | 25 | 26 | 27 | 34 | 35 | x.detail} 40 | --height="50px" 41 | bind:sortRowsBy 42 | /> 43 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hurtigruten/svelte-table/aaff36ea151b54c59e4a7eb07789a1b015ca9352/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 7 | // for more information about preprocessors 8 | preprocess: vitePreprocess(), 9 | 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter() 15 | } 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true 12 | } 13 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 14 | // 15 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 16 | // from the referenced tsconfig.json - TypeScript does not merge them in 17 | } 18 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | globals: true, 8 | css: false, 9 | passWithNoTests: true, 10 | setupFiles: ['./setupTests.ts'], 11 | include: ['src/**/*.{test,spec}.{js,ts}', 'src/lib/utils/**/*.{js,ts}'] 12 | } 13 | }); 14 | --------------------------------------------------------------------------------