diff --git a/custom-icons/sortColumn.svg b/custom-icons/sortColumn.svg
new file mode 100644
index 000000000..e4cd4058c
--- /dev/null
+++ b/custom-icons/sortColumn.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/docs/pages/ktable.vue b/docs/pages/ktable.vue
new file mode 100644
index 000000000..b2e28580f
--- /dev/null
+++ b/docs/pages/ktable.vue
@@ -0,0 +1,153 @@
+
+
+
+
+
+ The KTable
component is an accessible and customizable table component designed to handle a variety of data presentation needs. The component is suitable for both simple and complex data tables. It offers:
+
+ Offers built-in sorting by default
+ Integrates with already sorted data
+ Keyboard navigation
+ Dynamic column resizing
+ Sticky headers
+
+
+
+
+
+ Table without sorting functionality
+
+ This is an example to show how KTable
can be used without any sorting functionality, as a simple table. Use of slots is optional.
+
+
+
+
+
+
+ { header.label } (Backend)
+
+
+ { content } (City)
+ { content }
+
+
+
+
+
+
+ data() {
+ return {
+ headers: [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ { label: 'City', dataType: 'string' },
+ ],
+ rows: [
+ ['John Doe', 28, 'New York'],
+ ['Jane Smith', 34, 'Los Angeles'],
+ ['Samuel Green', 22, 'Chicago'],
+ ['Alice Johnson', 30, 'Houston'],
+ ['Michael Brown', 45, 'Phoenix'],
+ ['Emily Davis', 27, 'Philadelphia'],
+ ]
+ };
+ },
+
+
+
+
+
+ {{ header.label }} (Backend)
+
+
+ {{ content }} (City)
+ {{ content }}
+
+
+
+
+
+ Table with Default Sorting
+
+ The KTable
can be used with default sorting functionality, allowing you to sort data on the client side without the need for server requests. There are 4 permissible data types - string
,number
,date
and undefined
. Columns declared with undefined
data type are not sortable. This example demonstrates a table with default sorting enabled.
+
+
+
+
+
+
+
+
+ data() {
+ return {
+ headers: [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ { label: 'City', dataType: 'string' },
+ ],
+ rows: [
+ ['John Doe', 28, 'New York'],
+ ['Jane Smith', 34, 'Los Angeles'],
+ ['Samuel Green', 22, 'Chicago'],
+ ['Alice Johnson', 30, 'Houston'],
+ ['Michael Brown', 45, 'Phoenix'],
+ ['Emily Davis', 27, 'Philadelphia'],
+ ]
+ };
+ },
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/docs/rstIconReplacements.txt b/docs/rstIconReplacements.txt
index e30081ab3..93deb7f5e 100644
--- a/docs/rstIconReplacements.txt
+++ b/docs/rstIconReplacements.txt
@@ -162,6 +162,7 @@
.. |skillsResource| replace:: :raw-html:` `
.. |slideshow| replace:: :raw-html:` `
.. |socialSciencesResource| replace:: :raw-html:` `
+.. |sortColumn| replace:: :raw-html:` `
.. |sort| replace:: :raw-html:` `
.. |starBorder| replace:: :raw-html:` `
.. |star| replace:: :raw-html:` `
diff --git a/docs/tableOfContents.js b/docs/tableOfContents.js
index 98b1011b3..451c38f30 100644
--- a/docs/tableOfContents.js
+++ b/docs/tableOfContents.js
@@ -340,6 +340,11 @@ export default [
isCode: true,
keywords: ['button'],
}),
+ new Page({
+ path: '/ktable',
+ title: 'KTable',
+ isCode: true,
+ }),
new Page({
path: '/kgrid',
title: 'KGrid',
diff --git a/lib/KIcon/iconDefinitions.js b/lib/KIcon/iconDefinitions.js
index 40a917d4f..bf2f7c279 100644
--- a/lib/KIcon/iconDefinitions.js
+++ b/lib/KIcon/iconDefinitions.js
@@ -72,6 +72,9 @@ const KolibriIcons = {
icon: require('./precompiled-icons/le/preview-unavailable.vue').default,
},
sort: { icon: require('./precompiled-icons/le/sort.vue').default },
+ sortColumn: {
+ icon: require('./precompiled-icons/le/sortColumn.vue').default,
+ },
// Features and links
learn: { icon: require('./precompiled-icons/material-icons/school/baseline.vue').default },
diff --git a/lib/KIcon/precompiled-icons/le/sortColumn.vue b/lib/KIcon/precompiled-icons/le/sortColumn.vue
new file mode 100644
index 000000000..342a023fe
--- /dev/null
+++ b/lib/KIcon/precompiled-icons/le/sortColumn.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/KTable/KTableGridItem.vue b/lib/KTable/KTableGridItem.vue
new file mode 100644
index 000000000..4391e2ef7
--- /dev/null
+++ b/lib/KTable/KTableGridItem.vue
@@ -0,0 +1,85 @@
+
+
+
+
+ {{ content }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/KTable/index.vue b/lib/KTable/index.vue
new file mode 100644
index 000000000..4d41e05db
--- /dev/null
+++ b/lib/KTable/index.vue
@@ -0,0 +1,567 @@
+
+
+
+
+
+
+
+
+
+ {{ caption }}
+
+
+
+
+
+
+ {{ header.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ slotProps.content }}
+
+
+
+
+
+
+
+ {{ emptyMessage }}
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/lib/KTable/useSorting/__tests__/index.spec.js b/lib/KTable/useSorting/__tests__/index.spec.js
new file mode 100644
index 000000000..5449accb3
--- /dev/null
+++ b/lib/KTable/useSorting/__tests__/index.spec.js
@@ -0,0 +1,112 @@
+import { ref } from '@vue/composition-api';
+import useSorting, {
+ SORT_ORDER_ASC,
+ SORT_ORDER_DESC,
+ DATA_TYPE_STRING,
+ DATA_TYPE_NUMERIC,
+ DATA_TYPE_DATE,
+ DATA_TYPE_OTHERS,
+} from '../';
+
+describe('useSorting', () => {
+ let headers, rows, useLocalSorting;
+
+ beforeEach(() => {
+ headers = ref([
+ { label: 'Name', dataType: DATA_TYPE_STRING },
+ { label: 'Age', dataType: DATA_TYPE_NUMERIC },
+ { label: 'Birthdate', dataType: DATA_TYPE_DATE },
+ { label: 'Other', dataType: DATA_TYPE_OTHERS },
+ ]);
+
+ rows = ref([
+ ['John', 30, new Date(1990, 5, 15)],
+ ['Jane', 25, new Date(1995, 10, 20)],
+ ['Alice', 28, new Date(1992, 8, 10)],
+ ]);
+
+ useLocalSorting = ref(true);
+ });
+
+ it('should return rows unsorted when useLocalSorting is false', () => {
+ useLocalSorting.value = false;
+
+ const { sortedRows } = useSorting(headers, rows, useLocalSorting);
+ expect(sortedRows.value).toEqual(rows.value);
+ });
+
+ it('should sort rows by string column in ascending order', () => {
+ const { handleSort, sortedRows } = useSorting(headers, rows, useLocalSorting);
+
+ handleSort(0); // Sort by 'Name'
+
+ expect(sortedRows.value).toEqual([
+ ['Alice', 28, new Date(1992, 8, 10)],
+ ['Jane', 25, new Date(1995, 10, 20)],
+ ['John', 30, new Date(1990, 5, 15)],
+ ]);
+ });
+
+ it('should sort rows by numeric column in ascending and descending order', () => {
+ const { handleSort, sortedRows, sortOrder } = useSorting(headers, rows, useLocalSorting);
+
+ handleSort(1); // Sort by 'Age'
+ expect(sortedRows.value).toEqual([
+ ['Jane', 25, new Date(1995, 10, 20)],
+ ['Alice', 28, new Date(1992, 8, 10)],
+ ['John', 30, new Date(1990, 5, 15)],
+ ]);
+ expect(sortOrder.value).toBe(SORT_ORDER_ASC);
+
+ handleSort(1); // Sort by 'Age' again to toggle order
+ expect(sortedRows.value).toEqual([
+ ['John', 30, new Date(1990, 5, 15)],
+ ['Alice', 28, new Date(1992, 8, 10)],
+ ['Jane', 25, new Date(1995, 10, 20)],
+ ]);
+ expect(sortOrder.value).toBe(SORT_ORDER_DESC);
+ });
+
+ it('should sort rows by date column in ascending order', () => {
+ const { handleSort, sortedRows } = useSorting(headers, rows, useLocalSorting);
+
+ handleSort(2); // Sort by 'Birthdate'
+ expect(sortedRows.value).toEqual([
+ ['John', 30, new Date(1990, 5, 15)],
+ ['Alice', 28, new Date(1992, 8, 10)],
+ ['Jane', 25, new Date(1995, 10, 20)],
+ ]);
+ });
+
+ it('should not sort rows when sorting by a column with dataType "undefined"', () => {
+ const { handleSort, sortedRows, sortKey } = useSorting(headers, rows, useLocalSorting);
+
+ handleSort(3); // Attempt to sort by 'Other'
+ expect(sortedRows.value).toEqual(rows.value);
+ expect(sortKey.value).toBe(null); // sortKey should remain null
+ });
+
+ it('should return correct aria-sort attribute based on current sorting', () => {
+ const { handleSort, getAriaSort } = useSorting(headers, rows, useLocalSorting);
+
+ expect(getAriaSort(0)).toBe('none');
+
+ handleSort(0); // Sort by 'Name'
+ expect(getAriaSort(0)).toBe('ascending');
+
+ handleSort(0); // Toggle sort order by 'Name'
+ expect(getAriaSort(0)).toBe('descending');
+ });
+
+ it('should reset sortKey and sortOrder when a new column is sorted', () => {
+ const { handleSort, sortKey, sortOrder } = useSorting(headers, rows, useLocalSorting);
+
+ handleSort(0); // Sort by 'Name'
+ expect(sortKey.value).toBe(0);
+ expect(sortOrder.value).toBe(SORT_ORDER_ASC);
+
+ handleSort(1); // Sort by 'Age'
+ expect(sortKey.value).toBe(1);
+ expect(sortOrder.value).toBe(SORT_ORDER_ASC);
+ });
+});
diff --git a/lib/KTable/useSorting/index.js b/lib/KTable/useSorting/index.js
new file mode 100644
index 000000000..fdb8fd8c5
--- /dev/null
+++ b/lib/KTable/useSorting/index.js
@@ -0,0 +1,68 @@
+import { ref, computed } from '@vue/composition-api';
+import _ from 'lodash';
+
+export const SORT_ORDER_ASC = 'asc';
+export const SORT_ORDER_DESC = 'desc';
+export const DATA_TYPE_STRING = 'string';
+export const DATA_TYPE_NUMERIC = 'number';
+export const DATA_TYPE_DATE = 'date';
+export const DATA_TYPE_OTHERS = 'undefined';
+
+/**
+ * Custom hook for handling sorting logic in a table.
+ *
+ * @param {Ref} headers - Reactive reference to the table headers.
+ * @param {Ref} rows - Reactive reference to the table rows.
+ * @param {Ref} useLocalSorting - Reactive reference to a boolean indicating if local sorting should be used.
+ * @returns {Object} - An object containing reactive references and methods for sorting.
+ */
+export default function useSorting(headers, rows, useLocalSorting) {
+ const sortKey = ref(null);
+ const sortOrder = ref(null);
+ /**
+ * Computed property that returns the sorted rows based on the current sort key and order.
+ * If local sorting is disabled or no sort key is set, it returns the original rows.
+ */
+ const sortedRows = computed(() => {
+ if (!useLocalSorting.value || sortKey.value === null || sortOrder.value === null)
+ return rows.value;
+
+ return _.orderBy(rows.value, [row => row[sortKey.value]], [sortOrder.value]);
+ });
+ /**
+ * Method to handle sorting when a column header is clicked.
+ *
+ * @param {Number} index - The index of the column to sort by.
+ */
+ const handleSort = index => {
+ if (headers.value[index].dataType === DATA_TYPE_OTHERS) return;
+
+ if (sortKey.value === index) {
+ sortOrder.value = sortOrder.value === SORT_ORDER_ASC ? SORT_ORDER_DESC : SORT_ORDER_ASC;
+ } else {
+ sortKey.value = index;
+ sortOrder.value = SORT_ORDER_ASC;
+ }
+ };
+ /**
+ * Method to get the ARIA sort attribute value for a column header.
+ *
+ * @param {Number} index - The index of the column.
+ * @returns {String} - The ARIA sort attribute value ('ascending', 'descending', or 'none').
+ */
+ const getAriaSort = index => {
+ if (sortKey.value === index) {
+ return sortOrder.value === SORT_ORDER_ASC ? 'ascending' : 'descending';
+ } else {
+ return 'none';
+ }
+ };
+
+ return {
+ sortKey,
+ sortOrder,
+ sortedRows,
+ handleSort,
+ getAriaSort,
+ };
+}
diff --git a/lib/KThemePlugin.js b/lib/KThemePlugin.js
index 1a8355ac0..699fb13cb 100644
--- a/lib/KThemePlugin.js
+++ b/lib/KThemePlugin.js
@@ -28,6 +28,7 @@ import KRadioButton from './KRadioButton';
import KRouterLink from './buttons-and-links/KRouterLink';
import KSelect from './KSelect';
import KSwitch from './KSwitch';
+import KTable from './KTable';
import KTabs from './tabs/KTabs';
import KTabsList from './tabs/KTabsList';
import KTabsPanel from './tabs/KTabsPanel';
@@ -149,6 +150,7 @@ export default function KThemePlugin(Vue) {
Vue.component('KRouterLink', KRouterLink);
Vue.component('KSelect', KSelect);
Vue.component('KSwitch', KSwitch);
+ Vue.component('KTable', KTable);
Vue.component('KTabs', KTabs);
Vue.component('KTabsList', KTabsList);
Vue.component('KTabsPanel', KTabsPanel);
diff --git a/lib/__tests__/KTable.spec.js b/lib/__tests__/KTable.spec.js
new file mode 100644
index 000000000..7b0bcc59d
--- /dev/null
+++ b/lib/__tests__/KTable.spec.js
@@ -0,0 +1,155 @@
+import { mount } from '@vue/test-utils';
+import KTable from '../KTable';
+
+describe('KTable.vue', () => {
+ it('should mount the component', () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ { label: 'City', dataType: 'string' },
+ ];
+ const rows = [
+ ['Alice', 25, 'New York'],
+ ['Bob', 30, 'Los Angeles'],
+ ['Charlie', 35, 'San Francisco'],
+ ];
+ const wrapper = mount(KTable, {
+ propsData: {
+ headers,
+ rows,
+ sortable: true,
+ },
+ });
+ const thElements = wrapper.findAll('th');
+ expect(thElements.length).toBe(headers.length);
+ });
+ it('renders the correct content in rows and columns', async () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ { label: 'Date', dataType: 'date' },
+ ];
+ const rows = [
+ ['John', 30, '2023-01-01'],
+ ['Jane', 25, '2023-02-01'],
+ ['Doe', 35, '2023-03-01'],
+ ];
+
+ const wrapper = mount(KTable, {
+ propsData: { headers, rows, caption: 'Test Table' },
+ });
+
+ // Wait for the table to be fully rendered
+ await wrapper.vm.$nextTick();
+
+ const tableRows = wrapper.findAll('tbody tr');
+ expect(tableRows.length).toBe(rows.length);
+
+ rows.forEach((row, rowIndex) => {
+ const cells = tableRows.at(rowIndex).findAll('td');
+ row.forEach((cellContent, colIndex) => {
+ expect(cells.at(colIndex).text()).toBe(String(cellContent));
+ });
+ });
+ });
+ it('should emit changeSort event on header click when disableDefaultSorting is true', async () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ { label: 'City', dataType: 'string' },
+ ];
+ const items = [
+ ['Alice', 25, 'New York'],
+ ['Bob', 30, 'Los Angeles'],
+ ['Charlie', 35, 'San Francisco'],
+ ];
+ const wrapper = mount(KTable, {
+ propsData: {
+ headers,
+ items,
+ sortable: true,
+ disableDefaultSorting: true,
+ },
+ computed: {
+ isTableEmpty: () => false,
+ },
+ });
+
+ const thElements = wrapper.findAll('th');
+ await thElements.at(0).trigger('click');
+ expect(wrapper.emitted().changeSort).toBeTruthy();
+ });
+ it('should handle sticky headers and columns', async () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ ];
+ const rows = [
+ ['John', 30],
+ ['Jane', 25],
+ ];
+
+ const wrapper = mount(KTable, {
+ propsData: { headers, rows, caption: 'Sticky Table' },
+ });
+
+ // Wait for the table to be fully rendered
+ await wrapper.vm.$nextTick();
+
+ const headerCells = wrapper.findAll('thead th');
+ headerCells.wrappers.forEach(headerCell => {
+ expect(headerCell.classes()).toContain('sticky-header');
+ });
+
+ const firstColumnCells = wrapper.findAll('tbody tr td:first-child');
+ firstColumnCells.wrappers.forEach(cell => {
+ expect(cell.classes()).toContain('sticky-column');
+ });
+ });
+
+ beforeEach(() => {
+ /*Since our primary concern in this test is checking focus management rather than actual scrolling behavior,
+ mocking scrollIntoView allows the test to focus on the relevant aspects without getting interrupted
+ by unsupported methods in the test environment.*/
+ window.HTMLElement.prototype.scrollIntoView = jest.fn();
+ });
+
+ it('should handle keyboard navigation within the table', async () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'number' },
+ ];
+ const rows = [
+ ['John', 30],
+ ['Jane', 25],
+ ];
+
+ const wrapper = mount(KTable, {
+ propsData: { headers, rows, caption: 'Keyboard Navigation Table' },
+ attachTo: document.body, // Attach to document body to properly manage focus
+ });
+
+ await wrapper.vm.$nextTick(); // Ensure the component is fully rendered
+
+ const firstCell = wrapper.find('tbody tr:first-child td:first-child');
+ await firstCell.element.focus(); // Focus the first cell directly
+ expect(document.activeElement).toBe(firstCell.element); // Check if the first cell is focused
+
+ // Simulate ArrowRight key press
+ await firstCell.trigger('keydown', { key: 'ArrowRight' });
+
+ const secondCell = wrapper.find('tbody tr:first-child td:nth-child(2)');
+ await secondCell.element.focus(); // Focus the second cell directly
+ expect(document.activeElement).toBe(secondCell.element); // Check if the second cell is focused
+
+ // Simulate ArrowDown key press
+ await secondCell.trigger('keydown', { key: 'ArrowDown' });
+
+ const thirdCell = wrapper.find('tbody tr:nth-child(2) td:nth-child(2)');
+ await thirdCell.element.focus(); // Focus the third cell directly
+ expect(document.activeElement).toBe(thirdCell.element); // Check if the third cell is focused
+
+ // Cleanup: detach the wrapper from the document body after the test
+ wrapper.destroy();
+ });
+});