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 @@ + + + + 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 @@ + + + + + + + \ 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 @@ + + + + + + + \ 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(); + }); +});