diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 298360c8c0..56d269db0c 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -84,6 +84,7 @@ export default defineConfig({ { text: 'Slideout', link: '/components/slideout' }, { text: 'Stepper', link: '/components/stepper' }, { text: 'Table', link: '/components/table' }, + { text: 'Table View', link: '/components/table-view' }, { text: 'Tabs', link: '/components/tabs' }, { text: 'Textarea', link: '/components/textarea' }, { text: 'Toaster', link: '/components/toaster' }, diff --git a/docs/components/table-view.md b/docs/components/table-view.md new file mode 100644 index 0000000000..bacad1e294 --- /dev/null +++ b/docs/components/table-view.md @@ -0,0 +1,1091 @@ +# Table View + +Component for displaying data in table format. + +:::tip NOTE +KTableView does not handle data management capabilities like data fetching, functional pagination, sorting or searching. If you are looking for a component that integrates with the data layer, check out [KTable](/components/table). +::: + + + +```html + +``` + +## Props + +### headers + +Array of objects that represent table columns along with some configurations that should apply to each of the columns (whether a column is sortable, hidable, etc). + +```ts +interface TableViewHeader { + /** must be unique for each column, see Reserved Header Keys section for more information about 'actions' key value */ + key: string + /** visible column header text */ + label: string + /** in a nutshell, this property defines whether sort icon should be displayed next to the column header and whether the column header will emit sort event upon clicking on it */ + sortable?: boolean + /** allow toggling column visibility */ + hidable?: boolean + /** when provided, an info icon will be rendered next to the column label, upon hovering on which the tooltip will be revealed */ + tooltip?: string + /** whether column header text should be hidden (only visible to screen readers) */ + hideLabel?: boolean +} +``` + +:::tip NOTE +If at least one column is `hidable` in the table, KTableView will render a dropdown on the right of the table toolbar directly above the table, which will provide an interface for showing/hiding columns to the user. +::: + +For an example of `headers` prop usage please refer to [`data` prop documentation](#data) below. + +#### Reserved Header Keys + +- `actions` - the column displays an actions [KDropdown](/components/dropdown) button for each row and displays no label (as if `hideLabel` was `true`; you can set `hideLabel` parameter to `false` to show the label). KTableView will automatically render the actions dropdown button with an icon and you simply need to provide dropdown items via the [`action-items` slot](#action-items). + +### data + +Data to be rendered in the table. Accepted interface is an array of objects where each property key should have a corresponding `key` in the [`headers` prop](#headers). + +```ts +type TableViewData = Record[] +``` + + + + + +```vue + + + +``` + +:::tip NOTE +Notice that in the example above the _Username_ column is `sortable` and the _Email_ column is `hidable`. +::: + +### loading + +Boolean to control whether the component should display the loading state. Defaults to `false`. + + + +```html + +``` + +### error + +Boolean to control whether the component should display the error state. Defaults to `false`. See [error state](#error-1) section for more customization options. + + + +```html + +``` + +### resizeColumns + +Allow table column width to be resizable (defaults to `false`). Adjusting a column's width will trigger an [`update:table-preferences` event](#updatetable-preferences). + + + +```html + +``` + +### rowHover + +Boolean to control whether table should display hover state upon hovering rows. Defaults to `true`. + +### tablePreferences + +Can be used to pass object with locally stored preferences for different table configuration options. For example, when user resizes a column in a given table, `update:table-preferences` event will be emitted - you can then save the value and re-apply it next time user encounters this table. + +```ts +interface TablePreferences { + /** the number of items to display per page */ + pageSize?: number + /** the column to sort by's `key` defined in the table headers */ + sortColumnKey?: string + /** the order by which to sort the column, one of `asc` or `desc` */ + sortColumnOrder?: SortColumnOrder + /** the customized column widths, if resizing is allowed */ + columnWidths?: Record + /** column visibility, if visibility is toggleable */ + columnVisibility?: Record +} +``` + + + +```html + +``` + +### rowAttrs + +Function for adding custom attributes to each row. The function should return an object with `key: value` pairs for each attribute. + +The passed function receives row value object as an argument. + + + +```html + +``` + +### rowLink + +Function for turning row into a link. The function receives row value object as an argument and should return an object with two optional parameters: + +```ts +interface RowLink { + /** RouteLocationRaw or url string for row link */ + to?: RouteLocationRaw | string + /** Target for row link */ + target?: '_self' | '_blank' | '_parent' | '_top' +} +``` + + + +```vue + + + + +``` + +### cellAttrs + +Function for adding custom attributes to each table cell. The function should return an object with `key: value` pairs for each attribute. + +The passed function receives an object with these parameters as an argument: +```ts +{ + headerKey: string // header key + row: object // row value + rowIndex: number // row index + colIndex: index // column index +} +``` + + + +```html + +``` + +### maxHeight + +Limit the table height by passing a number, in pixels. If the table height exceeds the specified number, it will be scrollable. Table header is a `position: sticky;` element and will always be visible. + + + +```html + +``` + +### paginationAttributes + +Object to be passed to underlying pagination component. See [KPagination props](/components/pagination#props) for more details. Expects an object of type `TablePaginationAttributes`: + +```ts +interface TablePaginationAttributes { + totalCount?: number + pageSizes?: number[] + initialPageSize?: number + currentPage?: number + offset?: boolean + disablePageJump?: boolean + offsetPreviousButtonDisabled?: boolean + offsetNextButtonDisabled?: boolean +} +``` + + + +```html + +``` + +### hidePagination + +A boolean to hide pagination element (defaults to `false`). + +## States + +### Empty + + + +```html + +``` + +Set the following props to handle empty state: + +- `emptyStateTitle` - Title text for empty state +- `emptyStateMessage` - Message for empty state +- `emptyStateIconVariant` - Icon variant for empty state (see [KEmptyState component props](/components/empty-state#iconvariant)) +- `emptyStateActionMessage` - Button text for empty state action +- `emptyStateActionRoute` - Route for empty state action +- `emptyStateButtonAppearance` - Appearance of empty state action button. See [KButton `appearance` prop](/components/button#appearance) for details + +:::tip +To display an icon inside of action button, you can use the `empty-state-action-icon` slot. +::: + +When the empty state action button is clicked, KTableView emits the `empty-state-action-click` event. + + + + + +```html + + + +``` + +### Error + +Set the `error` prop to `true` to enable the error state. + + + +```html + +``` + +Set the following properties to customize the error state: + +- `errorStateTitle` - Title text for the error state +- `errorStateMessage` - Message for the error state +- `errorStateActionMessage` - Button text for the error state action +- `errorStateActionRoute` - Route for the error state action + +A `error-action-click` event is emitted when error state action button is clicked. + + + +```html + +``` + +## Slots + +### Column Header + +You can slot in your custom content into each column header. For that, use column `key` value prefixed with `column-*` like in the example below. + +Slot props: +* `column` - column header object + + + + + +```html + + + +``` + +### Cell + +You can provide each individual cell's content via slot. Each cell slot is named after the header `key` it corresponds to. + +Slot props: +* `row` - table row object +* `rowKey` - table row index +* `rowValue` - the cell value + +:::warning NOTE +This slot is not supported for the [`actions` column](#reserved-header-keys). +::: + + + + + +```html + + + +``` + +### Header Tooltip + +Utilize HTML in the column header's tooltip by utilizing this slot. Similar to the column header slot, it uses the column `key` value prefixed with `tooltip-*` as shown in the example below. + +Slot props: +* `column` - column header object + + + + + +```html + + + +``` + +### toolbar + +The toolbar is rendered directly above the table and is useful for providing table controls like search or filter fields. + + + + + +```html + + + +``` + +### empty-state + +Slot content to be displayed when empty. + +### empty-state-action-icon + +Slot for icon to be displayed in front of action button text in empty state. See [empty state](#empty) section for example of usage of this slot. + +### error-state + +Slot content to be displayed when in error state. + +### action-items + +Slot for passing action dropdown items. See [KDropdownItem component docs](/components/dropdown#kdropdownitem) for details. + +Slot props: +* `row` - table row object +* `rowKey` - table row index + +:::tip NOTE +This slot is only available when the `actions` header key is present in [`headers`](#reserved-header-keys). +::: + + + + + +```html + + + +``` + +## Events + +### Row Events + +`@row:{event}` - returns the `Event`, the row item, and the type. `row-click` event is emitted whenever a row is clicked and the row click event handler is emitted, returns the row `data`. + +To avoid firing row clicks by accident, the row click handler ignores events coming from `a`, `button`, `label`, `input`, and `select` elements (unless they have the `disabled` attribute). As such click handlers attached to these element types do not require stopping propagation via `@click.stop`. + +The table in the example below contains buttons, inputs and links to demonstrate how KTableView handles clicks on different interactive elements within the table as well as clicks on its rows. + +Try clicking below on the table row, and then within elements inside the table cells. + + +
+ + + + + + +
+
+ +```html + +``` + +### Cell Events + +`@cell:{event}` - returns the `Event`, the cell value, and the type. `cell-click` event is emitted whenever a cell is clicked and the cell click event handler is emitted, returns the cell `data`. + +The table in the example below contains buttons, inputs and links to demonstrate how KTableView handles clicks on different interactive elements within the table as well as clicks on its cells. + + +
+ + + + + + +
+
+ +```html + +``` + +### Pagination Events + +KTableView propagates all events emitted by underlying pagination component. See [KPagination docs](/components/pagination#events) for more details. + +### sort + +Emitted when user clicks on a sortable column heading. Event payload is object of type `TableSortPayload`: + +```ts +interface TableSortPayload { + prevKey: string + sortColumnKey: string + sortColumnOrder: string +} +``` + +Refer to [`data` prop usage](#data) for example. + +### empty-state-action-click + +Emitted when empty state action button is clocked. + +### error-action-click + +Emitted when error state action button is clicked. + +### update:table-preferences + +Emitted when the user performs sorting, resizes columns or toggles column visibility. Event payload is object of type `TablePreferences` interface (see [`tablePreferences` prop](#tablepreferences) for details). + + + + diff --git a/docs/components/table.md b/docs/components/table.md index 427dc71694..0eab53c665 100644 --- a/docs/components/table.md +++ b/docs/components/table.md @@ -2,6 +2,10 @@ Component that takes care of fetching and rendering data in a table format. +:::tip NOTE +If you are looking for a simpler table component that does not integrate data fetching, check out [KTableView](/components/table-view). +::: + ``` +### maxHeight + +Pass a number (px) you want to limit table height to. If the height table exceeds the specified number, it will be scrollable. Table header is a `position: sticky;` element and will always be visible. + + + +```html + +``` + ## Pagination KTable uses KPagination component under the hood and exposes a few props as a way to modify how pagination looks and behaves in tables. See [KPagination](/components/pagination.html#props) docs for more details and examples: diff --git a/sandbox/index.ts b/sandbox/index.ts index 0b194e3b19..2473b2dab4 100644 --- a/sandbox/index.ts +++ b/sandbox/index.ts @@ -43,6 +43,7 @@ const sandboxAppLinks: SandboxNavigationItem[] = ([ { name: 'KSlideout', to: { name: 'slideout' } }, { name: 'KStepper', to: { name: 'stepper' } }, { name: 'KTable', to: { name: 'table' } }, + { name: 'KTableView', to: { name: 'table-view' } }, { name: 'KTabs', to: { name: 'tabs' } }, { name: 'KTextarea', to: { name: 'textarea' } }, { name: 'KToaster', to: { name: 'toaster' } }, diff --git a/sandbox/pages/SandboxTable/SandboxTable.vue b/sandbox/pages/SandboxTable/SandboxTable.vue index f416f02694..02c32e7089 100644 --- a/sandbox/pages/SandboxTable/SandboxTable.vue +++ b/sandbox/pages/SandboxTable/SandboxTable.vue @@ -9,7 +9,7 @@ is-subtitle title="Props" /> - + @@ -47,7 +47,7 @@ + +
+ + + + +
+ + +
+ + + + +
+
+ + +
+ + + +
+ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + + diff --git a/sandbox/pages/SandboxTableView/SandboxTableViewActions.vue b/sandbox/pages/SandboxTableView/SandboxTableViewActions.vue new file mode 100644 index 0000000000..0d90c0b88f --- /dev/null +++ b/sandbox/pages/SandboxTableView/SandboxTableViewActions.vue @@ -0,0 +1,11 @@ + diff --git a/sandbox/router/sandbox-routes.ts b/sandbox/router/sandbox-routes.ts index 6a55712f5b..bcc150b984 100644 --- a/sandbox/router/sandbox-routes.ts +++ b/sandbox/router/sandbox-routes.ts @@ -170,6 +170,12 @@ const componentRoutes: RouteRecordRaw[] = [ meta: { title: 'Stepper Sandbox' }, component: () => import('../pages/SandboxStepper.vue'), }, + { + path: '/table-view', + name: 'table-view', + meta: { title: 'Table View Sandbox' }, + component: () => import('../pages/SandboxTableView/SandboxTableView.vue'), + }, { path: '/table', name: 'table', diff --git a/src/components/KCard/KCard.vue b/src/components/KCard/KCard.vue index 894b974e42..206eff870a 100644 --- a/src/components/KCard/KCard.vue +++ b/src/components/KCard/KCard.vue @@ -82,6 +82,8 @@ const showCardHeader = computed((): boolean => { font-weight: var(--kui-font-weight-bold, $kui-font-weight-bold); line-height: var(--kui-line-height-30, $kui-line-height-30); margin: var(--kui-space-0, $kui-space-0); + text-align: left; + width: 100%; } .card-actions { diff --git a/src/components/KTable/KTable.vue b/src/components/KTable/KTable.vue index bcd353d28b..3cc2f8febf 100644 --- a/src/components/KTable/KTable.vue +++ b/src/components/KTable/KTable.vue @@ -83,6 +83,7 @@
@@ -272,7 +273,7 @@ import { KUI_COLOR_TEXT_NEUTRAL, KUI_ICON_SIZE_30 } from '@kong/design-tokens' import ColumnVisibilityMenu from './ColumnVisibilityMenu.vue' import useUniqueId from '@/composables/useUniqueId' -const { useDebounce, useRequest, useSwrvState, clientSideSorter: defaultClientSideSorter } = useUtilities() +const { useDebounce, useRequest, useSwrvState, clientSideSorter: defaultClientSideSorter, getSizeFromString } = useUtilities() const props = defineProps({ /** @@ -487,6 +488,10 @@ const props = defineProps({ type: Boolean, default: false, }, + maxHeight: { + type: String, + default: 'none', + }, }) const emit = defineEmits<{ @@ -549,6 +554,9 @@ const isClickable = ref(false) const hasInitialized = ref(false) const nextPageClicked = ref(false) const hasToolbarSlot = computed((): boolean => !!slots.toolbar || hasColumnVisibilityMenu.value) +const tableWrapperStyles = computed((): Record => ({ + maxHeight: getSizeFromString(props.maxHeight), +})) /** * Utilize a helper function to generate the column slot name. @@ -738,12 +746,10 @@ const headerHeight = computed((): string => { const elem = headerElems.value?.item(0) if (elem) { const styles = window?.getComputedStyle(elem) - if (styles?.height) { return `${parseInt(styles.height, 10)}px` } } - return 'auto' }) @@ -978,10 +984,10 @@ const pageSizeChangeHandler = ({ pageSize: newPageSize }: PageSizeChangeData) => } const scrollHandler = (event: any): void => { - if (event && event.target && event.target.scrollTop) { + if (event && event.target && typeof event.target.scrollTop === 'number') { if (event.target.scrollTop > 1) { isScrolled.value = true - } else if (event.target.scrollTop) { + } else if (event.target.scrollTop === 0) { isScrolled.value = !isScrolled.value } } @@ -1122,220 +1128,17 @@ onMounted(() => { diff --git a/src/components/KTableView/KTableView.cy.ts b/src/components/KTableView/KTableView.cy.ts new file mode 100644 index 0000000000..74f7a6c326 --- /dev/null +++ b/src/components/KTableView/KTableView.cy.ts @@ -0,0 +1,397 @@ +import { mount } from 'cypress/vue' +import { h } from 'vue' +import KTableView from '@/components/KTableView/KTableView.vue' +import type { TableHeader } from '@/types' + +const largeDataSet = [ + { + name: 'Basic Auth', + id: '517526354743085', + enabled: 'true', + }, + { + name: 'Website Desktop', + id: '328027447731198', + enabled: 'false', + }, + { + name: 'Android App', + id: '405383051040955', + enabled: 'true', + }, + { + name: 'Basic Auth', + id: '517526354743085', + enabled: 'true', + }, + { + name: 'Website Desktop', + id: '328027447731198', + enabled: 'false', + }, + { + name: 'Android App', + id: '405383051040955', + enabled: 'true', + }, + { + name: 'Basic Auth', + id: '517526354743085', + enabled: 'true', + }, + { + name: 'Website Desktop', + id: '328027447731198', + enabled: 'false', + }, + { + name: 'Android App', + id: '405383051040955', + enabled: 'true', + }, + { + name: 'Basic Auth', + id: '517526354743085', + enabled: 'true', + }, + { + name: 'Website Desktop', + id: '328027447731198', + enabled: 'false', + }, + { + name: 'Android App', + id: '405383051040955', + enabled: 'true', + }, +] + +const options = { + headers: [ + { label: 'Name', key: 'name', sortable: true }, + { label: 'ID', key: 'id', sortable: false }, + { label: 'Enabled', key: 'enabled', sortable: false }, + { label: '', key: 'actions', sortable: false }, + ] as TableHeader[], + data: [ + { + name: 'Basic Auth', + id: '517526354743085', + enabled: 'true', + }, + { + name: 'Website Desktop', + id: '328027447731198', + enabled: 'false', + }, + { + name: 'Android App', + id: '405383051040955', + enabled: 'true', + }, + ], +} + +describe('KTableView', () => { + describe('states', () => { + it('displays an empty state when no data is available', () => { + mount(KTableView, { + props: { + data: [], + headers: options.headers, + }, + }) + + cy.get('.k-empty-state').should('be.visible') + }) + + it('displays an empty state when no data is available (slot)', () => { + const emptySlotContent = 'Look mah! I am empty!' + mount(KTableView, { + props: { + data: [], + headers: options.headers, + }, + slots: { + 'empty-state': () => h('span', {}, emptySlotContent), + }, + }) + + cy.getTestId('table-empty-state').should('contain.text', emptySlotContent) + }) + + it('displays a loading skeletion when the "loading" prop is set to true"', () => { + mount(KTableView, { + props: { + loading: true, + }, + }) + + cy.get('.skeleton-table-wrapper').should('be.visible') + }) + + it('displays an error state when the "error" prop is set to true"', () => { + mount(KTableView, { + props: { + error: true, + }, + }) + + cy.get('.k-empty-state.error').should('be.visible') + }) + + it('displays an error state (slot)', () => { + const errorSlotContent = 'Look mah! I am erroneous!' + mount(KTableView, { + props: { + error: true, + }, + slots: { + 'error-state': () => h('span', {}, errorSlotContent), + }, + }) + + cy.getTestId('table-error-state').should('contain.text', errorSlotContent) + }) + }) + + describe('default', () => { + it('renders link in action slot', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + slots: { + name: () => h('a', { href: '#' }, 'Link'), + }, + }) + + cy.get('.table td:first-of-type > *').contains('a', 'Link') + }) + + it('renders content in the toolbar slot', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + slots: { + toolbar: () => h('button', {}, 'Toolbar button'), + }, + }) + + cy.get('.k-table-view .table-toolbar').find('button').should('be.visible') + cy.get('.k-table-view .table-toolbar button').should('contain.text', 'Toolbar button') + }) + + it('has hover class when passed', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + rowHover: true, + }, + }) + + cy.get('.table').should('have.class', 'has-hover') + }) + + it('renders column resize toggles when resizeColumns is set', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + resizeColumns: true, + }, + }) + + cy.get('.table').find('th.resizable').should('be.visible') + cy.get('.resize-handle').should('exist') + }) + + it('renders column show/hide when headers.hidable is set', () => { + // make ID column hidable + options.headers[1].hidable = true + const modifiedHeaderKey = options.headers[1].key + + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + }) + + cy.get('.table').should('be.visible') + // menu button is visible + cy.getTestId('column-visibility-menu-button').should('be.visible') + cy.getTestId('column-visibility-menu-button').click() + + // only columns with hidable set to true should be visible and checked by default + cy.getTestId(`column-visibility-menu-item-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId(`column-visibility-menu-item-${options.headers[0].key}`).should('not.exist') + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).should('be.checked') + + // changes are applied only when Apply button is clicked + cy.getTestId(`column-visibility-checkbox-${modifiedHeaderKey}`).click() + cy.getTestId(`table-header-${modifiedHeaderKey}`).should('be.visible') + cy.getTestId('apply-button').click() + cy.getTestId(`table-header-${modifiedHeaderKey}`).should('not.exist') + }) + + it('renders tooltip when provided in headers', () => { + options.headers[0].tooltip = 'This is a tooltip' + + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + }) + + cy.getTestId(`tooltip-${options.headers[0].key}`).should('be.visible') + }) + + it('displays actions dropdown when actions key is provided', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + }) + + cy.getTestId('actions-dropdown').should('be.visible').and('have.length', options.data.length) + cy.get('th').eq(options.headers.indexOf(options.headers.find((header => header.key === 'actions'))!)).find('.table-header-label').should('have.class', 'sr-only') + }) + + it('displays each row as link when rowLink prop is provided', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + rowLink: () => ({ + to: '/link', + }), + }, + }) + + cy.get('table tbody td').each(($el) => { + cy.wrap($el).should('have.class', 'row-link').should('be.visible') + }) + + cy.get('table tbody td>a.cell-wrapper').each(($el) => { + cy.wrap($el).should('be.visible') + }) + }) + }) + + describe('handles prop changes as expected', () => { + it('reacts to changes in headers', () => { + mount(KTableView, { + propsData: { + data: largeDataSet, + headers: [ + { label: 'Name', key: 'name' }, + ], + }, + }).then((component) => { + cy.get('.table').find('th').should('have.length', 1) + cy.getTestId('table-header-name').should('be.visible').then(() => { + component.wrapper.setProps({ + headers: [ + { label: 'Name', key: 'name' }, + { label: 'ID', key: 'id' }, + ], + }).then(() => { + cy.get('.table').find('th').should('have.length', 2) + cy.getTestId('table-header-id').should('be.visible') + cy.get('.table').find('td').eq(1).should('contain', '517526354743085') + }) + }) + }) + }) + }) + + describe('sorting', () => { + it('should have sortable class when passed', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + }) + + cy.get('th').each(($el, index) => { + if (index === 0) { + cy.wrap($el).should('have.class', 'sortable') + } + }) + }) + + it('should emit event when sortable column is clicked', () => { + mount(KTableView, { + props: { + headers: options.headers, + data: options.data, + }, + }) + + cy.get('th').eq(0).click().then(() => { + cy.get('th').eq(0).should('have.class', 'active-sort') + cy.wrap(Cypress.vueWrapper.emitted()).should('have.property', 'sort').and('have.length', 1) + }) + }) + }) + + describe('pagination', () => { + it('displays pagination when data is provided', () => { + mount(KTableView, { + props: { + data: options.data, + headers: options.headers, + }, + }) + + cy.getTestId('table-pagination').should('be.visible') + }) + + it('does not display pagination when hidePagination prop is true', () => { + mount(KTableView, { + props: { + data: options.data, + headers: options.headers, + hidePagination: true, + }, + }) + + cy.getTestId('table-pagination').should('not.exist') + }) + + it('does not display pagination when data is empty', () => { + mount(KTableView, { + props: { + data: [], + headers: options.headers, + }, + }) + + cy.getTestId('table-pagination').should('not.exist') + }) + + it('passes the correct props to the pagination component', () => { + mount(KTableView, { + props: { + data: options.data, + headers: options.headers, + paginationAttributes: { + totalCount: 100, + currentPage: 2, + }, + }, + }) + + cy.getTestId('visible-items').eq(0).should('contain.text', '16 to 30 of 100') + }) + }) +}) diff --git a/src/components/KTableView/KTableView.vue b/src/components/KTableView/KTableView.vue new file mode 100644 index 0000000000..2870195340 --- /dev/null +++ b/src/components/KTableView/KTableView.vue @@ -0,0 +1,941 @@ + + + + + diff --git a/src/components/index.ts b/src/components/index.ts index bab3860cd0..bc45fc6d10 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -50,3 +50,4 @@ export { default as KTreeList } from './KTreeList/KTreeList.vue' export { default as KExternalLink } from './KExternalLink/KExternalLink.vue' export { default as KTruncate } from './KTruncate/KTruncate.vue' export { default as KCopy } from './KCopy/KCopy.vue' +export { default as KTableView } from './KTableView/KTableView.vue' diff --git a/src/global-components.ts b/src/global-components.ts index 1849c576d2..030f890a52 100644 --- a/src/global-components.ts +++ b/src/global-components.ts @@ -48,9 +48,10 @@ declare module 'vue' { ToastManager: typeof components.ToastManager KTruncate: typeof components.KTruncate KCopy: typeof components.KCopy + KTableView: typeof components.KTableView // {%%NEW_KONGPONENT%%} (do not remove comment) } } // Must have an export to be a module -export {} +export { } diff --git a/src/styles/_mixins.scss b/src/styles/_mixins.scss index 2b3e7a2ebe..fe11af5c3a 100644 --- a/src/styles/_mixins.scss +++ b/src/styles/_mixins.scss @@ -5,6 +5,7 @@ @import "@/styles/mixins/badges"; @import "@/styles/mixins/loaders"; @import "@/styles/mixins/buttons"; +@import "@/styles/mixins/tables"; /* Common mixins for all components */ diff --git a/src/styles/mixins/_tables.scss b/src/styles/mixins/_tables.scss new file mode 100644 index 0000000000..3fcb25a789 --- /dev/null +++ b/src/styles/mixins/_tables.scss @@ -0,0 +1,224 @@ +@mixin table { + /* Local variables */ + $tableThPaddingY: var(--kui-space-50, $kui-space-50); + + /* Styles */ + background-color: var(--kui-color-background, $kui-color-background); + display: flex; + flex-direction: column; + font-family: var(--kui-font-family-text, $kui-font-family-text); + gap: var(--kui-space-70, $kui-space-70); + + .table-toolbar { + display: flex; + gap: var(--kui-space-50, $kui-space-50); + width: 100%; + } + + .table-wrapper { + overflow: auto; + width: 100%; + + table { + border-collapse: collapse; + max-width: 100%; + width: 100%; + + th, + td { + @include truncate; + + padding: var(--kui-space-50, $kui-space-50) var(--kui-space-60, $kui-space-60); + vertical-align: middle; + white-space: nowrap; + } + + thead { + border-bottom: var(--kui-border-width-10, $kui-border-width-10) solid var(--kui-color-border, $kui-color-border); + height: 44px; + position: sticky; + top: 0; + + &.is-scrolled { + background-color: var(--kui-color-background, $kui-color-background); + } + + tr { + position: relative; + + &:after { + box-shadow: none; + content: ""; + height: 100%; + left: 0; + opacity: 0; + // Super-important to allow clicking on table rows in Safari. + // This allows clicks to pass through the "invisible" :after layer + pointer-events: none; + position: absolute; + transition: opacity $kongponentsTransitionDurTimingFunc; + width: 100%; + z-index: -1; + } + + &.is-scrolled { + &:after { + border-bottom: var(--kui-border-width-10, $kui-border-width-10) solid + var(--kui-color-border, $kui-color-border); + box-shadow: var(--kui-shadow, $kui-shadow); + opacity: 1; + transition: opacity $kongponentsTransitionDurTimingFunc; + } + } + + th { + color: var(--kui-color-text-neutral, $kui-color-text-neutral); + font-size: var(--kui-font-size-30, $kui-font-size-30); + font-weight: var(--kui-font-weight-semibold, $kui-font-weight-semibold); + line-height: var(--kui-line-height-30, $kui-line-height-30); + padding: $tableThPaddingY var(--kui-space-60, $kui-space-60); + text-align: left; + vertical-align: bottom; + + &.resizable { + // set min width so the column can't be collapsed to nothing - avoiding bad UX + min-width: 40px !important; // needs important because resizing will set min-width inline + position: relative; + + // when sortable or has tooltip (or both), we need to increase the min-width to account for icons + &.sortable, + &.has-tooltip { + min-width: 80px !important; // needs important because resizing will set min-width inline + } + + &.sortable.has-tooltip { + min-width: 100px !important; // needs important because resizing will set min-width inline + } + + .resize-handle { + // height property is set in component style block since it's bound to the calculated value + cursor: col-resize; + position: absolute; + right: 0; + top: 0; + width: 6px; + + &.previous { + left: 0; + right: unset; + } + } + } + + &.active-sort { + color: var(--kui-color-text, $kui-color-text); + } + + .sr-only { + border-width: var(--kui-border-width-0, $kui-border-width-0); + clip: rect(0, 0, 0, 0); + height: 1px; + margin: -1px; + overflow: hidden; + padding: var(--kui-space-0, $kui-space-0); + position: absolute; + white-space: nowrap; + width: 1px; + } + + &.sortable { + cursor: pointer; + + &.asc .sort-icon { + transform: rotate(-180deg); + } + } + + .table-headers-container { + align-items: center; + display: flex; + gap: var(--kui-space-40, $kui-space-40); + + &.resized { + // when column is resized we need to set position: absolute; to avoid glitching resizing behavior + bottom: $tableThPaddingY; + position: absolute; + } + + .table-header-label { + @include truncate; + } + + .header-tooltip-trigger { + cursor: help; + } + + .sort-icon { + flex-shrink: 0; + } + } + + &.resize-hover { + // creates a 2px "border" on the right - can't use border property because it will "jump" + box-shadow: calc(-1 * var(--kui-border-width-20, $kui-border-width-20)) 0 0 0 + var(--kui-color-border-decorative-purple, $kui-color-border-decorative-purple) inset; + } + } + } + } + + tbody { + tr { + height: 48px; + + &:not(:last-child) { + border-bottom: var(--kui-border-width-10, $kui-border-width-10) solid + var(--kui-color-border, $kui-color-border); + } + + td { + color: var(--kui-color-text, $kui-color-text); + font-size: var(--kui-font-size-30, $kui-font-size-30); + font-weight: var(--kui-font-weight-regular, $kui-font-weight-regular); + line-height: var(--kui-line-height-30, $kui-line-height-30); + white-space: nowrap; + + &.resize-hover { + // creates a 2px "border" on the right - can't use the border because it will "jump" + box-shadow: calc(-1 * var(--kui-border-width-20, $kui-border-width-20)) 0 0 0 + var(--kui-color-border, $kui-color-border) inset; + } + + .row-link { + color: var(--kui-color-text, $kui-color-text); + display: block; + padding: var(--kui-space-50, $kui-space-50) var(--kui-space-60, $kui-space-60); + text-decoration: none; + } + + .row-cell-wrapper { + display: contents; + } + } + } + } + + // Variants + &.has-hover { + tbody tr:hover { + background-color: var(--kui-color-background-primary-weakest, $kui-color-background-primary-weakest); + } + } + + &.is-clickable { + tbody tr { + cursor: pointer; + } + } + } + } + + .table-pagination { + margin-top: var(--kui-space-70, $kui-space-70); + } +} diff --git a/src/types/table.ts b/src/types/table.ts index d6addee1ac..7d6176bcd9 100644 --- a/src/types/table.ts +++ b/src/types/table.ts @@ -1,3 +1,5 @@ +import type { RouteLocationRaw } from 'vue-router' + export type SortColumnOrder = 'asc' | 'desc' export interface TablePreferences { @@ -13,6 +15,8 @@ export interface TablePreferences { columnVisibility?: Record } +export type TableViewData = Record[] + export interface TableHeader { /** Must be unique for each column */ key: string @@ -30,6 +34,8 @@ export interface TableHeader { useSortHandlerFunction?: boolean } +export interface TableViewHeader extends Omit { } + /** * Provide a type interface for KTable `column-*` and `tooltip-*` slot names. * @@ -50,3 +56,21 @@ export interface TableStatePayload { state: TableState hasData: boolean } + +export interface RowLink { + /** RouteLocationRaw or url string for row link */ + to?: RouteLocationRaw | string + /** Target for row link */ + target?: '_self' | '_blank' | '_parent' | '_top' +} + +export interface TablePaginationAttributes { + totalCount?: number + pageSizes?: number[] + initialPageSize?: number + currentPage?: number + offset?: boolean + disablePageJump?: boolean + offsetPreviousButtonDisabled?: boolean + offsetNextButtonDisabled?: boolean +}
- {{ row[value.key] }} + {{ row[header.key] }}