-
-
-
-
+
+
KTable Component Example
+
+
+
Local Sorting Table
+
+
+ {{ header.label }} (Local)
+
+
+ {{ content }} years old
+ Test
+ {{ content }}
+
+
+
+
+
Backend Sorting Table(with Custom Widths)
+
+ {{ loadingMessage }}
+
+
+
+ {{ header.label }} (Backend)
+
+
+ {{ content }} (City)
+ {{ content }}
+
+
+
+
+ Data is loading. Please wait...
+
+
Non Sortable Table
+
@@ -32,16 +64,150 @@
\ No newline at end of file
+
+
+
+
+
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 e1ce15935..3d3114990 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 84cbc3c12..e91ea48c1 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..631ddb24d
--- /dev/null
+++ b/lib/KTable/KTableGridItem.vue
@@ -0,0 +1,97 @@
+
+
+
+
+ {{ 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..ad7a5aa7d
--- /dev/null
+++ b/lib/KTable/index.vue
@@ -0,0 +1,518 @@
+
+
+
+
+
+ {{ caption }}
+
+
+
+
+
+
+
+ {{ header.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ slotProps.content }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..a7d08b6e0
--- /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 "others"', () => {
+ 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..366fd2c27
--- /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 = 'numeric';
+export const DATA_TYPE_DATE = 'date';
+export const DATA_TYPE_OTHERS = 'others';
+
+/**
+ * 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 8f6ba05a2..7f2655581 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..36cc77e10
--- /dev/null
+++ b/lib/__tests__/KTable.spec.js
@@ -0,0 +1,151 @@
+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: 'numeric' },
+ { 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,
+ useLocalSorting: 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: 'numeric' },
+ { 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 useLocalSorting is false', async () => {
+ const headers = [
+ { label: 'Name', dataType: 'string' },
+ { label: 'Age', dataType: 'numeric' },
+ { 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,
+ useLocalSorting: 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: 'numeric' },
+ ];
+ 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: 'numeric' },
+ ];
+ 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();
+ });
+});