diff --git a/packages/components/package.json b/packages/components/package.json index a625b97d7d..3e4d60068c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -122,6 +122,17 @@ "./components/hds/accordion/index.js": "./dist/_app_/components/hds/accordion/index.js", "./components/hds/accordion/item/button.js": "./dist/_app_/components/hds/accordion/item/button.js", "./components/hds/accordion/item/index.js": "./dist/_app_/components/hds/accordion/item/index.js", + "./components/hds/advanced-table/helpers.js": "./dist/_app_/components/hds/advanced-table/helpers.js", + "./components/hds/advanced-table/index.js": "./dist/_app_/components/hds/advanced-table/index.js", + "./components/hds/advanced-table/td.js": "./dist/_app_/components/hds/advanced-table/td.js", + "./components/hds/advanced-table/th-button-expand.js": "./dist/_app_/components/hds/advanced-table/th-button-expand.js", + "./components/hds/advanced-table/th-button-sort.js": "./dist/_app_/components/hds/advanced-table/th-button-sort.js", + "./components/hds/advanced-table/th-button-tooltip.js": "./dist/_app_/components/hds/advanced-table/th-button-tooltip.js", + "./components/hds/advanced-table/th-selectable.js": "./dist/_app_/components/hds/advanced-table/th-selectable.js", + "./components/hds/advanced-table/th-sort.js": "./dist/_app_/components/hds/advanced-table/th-sort.js", + "./components/hds/advanced-table/th.js": "./dist/_app_/components/hds/advanced-table/th.js", + "./components/hds/advanced-table/tr-expandable-group.js": "./dist/_app_/components/hds/advanced-table/tr-expandable-group.js", + "./components/hds/advanced-table/tr.js": "./dist/_app_/components/hds/advanced-table/tr.js", "./components/hds/alert/description.js": "./dist/_app_/components/hds/alert/description.js", "./components/hds/alert/index.js": "./dist/_app_/components/hds/alert/index.js", "./components/hds/alert/title.js": "./dist/_app_/components/hds/alert/title.js", @@ -270,6 +281,7 @@ "./components/hds/side-nav/toggle-button.js": "./dist/_app_/components/hds/side-nav/toggle-button.js", "./components/hds/stepper/step/indicator.js": "./dist/_app_/components/hds/stepper/step/indicator.js", "./components/hds/stepper/task/indicator.js": "./dist/_app_/components/hds/stepper/task/indicator.js", + "./components/hds/table/helpers.js": "./dist/_app_/components/hds/table/helpers.js", "./components/hds/table/index.js": "./dist/_app_/components/hds/table/index.js", "./components/hds/table/td.js": "./dist/_app_/components/hds/table/td.js", "./components/hds/table/th-button-sort.js": "./dist/_app_/components/hds/table/th-button-sort.js", diff --git a/packages/components/src/components.ts b/packages/components/src/components.ts index cf27344d7e..9d4fba804a 100644 --- a/packages/components/src/components.ts +++ b/packages/components/src/components.ts @@ -14,6 +14,19 @@ export { default as HdsAccordion } from './components/hds/accordion/index.ts'; export { default as HdsAccordionItem } from './components/hds/accordion/item/index.ts'; export * from './components/hds/accordion/types.ts'; +// Advanced Table +export { default as HdsAdvancedTable } from './components/hds/advanced-table/index.ts'; +export { default as HdsAdvancedTableTd } from './components/hds/advanced-table/td.ts'; +export { default as HdsAdvancedTableTh } from './components/hds/advanced-table/th.ts'; +export { default as HdsAdvancedTableThButtonExpand } from './components/hds/advanced-table/th-button-expand.ts'; +export { default as HdsAdvancedTableThButtonSort } from './components/hds/advanced-table/th-button-sort.ts'; +export { default as HdsAdvancedTableThButtonTooltip } from './components/hds/advanced-table/th-button-tooltip.ts'; +export { default as HdsAdvancedTableThSelectable } from './components/hds/advanced-table/th-selectable.ts'; +export { default as HdsAdvancedTableThSort } from './components/hds/advanced-table/th-sort.ts'; +export { default as HdsAdvancedTableTrExpandableGroup } from './components/hds/advanced-table/tr-expandable-group.ts'; +export { default as HdsAdvancedTableTr } from './components/hds/advanced-table/tr.ts'; +export * from './components/hds/advanced-table/types.ts'; + // Alert export { default as HdsAlert } from './components/hds/alert/index.ts'; export { default as HdsAlertDescription } from './components/hds/alert/description.ts'; diff --git a/packages/components/src/components/hds/advanced-table/helpers.ts b/packages/components/src/components/hds/advanced-table/helpers.ts new file mode 100644 index 0000000000..5ae07abce4 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/helpers.ts @@ -0,0 +1,119 @@ +// TODO +// did insert +// add escape listener to focusable elements inside cell +// Page Down: Moves focus down an author-determined number of rows, typically scrolling so the bottom row in the currently visible set of rows becomes one of the first visible rows. If focus is in the last row of the grid, focus does not move. +// Page Up: Moves focus up an author-determined number of rows, typically scrolling so the top row in the currently visible set of rows becomes one of the last visible rows. If focus is in the first row of the grid, focus does not move. +// Home: moves focus to the first cell in the row that contains focus. +// End: moves focus to the last cell in the row that contains focus. +// Control + Home: moves focus to the first cell in the first row. +// Control + End: moves focus to the last cell in the last row. + +import type { HdsAdvancedTableTdSignature } from './td'; +import type { HdsAdvancedTableThSignature } from './th'; + +export const didInsertGridCell = ( + cell: + | HdsAdvancedTableThSignature['Element'] + | HdsAdvancedTableTdSignature['Element'] +): void => { + const currentRow = cell.parentElement; + + if (currentRow?.parentElement?.tagName === 'THEAD') { + const thead = currentRow.parentElement; + + if ( + thead.children.item(0) === currentRow && + currentRow.children.item(0) === cell + ) { + cell.setAttribute('tabindex', '0'); + } else { + cell.setAttribute('tabindex', '-1'); + } + } else if (currentRow?.parentElement?.tagName === 'TBODY') { + const table = currentRow.parentElement.parentElement; + const thead = table?.querySelector('thead'); + const tbody = table?.querySelector('tbody'); + + if (thead === null) { + if ( + tbody?.children.item(0) === currentRow && + currentRow.children.item(0) === cell + ) { + cell.setAttribute('tabindex', '0'); + } + } else { + cell.setAttribute('tabindex', '-1'); + } + } +}; + +export const handleGridCellKeyPress = (event: KeyboardEvent): void => { + const { key, target } = event; + + console.log('hello'); + + const changeActiveCell = (oldCell: HTMLElement, newCell: HTMLElement) => { + oldCell.setAttribute('tabindex', '-1'); + newCell.setAttribute('tabindex', '0'); + newCell.focus(); + }; + + const findNewRow = ( + currentRow: HTMLElement, + direction: 'ArrowDown' | 'ArrowUp' + ) => { + const table = currentRow.parentElement?.closest('table'); + const allRows = table?.querySelectorAll( + 'tr:not(.hds-advanced-table__tr--hidden)' + ); + + if (allRows) { + const currentRowIndex = Array.from(allRows).indexOf(currentRow); + if (direction === 'ArrowDown') return allRows[currentRowIndex + 1]; + else if (direction === 'ArrowUp') return allRows[currentRowIndex - 1]; + } + }; + + if (target instanceof HTMLElement) { + if (key === 'Enter') { + const focusableElements = target.querySelectorAll( + 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + // if (focusableElements.length === 1) { + // const element = focusableElements[0] as HTMLElement; + // element.click(); + // } else + + if (focusableElements.length > 0) { + const element = focusableElements[0] as HTMLElement; + element.focus(); + } + } else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + const nextElement = + key === 'ArrowRight' + ? target.nextElementSibling + : target.previousElementSibling; + + if (nextElement !== null && nextElement instanceof HTMLElement) { + changeActiveCell(target, nextElement); + } + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + const currentRow = target.parentElement; + + if (currentRow instanceof HTMLElement) { + const currentCellIndex = Array.from(currentRow.children).indexOf( + target + ); + const nextRow = findNewRow(currentRow, event.key); + + if (nextRow !== null && nextRow instanceof HTMLElement) { + const nextCell = nextRow.children[currentCellIndex]; + if (nextCell instanceof HTMLElement) { + changeActiveCell(target, nextCell); + } + } + } + } + } +}; diff --git a/packages/components/src/components/hds/advanced-table/index.hbs b/packages/components/src/components/hds/advanced-table/index.hbs new file mode 100644 index 0000000000..641461e5a3 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/index.hbs @@ -0,0 +1,91 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + + + + + {{#each @columns as |column|}} + {{#if column.isSortable}} + + {{column.label}} + + {{else}} + {{column.label}} + {{/if}} + {{/each}} + + + + + {{! ---------------------------------------------------------------------------------------- + IMPORTANT: we loop on the `model` array and for each record + we yield the Tr/Td/Th elements _and_ the record itself as `data` + this means the consumer will *have to* use the `data` key to access it in their template + -------------------------------------------------------------------------------------------- }} + {{#each (sort-by this.getSortCriteria @model) key=this.identityKey as |record|}} + {{#if @hasNestedRows}} + + {{yield + (hash + Tr=(component + "hds/advanced-table/tr" + selectionScope="row" + isSelectable=@isSelectable + onSelectionChange=this.onSelectionRowChange + didInsertCheckbox=this.didInsertRowCheckbox + willDestroy=this.willDestroyRowCheckbox + selectionAriaLabelSuffix=@selectionAriaLabelSuffix + ) + Th=(component "hds/advanced-table/th" scope="row" isExpandable=(if record.children true)) + Td=(component "hds/advanced-table/td" align=@align) + data=record + ) + to="body" + }} + + {{else}} + {{yield + (hash + Tr=(component + "hds/advanced-table/tr" + selectionScope="row" + isSelectable=@isSelectable + onSelectionChange=this.onSelectionRowChange + didInsertCheckbox=this.didInsertRowCheckbox + willDestroy=this.willDestroyRowCheckbox + selectionAriaLabelSuffix=@selectionAriaLabelSuffix + ) + Th=(component "hds/advanced-table/th" scope="row") + Td=(component "hds/advanced-table/td" align=@align) + data=record + ) + to="body" + }} + {{/if}} + {{/each}} + +
{{@caption}} {{this.sortedMessageText}}
\ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/index.ts b/packages/components/src/components/hds/advanced-table/index.ts new file mode 100644 index 0000000000..547e7f95f1 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/index.ts @@ -0,0 +1,316 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { assert } from '@ember/debug'; +import { tracked } from '@glimmer/tracking'; +import type { ComponentLike } from '@glint/template'; +import { htmlSafe } from '@ember/template'; +import type { SafeString } from '@ember/template/-private/handlebars'; + +import { + HdsAdvancedTableDensityValues, + HdsAdvancedTableThSortOrderValues, + HdsAdvancedTableVerticalAlignmentValues, +} from './types.ts'; +import type { + HdsAdvancedTableColumn, + HdsAdvancedTableDensities, + HdsAdvancedTableHorizontalAlignment, + HdsAdvancedTableOnSelectionChangeSignature, + HdsAdvancedTableSelectableRow, + HdsAdvancedTableSortingFunction, + HdsAdvancedTableThSortOrder, + HdsAdvancedTableVerticalAlignment, + HdsAdvancedTableModel, +} from './types'; +import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base'; +import type { HdsAdvancedTableTdSignature } from './td.ts'; +import type { HdsAdvancedTableThSignature } from './th.ts'; +import type { HdsAdvancedTableTrSignature } from './tr.ts'; + +export const DENSITIES: HdsAdvancedTableDensities[] = Object.values( + HdsAdvancedTableDensityValues +); +export const DEFAULT_DENSITY = HdsAdvancedTableDensityValues.Medium; + +export const VALIGNMENTS: HdsAdvancedTableVerticalAlignment[] = Object.values( + HdsAdvancedTableVerticalAlignmentValues +); +export const DEFAULT_VALIGN = HdsAdvancedTableVerticalAlignmentValues.Top; + +export interface HdsAdvancedTableSignature { + Args: { + align?: HdsAdvancedTableHorizontalAlignment; + caption?: string; + columns: HdsAdvancedTableColumn[]; + density?: HdsAdvancedTableDensities; + identityKey?: string; + isSelectable?: boolean; + isStriped?: boolean; + model: HdsAdvancedTableModel; + onSelectionChange?: ( + selection: HdsAdvancedTableOnSelectionChangeSignature + ) => void; + onSort?: (sortBy: string, sortOrder: HdsAdvancedTableThSortOrder) => void; + selectionAriaLabelSuffix?: string; + sortBy?: string; + selectableColumnKey?: string; + sortedMessageText?: string; + sortOrder?: HdsAdvancedTableThSortOrder; + valign?: HdsAdvancedTableVerticalAlignment; + hasNestedRows?: boolean; + }; + Blocks: { + body?: [ + { + Td?: ComponentLike; + Tr?: ComponentLike; + Th?: ComponentLike; + data?: Record; + sortBy?: string; + sortOrder?: HdsAdvancedTableThSortOrder; + }, + ]; + }; + Element: HTMLTableElement; +} + +export default class HdsAdvancedTable extends Component { + @tracked sortBy = this.args.sortBy ?? undefined; + @tracked sortOrder = + this.args.sortOrder || HdsAdvancedTableThSortOrderValues.Asc; + @tracked selectAllCheckbox?: HdsFormCheckboxBaseSignature['Element'] = + undefined; + selectableRows: HdsAdvancedTableSelectableRow[] = []; + @tracked isSelectAllCheckboxSelected?: boolean = undefined; + + get getSortCriteria(): string | HdsAdvancedTableSortingFunction { + // get the current column + const currentColumn = this.args?.columns?.find( + (column) => column.key === this.sortBy + ); + if ( + // check if there is a custom sorting function associated with the current `sortBy` column (we assume the column has `isSortable`) + currentColumn?.sortingFunction && + typeof currentColumn.sortingFunction === 'function' + ) { + return currentColumn.sortingFunction; + } else { + // otherwise fallback to the default format "sortBy:sortOrder" + return `${this.sortBy}:${this.sortOrder}`; + } + } + + get identityKey(): string | undefined { + // we have to provide a way for the consumer to pass undefined because Ember tries to interpret undefined as missing an arg and therefore falls back to the default + if (this.args.identityKey === 'none') { + return undefined; + } else { + return this.args.identityKey ?? '@identity'; + } + } + + get sortedMessageText(): string { + if (this.args.sortedMessageText) { + return this.args.sortedMessageText; + } else if (this.sortBy && this.sortOrder) { + // we should allow the user to define a custom value here (e.g., for i18n) - tracked with HDS-965 + return `Sorted by ${this.sortBy} ${this.sortOrder}ending`; + } else { + return ''; + } + } + + get isStriped(): boolean { + return this.args.isStriped ?? false; + } + + get density(): HdsAdvancedTableDensities { + const { density = DEFAULT_DENSITY } = this.args; + + assert( + `@density for "Hds::Table" must be one of the following: ${DENSITIES.join( + ', ' + )}; received: ${density}`, + DENSITIES.includes(density) + ); + + return density; + } + + get valign(): HdsAdvancedTableVerticalAlignment { + const { valign = DEFAULT_VALIGN } = this.args; + + assert( + `@valign for "Hds::Table" must be one of the following: ${VALIGNMENTS.join( + ', ' + )}; received: ${valign}`, + VALIGNMENTS.includes(valign) + ); + + return valign; + } + + get gridColumns(): SafeString { + if (this.args.isSelectable) { + let style = 'grid-template-columns: auto'; + + for (let i = 0; i < this.args.columns.length; i++) { + style = `${style} 1fr`; + } + + return htmlSafe(style); + } + + return htmlSafe( + `grid-template-columns: repeat(${this.args.columns.length}, 1fr)` + ); + } + + get classNames(): string { + const classes = ['hds-advanced-table']; + + // add a class based on the @isStriped argument + if (this.isStriped) { + classes.push('hds-advanced-table--striped'); + } + + // add a class based on the @density argument + if (this.density) { + classes.push(`hds-advanced-table--density-${this.density}`); + } + + // add a class based on the @valign argument + if (this.valign) { + classes.push(`hds-advanced-table--valign-${this.valign}`); + } + + return classes.join(' '); + } + + @action + setSortBy(column: string): void { + if (this.sortBy === column) { + // check to see if the column is already sorted and invert the sort order if so + this.sortOrder = + this.sortOrder === HdsAdvancedTableThSortOrderValues.Asc + ? HdsAdvancedTableThSortOrderValues.Desc + : HdsAdvancedTableThSortOrderValues.Asc; + } else { + // otherwise, set the sort order to ascending + this.sortBy = column; + this.sortOrder = HdsAdvancedTableThSortOrderValues.Asc; + } + + const { onSort } = this.args; + + if (typeof onSort === 'function') { + onSort(this.sortBy, this.sortOrder); + } + } + + onSelectionChangeCallback( + checkbox?: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ): void { + const { onSelectionChange } = this.args; + if (typeof onSelectionChange === 'function') { + onSelectionChange({ + selectionKey: selectionKey, + selectionCheckboxElement: checkbox, + selectedRowsKeys: this.selectableRows.reduce((acc, row) => { + if (row.checkbox.checked) { + acc.push(row.selectionKey); + } + return acc; + }, []), + selectableRowsStates: this.selectableRows.reduce( + ( + acc: { selectionKey: string; isSelected: boolean | undefined }[], + row + ) => { + acc.push({ + selectionKey: row.selectionKey, + isSelected: row.checkbox.checked, + }); + return acc; + }, + [] + ), + }); + } + } + + @action + onSelectionAllChange(): void { + this.selectableRows.forEach((row) => { + row.checkbox.checked = this.selectAllCheckbox?.checked ?? false; + row.checkbox.dispatchEvent(new Event('toggle', { bubbles: false })); + }); + this.isSelectAllCheckboxSelected = this.selectAllCheckbox?.checked ?? false; + this.onSelectionChangeCallback(this.selectAllCheckbox, 'all'); + } + + @action + onSelectionRowChange( + checkbox?: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ): void { + this.setSelectAllState(); + this.onSelectionChangeCallback(checkbox, selectionKey); + } + + @action + didInsertSelectAllCheckbox( + checkbox: HdsFormCheckboxBaseSignature['Element'] + ): void { + this.selectAllCheckbox = checkbox; + } + + @action + willDestroySelectAllCheckbox(): void { + this.selectAllCheckbox = undefined; + } + + @action + didInsertRowCheckbox( + checkbox: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ): void { + if (selectionKey) { + this.selectableRows.push({ selectionKey, checkbox }); + } + this.setSelectAllState(); + } + + @action + willDestroyRowCheckbox(selectionKey?: string): void { + this.selectableRows = this.selectableRows.filter( + (row) => row.selectionKey !== selectionKey + ); + this.setSelectAllState(); + } + + @action + setSelectAllState(): void { + if (this.selectAllCheckbox) { + const selectableRowsCount = this.selectableRows.length; + const selectedRowsCount = this.selectableRows.filter( + (row) => row.checkbox.checked + ).length; + + this.selectAllCheckbox.checked = + selectedRowsCount === selectableRowsCount; + this.selectAllCheckbox.indeterminate = + selectedRowsCount > 0 && selectedRowsCount < selectableRowsCount; + this.isSelectAllCheckboxSelected = this.selectAllCheckbox.checked; + this.selectAllCheckbox.dispatchEvent( + new Event('toggle', { bubbles: false }) + ); + } + } +} diff --git a/packages/components/src/components/hds/advanced-table/td.hbs b/packages/components/src/components/hds/advanced-table/td.hbs new file mode 100644 index 0000000000..37c91b9331 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/td.hbs @@ -0,0 +1,15 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + {{yield}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/td.ts b/packages/components/src/components/hds/advanced-table/td.ts new file mode 100644 index 0000000000..ba2907bd4d --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/td.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; +import { htmlSafe } from '@ember/template'; +import type { SafeString } from '@ember/template/-private/handlebars'; + +import type { HdsAdvancedTableHorizontalAlignment } from './types.ts'; +import { HdsAdvancedTableHorizontalAlignmentValues } from './types.ts'; +import { didInsertGridCell, handleGridCellKeyPress } from './helpers.ts'; + +export const ALIGNMENTS: string[] = Object.values( + HdsAdvancedTableHorizontalAlignmentValues +); +export const DEFAULT_ALIGN = HdsAdvancedTableHorizontalAlignmentValues.Left; + +export interface HdsAdvancedTableTdSignature { + Args: { + align?: HdsAdvancedTableHorizontalAlignment; + rowspan?: number; + colspan?: number; + }; + Blocks: { + default: []; + }; + Element: HTMLTableCellElement; +} +export default class HdsAdvancedTableTd extends Component { + @action + didInsert(element: HTMLTableCellElement): void { + didInsertGridCell(element); + element.addEventListener('keydown', handleGridCellKeyPress); + } + + get style(): SafeString | undefined { + const { rowspan, colspan } = this.args; + let style = ''; + + if (rowspan) { + style += `grid-row: span ${rowspan};`; + } + + if (colspan) { + style += `grid-column: span ${colspan}`; + } + + if (style.length > 0) return htmlSafe(style); + return undefined; + } + + get align(): HdsAdvancedTableHorizontalAlignment { + const { align = DEFAULT_ALIGN } = this.args; + + assert( + `@align for "Hds::AdvancedTable::Td" must be one of the following: ${ALIGNMENTS.join( + ', ' + )}; received: ${align}`, + ALIGNMENTS.includes(align) + ); + return align; + } + + get classNames(): string { + const classes = [ + 'hds-advanced-table__td', + 'hds-typography-body-200', + 'hds-font-weight-regular', + ]; + + // add a class based on the @align argument + if (this.align) { + classes.push(`hds-advanced-table__td--align-${this.align}`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/th-button-expand.hbs b/packages/components/src/components/hds/advanced-table/th-button-expand.hbs new file mode 100644 index 0000000000..d0a8b0df7d --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-expand.hbs @@ -0,0 +1,18 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-button-expand.ts b/packages/components/src/components/hds/advanced-table/th-button-expand.ts new file mode 100644 index 0000000000..91e994dff2 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-expand.ts @@ -0,0 +1,58 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { HdsAdvancedTableThExpandIconValues } from './types.ts'; +import type { HdsAdvancedTableThSortExpandIcons } from './types.ts'; +export interface HdsAdvancedTableThButtonExpandSignature { + Args: { + labelId?: string; + }; + Element: HTMLButtonElement; +} + +export default class HdsAdvancedTableThButtonExpand extends Component { + @tracked isExpanded = false; + // Generates a unique ID for the (hidden) "label prefix/suffix" elements + prefixLabelId = 'prefix-' + guidFor(this); + suffixLabelId = 'suffix-' + guidFor(this); + + get icon(): HdsAdvancedTableThSortExpandIcons { + if (this.isExpanded) { + return HdsAdvancedTableThExpandIconValues.ChevronDown; + } else { + return HdsAdvancedTableThExpandIconValues.ChevronRight; + } + } + + // Determines the label (suffix) to use in the `aria-labelledby` attribute of the button, + // used to indicate what will happen if the user clicks on the button + // get sortOrderLabel(): HdsAdvancedTableThSortOrderLabels { + // return this.args.sortOrder === HdsAdvancedTableThSortOrderValues.Asc + // ? HdsAdvancedTableThSortOrderLabelValues.Desc + // : HdsAdvancedTableThSortOrderLabelValues.Asc; + // } + + @action onClick() { + this.isExpanded = !this.isExpanded; + } + + get classNames(): string { + const classes = [ + 'hds-advanced-table__th-button', + 'hds-advanced-table__th-button--expand', + ]; + + // add a class based on the isExpanded state + if (this.isExpanded) { + classes.push(`hds-advanced-table__th-button--is-expanded`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/th-button-sort.hbs b/packages/components/src/components/hds/advanced-table/th-button-sort.hbs new file mode 100644 index 0000000000..a9edc5282c --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-sort.hbs @@ -0,0 +1,18 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-button-sort.ts b/packages/components/src/components/hds/advanced-table/th-button-sort.ts new file mode 100644 index 0000000000..94111adab0 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-sort.ts @@ -0,0 +1,79 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { + HdsAdvancedTableThSortOrderIconValues, + HdsAdvancedTableThSortOrderLabelValues, + HdsAdvancedTableThSortOrderValues, +} from './types.ts'; +import type { + HdsAdvancedTableThSortOrder, + HdsAdvancedTableThSortOrderIcons, + HdsAdvancedTableThSortOrderLabels, +} from './types.ts'; +export interface HdsAdvancedTableThButtonSortSignature { + Args: { + labelId?: string; + onClick?: () => void; + sortOrder?: HdsAdvancedTableThSortOrder; + }; + Element: HTMLButtonElement; +} + +const NOOP = () => {}; + +export default class HdsAdvancedTableThButtonSort extends Component { + // Generates a unique ID for the (hidden) "label prefix/suffix" elements + prefixLabelId = 'prefix-' + guidFor(this); + suffixLabelId = 'suffix-' + guidFor(this); + + get icon(): HdsAdvancedTableThSortOrderIcons { + switch (this.args.sortOrder) { + case HdsAdvancedTableThSortOrderValues.Asc: + return HdsAdvancedTableThSortOrderIconValues.ArrowUp; + case HdsAdvancedTableThSortOrderValues.Desc: + return HdsAdvancedTableThSortOrderIconValues.ArrowDown; + default: + return HdsAdvancedTableThSortOrderIconValues.SwapVertical; + } + } + + // Determines the label (suffix) to use in the `aria-labelledby` attribute of the button, + // used to indicate what will happen if the user clicks on the button + get sortOrderLabel(): HdsAdvancedTableThSortOrderLabels { + return this.args.sortOrder === HdsAdvancedTableThSortOrderValues.Asc + ? HdsAdvancedTableThSortOrderLabelValues.Desc + : HdsAdvancedTableThSortOrderLabelValues.Asc; + } + + get onClick(): () => void { + const { onClick } = this.args; + + if (typeof onClick === 'function') { + return onClick; + } else { + return NOOP; + } + } + + get classNames(): string { + const classes = [ + 'hds-advanced-table__th-button', + 'hds-advanced-table__th-button--sort', + ]; + + // add a class based on the @sortOrder argument + if ( + this.args.sortOrder === HdsAdvancedTableThSortOrderValues.Asc || + this.args.sortOrder === HdsAdvancedTableThSortOrderValues.Desc + ) { + classes.push(`hds-advanced-table__th-button--is-sorted`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/th-button-tooltip.hbs b/packages/components/src/components/hds/advanced-table/th-button-tooltip.hbs new file mode 100644 index 0000000000..02fc3c252e --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-tooltip.hbs @@ -0,0 +1,15 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-button-tooltip.ts b/packages/components/src/components/hds/advanced-table/th-button-tooltip.ts new file mode 100644 index 0000000000..cda379d4fe --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-button-tooltip.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; +import { guidFor } from '@ember/object/internals'; + +export interface HdsAdvancedTableThButtonTooltipSignature { + Args: { + labelId?: string; + tooltip: string; + }; + Element: HTMLButtonElement; +} + +export default class HdsAdvancedTableThButtonTooltip extends Component { + // Generates a unique ID for the (hidden) "label prefix" element + prefixLabelId = guidFor(this); + + get tooltip(): string { + assert( + `@tooltip for "HdsAdvancedTableThButtonTooltip" must be a string`, + typeof this.args.tooltip === 'string' + ); + return this.args.tooltip; + } + + get classNames(): string { + const classes = [ + 'hds-advanced-table__th-button', + 'hds-advanced-table__th-button--tooltip', + ]; + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/th-selectable.hbs b/packages/components/src/components/hds/advanced-table/th-selectable.hbs new file mode 100644 index 0000000000..0c6719ebde --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-selectable.hbs @@ -0,0 +1,31 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + +
+ + {{#if this.isSortable}} + selection state + + {{/if}} +
+
\ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-selectable.ts b/packages/components/src/components/hds/advanced-table/th-selectable.ts new file mode 100644 index 0000000000..5d21e2c83e --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-selectable.ts @@ -0,0 +1,125 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; +import { tracked } from '@glimmer/tracking'; +import { + HdsAdvancedTableThSortOrderValues, + HdsAdvancedTableThSortOrderLabelValues, +} from './types.ts'; +import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base'; +import type { + HdsAdvancedTableScope, + HdsAdvancedTableThSortOrder, + HdsAdvancedTableThSortOrderLabels, +} from './types'; +import type { HdsAdvancedTableThSignature } from './th'; + +export interface HdsAdvancedTableThSelectableSignature { + Args: { + didInsertCheckbox?: ( + checkbox: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ) => void; + isSelected?: boolean; + onClickSortBySelected?: () => void; + onSelectionChange?: ( + target: HdsFormCheckboxBaseSignature['Element'], + selectionKey: string | undefined + ) => void; + selectionAriaLabelSuffix?: string; + selectionKey?: string; + selectionScope?: HdsAdvancedTableScope; + sortBySelectedOrder?: HdsAdvancedTableThSortOrder; + willDestroy?: (selectionKey?: string) => void; + }; + Element: HdsAdvancedTableThSignature['Element']; +} + +export default class HdsAdvancedTableThSelectable extends Component { + @tracked isSelected = this.args.isSelected ?? false; + + guid = guidFor(this); + + checkboxId = `checkbox-${this.guid}`; + labelId = `label-${this.guid}`; + + get isSortable(): boolean { + return this.args.onClickSortBySelected !== undefined; + } + + get ariaLabel(): string { + const { selectionAriaLabelSuffix } = this.args; + const prefix = this.isSelected ? 'Deselect' : 'Select'; + if (selectionAriaLabelSuffix) { + return `${prefix} ${selectionAriaLabelSuffix}`; + } else { + return prefix; + } + } + + get ariaSort(): HdsAdvancedTableThSortOrderLabels | undefined { + switch (this.args.sortBySelectedOrder) { + case HdsAdvancedTableThSortOrderValues.Asc: + return HdsAdvancedTableThSortOrderLabelValues.Asc; + case HdsAdvancedTableThSortOrderValues.Desc: + return HdsAdvancedTableThSortOrderLabelValues.Desc; + default: + // none is the default per the spec. + return HdsAdvancedTableThSortOrderLabelValues.None; + } + } + + @action + didInsertCheckbox(checkbox: HdsFormCheckboxBaseSignature['Element']): void { + const { didInsertCheckbox } = this.args; + if (typeof didInsertCheckbox === 'function') { + didInsertCheckbox(checkbox, this.args.selectionKey); + // we need to use a custom event listener here because changing the `checked` value via JS + // (and this happens with the "select all") doesn't trigger the `change` event + // and consequently the `aria-label` won't be automatically updated (and so we have to force it) + checkbox.addEventListener( + 'toggle', + this.updateAriaLabel.bind(this), + true + ); + } + } + + @action + willDestroyNode(checkbox: HdsFormCheckboxBaseSignature['Element']): void { + super.willDestroy(); + const { willDestroy } = this.args; + if (typeof willDestroy === 'function') { + willDestroy(this.args.selectionKey); + if (checkbox) { + checkbox.removeEventListener( + 'toggle', + this.updateAriaLabel.bind(this), + true + ); + } + } + } + + @action + onSelectionChange(event: Event): void { + // Assert event.target as HdsFormCheckboxBaseSignature['Element'] to access the 'checked' property + const target = event.target as HdsFormCheckboxBaseSignature['Element']; + this.isSelected = target.checked; + const { onSelectionChange } = this.args; + if (typeof onSelectionChange === 'function') { + onSelectionChange(target, this.args.selectionKey); + } + } + + updateAriaLabel(event: Event): void { + // Assert event.target as HTMLInputElement to access the 'checked' property + const target = event.target as HdsFormCheckboxBaseSignature['Element']; + this.isSelected = target.checked; + } +} diff --git a/packages/components/src/components/hds/advanced-table/th-sort.hbs b/packages/components/src/components/hds/advanced-table/th-sort.hbs new file mode 100644 index 0000000000..9d33d173b4 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-sort.hbs @@ -0,0 +1,23 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +
+ {{yield}} + {{#if @tooltip}} + + {{/if}} + +
+ \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-sort.ts b/packages/components/src/components/hds/advanced-table/th-sort.ts new file mode 100644 index 0000000000..a9ee456b35 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-sort.ts @@ -0,0 +1,95 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; + +import { + HdsAdvancedTableHorizontalAlignmentValues, + HdsAdvancedTableThSortOrderValues, + HdsAdvancedTableThSortOrderLabelValues, +} from './types.ts'; +import type { + HdsAdvancedTableHorizontalAlignment, + HdsAdvancedTableThSortOrder, + HdsAdvancedTableThSortOrderLabels, +} from './types.ts'; +import type { HdsAdvancedTableThButtonSortSignature } from './th-button-sort'; +import { didInsertGridCell, handleGridCellKeyPress } from './helpers.ts'; + +export const ALIGNMENTS: string[] = Object.values( + HdsAdvancedTableHorizontalAlignmentValues +); +export const DEFAULT_ALIGN = HdsAdvancedTableHorizontalAlignmentValues.Left; + +export interface HdsAdvancedTableThSortSignature { + Args: { + align?: HdsAdvancedTableHorizontalAlignment; + onClickSort?: HdsAdvancedTableThButtonSortSignature['Args']['onClick']; + sortOrder?: HdsAdvancedTableThSortOrder; + tooltip?: string; + width?: string; + rowspan?: number; + colspan?: number; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsAdvancedTableThSort extends Component { + labelId = guidFor(this); + + @action + didInsert(element: HTMLTableCellElement): void { + didInsertGridCell(element); + element.addEventListener('keydown', handleGridCellKeyPress); + } + + /** + * @param ariaSort + * @type {HdsAdvancedTableThSortOrderLabels} + * @private + * @default none + * @description Sets the aria-sort attribute based on the sort order defined; acceptable values are ascending, descending, none(default) and other. Authors SHOULD only apply this property to table headers or grid headers. If the property is not provided, there is no defined sort order. For each table or grid, authors SHOULD apply aria-sort to only one header at a time. + */ + get ariaSort(): HdsAdvancedTableThSortOrderLabels { + switch (this.args.sortOrder) { + case HdsAdvancedTableThSortOrderValues.Asc: + return HdsAdvancedTableThSortOrderLabelValues.Asc; + case HdsAdvancedTableThSortOrderValues.Desc: + return HdsAdvancedTableThSortOrderLabelValues.Desc; + default: + // none is the default per the spec. + return HdsAdvancedTableThSortOrderLabelValues.None; + } + } + + get align(): HdsAdvancedTableHorizontalAlignment { + const { align = DEFAULT_ALIGN } = this.args; + + assert( + `@align for "Hds::Table" must be one of the following: ${ALIGNMENTS.join( + ', ' + )}; received: ${align}`, + ALIGNMENTS.includes(align) + ); + return align; + } + + get classNames(): string { + const classes = ['hds-advanced-table__th', 'hds-advanced-table__th--sort']; + + // add a class based on the @align argument + if (this.align) { + classes.push(`hds-advanced-table__th--align-${this.align}`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/th.hbs b/packages/components/src/components/hds/advanced-table/th.hbs new file mode 100644 index 0000000000..264b8ae532 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th.hbs @@ -0,0 +1,35 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + {{#if @isVisuallyHidden}} + {{yield}} + {{else}} + {{#if @tooltip}} +
+ {{#if @isExpandable}} + + {{/if}} + {{yield}} + +
+ {{else}} +
+ {{#if @isExpandable}} + + {{/if}} + {{yield}} +
+ {{/if}} + {{/if}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th.ts b/packages/components/src/components/hds/advanced-table/th.ts new file mode 100644 index 0000000000..98f17339e7 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th.ts @@ -0,0 +1,81 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { guidFor } from '@ember/object/internals'; +import { assert } from '@ember/debug'; +import { action } from '@ember/object'; + +import type { + HdsAdvancedTableHorizontalAlignment, + HdsAdvancedTableScope, +} from './types.ts'; +import { HdsAdvancedTableHorizontalAlignmentValues } from './types.ts'; +import { didInsertGridCell, handleGridCellKeyPress } from './helpers.ts'; + +export const ALIGNMENTS: string[] = Object.values( + HdsAdvancedTableHorizontalAlignmentValues +); +export const DEFAULT_ALIGN = HdsAdvancedTableHorizontalAlignmentValues.Left; + +export interface HdsAdvancedTableThSignature { + Args: { + align?: HdsAdvancedTableHorizontalAlignment; + isVisuallyHidden?: boolean; + scope?: HdsAdvancedTableScope; + tooltip?: string; + width?: string; + rowspan?: number; + colspan?: number; + isExpandable?: boolean; + }; + Blocks: { + default: []; + }; + Element: HTMLTableCellElement; +} + +export default class HdsAdvancedTableTh extends Component { + labelId = guidFor(this); + + @action + didInsert(element: HTMLTableCellElement): void { + didInsertGridCell(element); + element.addEventListener('keydown', handleGridCellKeyPress); + } + + get scope(): HdsAdvancedTableScope { + const { scope = 'col' } = this.args; + return scope; + } + + get role(): string { + if (this.scope === 'col') return 'columnheader'; + else return 'rowheader'; + } + + get align(): HdsAdvancedTableHorizontalAlignment { + const { align = DEFAULT_ALIGN } = this.args; + + assert( + `@align for "Hds::Table::Th" must be one of the following: ${ALIGNMENTS.join( + ', ' + )}; received: ${align}`, + ALIGNMENTS.includes(align) + ); + return align; + } + + get classNames(): string { + const classes = ['hds-advanced-table__th']; + + // add a class based on the @align argument + if (this.align) { + classes.push(`hds-advanced-table__th--align-${this.align}`); + } + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/advanced-table/tr-expandable-group.hbs b/packages/components/src/components/hds/advanced-table/tr-expandable-group.hbs new file mode 100644 index 0000000000..9b7eeec7aa --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/tr-expandable-group.hbs @@ -0,0 +1,10 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} +{{yield}} +{{#if (and this.hasChildren)}} + {{#each this.children as |childRecord|}} + + {{/each}} +{{/if}} \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/tr-expandable-group.ts b/packages/components/src/components/hds/advanced-table/tr-expandable-group.ts new file mode 100644 index 0000000000..e5ae828f5e --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/tr-expandable-group.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ +import Component from '@glimmer/component'; + +import type { HdsAdvancedTableHorizontalAlignment } from './types'; +export interface HdsAdvancedTableTrExpandableGroupSignature { + Args: { + align?: HdsAdvancedTableHorizontalAlignment; + depth?: number; + record: Record; + }; + Blocks: { + default?: []; + }; + Element: HTMLTableElement; +} + +export default class HdsAdvancedTableTrExpandableGroup extends Component { + get children(): Array> | undefined { + const { record } = this.args; + + if (record['children'] && Array.isArray(record['children'])) { + return record['children']; + } + return undefined; + } + + get hasChildren(): boolean { + if (this.children) return true; + return false; + } + + get newDepth(): number { + const { depth = 1 } = this.args; + return depth + 1; + } +} diff --git a/packages/components/src/components/hds/advanced-table/tr.hbs b/packages/components/src/components/hds/advanced-table/tr.hbs new file mode 100644 index 0000000000..89f7f9cd5a --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/tr.hbs @@ -0,0 +1,22 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + + + {{#if @isSelectable}} + + {{/if}} + + {{yield}} + \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/tr.ts b/packages/components/src/components/hds/advanced-table/tr.ts new file mode 100644 index 0000000000..6e948fd35d --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/tr.ts @@ -0,0 +1,68 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { assert } from '@ember/debug'; +import { HdsAdvancedTableScopeValues } from './types.ts'; +import type { + HdsAdvancedTableScope, + HdsAdvancedTableThSortOrder, +} from './types.ts'; +import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base'; +import type { HdsAdvancedTableSignature } from './index.ts'; +import type { HdsAdvancedTableThSelectableSignature } from './th-selectable.ts'; + +export interface BaseHdsAdvancedTableTrSignature { + Args: { + selectableColumnKey?: HdsAdvancedTableSignature['Args']['selectableColumnKey']; + isSelectable?: boolean; + isSelected?: false; + selectionAriaLabelSuffix?: string; + selectionKey?: string; + selectionScope?: HdsAdvancedTableScope; + sortBySelectedOrder?: HdsAdvancedTableThSortOrder; + didInsertCheckbox?: ( + checkbox: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ) => void; + onSelectionChange?: ( + checkbox?: HdsFormCheckboxBaseSignature['Element'], + selectionKey?: string + ) => void; + willDestroy?: () => void; + onClickSortBySelected?: HdsAdvancedTableThSelectableSignature['Args']['onClickSortBySelected']; + }; + Blocks: { + default: []; + }; + Element: HTMLTableRowElement; +} + +// Extended interface for selectable rows +export interface SelectableHdsAdvancedTableTrArgs + extends BaseHdsAdvancedTableTrSignature { + Args: BaseHdsAdvancedTableTrSignature['Args'] & { + isSelectable: true; + selectionScope?: HdsAdvancedTableScopeValues.Row; + selectionKey: string; // Now required for selectable rows + }; +} + +// Union type to combine both possible states +export type HdsAdvancedTableTrSignature = + | BaseHdsAdvancedTableTrSignature + | SelectableHdsAdvancedTableTrArgs; +export default class HdsAdvancedTableTr extends Component { + get selectionKey(): string | undefined { + if (this.args.isSelectable && this.args.selectionScope === 'row') { + assert( + `@selectionKey must be defined on Table::Tr or B.Tr when @isSelectable is true`, + this.args.selectionKey + ); + return this.args.selectionKey; + } + return undefined; + } +} diff --git a/packages/components/src/components/hds/advanced-table/types.ts b/packages/components/src/components/hds/advanced-table/types.ts new file mode 100644 index 0000000000..bd3c3893c7 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/types.ts @@ -0,0 +1,108 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base'; + +export enum HdsAdvancedTableDensityValues { + Default = 'default', + Medium = 'medium', + Short = 'short', + Tall = 'tall', +} +export type HdsAdvancedTableDensities = `${HdsAdvancedTableDensityValues}`; + +export enum HdsAdvancedTableHorizontalAlignmentValues { + Center = 'center', + Left = 'left', + Right = 'right', +} +export type HdsAdvancedTableHorizontalAlignment = + `${HdsAdvancedTableHorizontalAlignmentValues}`; + +export enum HdsAdvancedTableScopeValues { + Col = 'col', + Row = 'row', +} +export type HdsAdvancedTableScope = `${HdsAdvancedTableScopeValues}`; + +export enum HdsAdvancedTableThExpandIconValues { + ChevronRight = 'chevron-right', + ChevronDown = 'chevron-down', +} +export type HdsAdvancedTableThSortExpandIcons = + `${HdsAdvancedTableThExpandIconValues}`; + +export enum HdsAdvancedTableThSortOrderIconValues { + ArrowDown = 'arrow-down', + ArrowUp = 'arrow-up', + SwapVertical = 'swap-vertical', +} +export type HdsAdvancedTableThSortOrderIcons = + `${HdsAdvancedTableThSortOrderIconValues}`; + +export enum HdsAdvancedTableThSortOrderLabelValues { + Asc = 'ascending', + Desc = 'descending', + None = 'none', +} +export type HdsAdvancedTableThSortOrderLabels = + `${HdsAdvancedTableThSortOrderLabelValues}`; + +export enum HdsAdvancedTableThSortOrderValues { + Asc = 'asc', + Desc = 'desc', +} +export type HdsAdvancedTableThSortOrder = + `${HdsAdvancedTableThSortOrderValues}`; + +export enum HdsAdvancedTableVerticalAlignmentValues { + Baseline = 'baseline', + Middle = 'middle', + Top = 'top', +} +export type HdsAdvancedTableVerticalAlignment = + `${HdsAdvancedTableVerticalAlignmentValues}`; + +export type HdsAdvancedTableSelectableRow = { + checkbox: HdsFormCheckboxBaseSignature['Element']; + selectionKey: string; +}; + +interface BaseHdsAdvancedTableColumn { + align?: HdsAdvancedTableHorizontalAlignment; + isVisuallyHidden?: boolean; + label: string; + sortingFunction?: HdsAdvancedTableSortingFunction; + tooltip?: string; + width?: string; +} + +interface SortableHdsAdvancedTableColumn extends BaseHdsAdvancedTableColumn { + isSortable: true; + key: string; +} + +interface NonSortableHdsAdvancedTableColumn extends BaseHdsAdvancedTableColumn { + isSortable?: false; + key?: string; +} + +export type HdsAdvancedTableColumn = + | SortableHdsAdvancedTableColumn + | NonSortableHdsAdvancedTableColumn; + +export type HdsAdvancedTableSortingFunction = (a: T, b: T) => number; + +export interface HdsAdvancedTableOnSelectionChangeSignature { + selectionKey?: string; + selectionCheckboxElement?: HdsFormCheckboxBaseSignature['Element']; + selectedRowsKeys: string[]; + selectableRowsStates: { + selectionKey: string; + isSelected?: boolean; + }[]; +} + +export type HdsAdvancedTableModel = Array>; diff --git a/packages/components/src/components/hds/table/helpers.ts b/packages/components/src/components/hds/table/helpers.ts new file mode 100644 index 0000000000..4ea1dba42f --- /dev/null +++ b/packages/components/src/components/hds/table/helpers.ts @@ -0,0 +1,120 @@ +// TODO +// did insert +// add escape listener to focusable elements inside cell +// Page Down: Moves focus down an author-determined number of rows, typically scrolling so the bottom row in the currently visible set of rows becomes one of the first visible rows. If focus is in the last row of the grid, focus does not move. +// Page Up: Moves focus up an author-determined number of rows, typically scrolling so the top row in the currently visible set of rows becomes one of the last visible rows. If focus is in the first row of the grid, focus does not move. +// Home: moves focus to the first cell in the row that contains focus. +// End: moves focus to the last cell in the row that contains focus. +// Control + Home: moves focus to the first cell in the first row. +// Control + End: moves focus to the last cell in the last row. + +import type { HdsTableTdSignature } from './td'; +import type { HdsTableThSignature } from './th'; + +export const didInsertGridCell = ( + cell: HdsTableThSignature['Element'] | HdsTableTdSignature['Element'] +): void => { + const currentRow = cell.parentElement; + + if (currentRow?.parentElement?.tagName === 'THEAD') { + const thead = currentRow.parentElement; + + if ( + thead.children.item(0) === currentRow && + currentRow.children.item(0) === cell + ) { + cell.setAttribute('tabindex', '0'); + } else { + cell.setAttribute('tabindex', '-1'); + } + } else if (currentRow?.parentElement?.tagName === 'TBODY') { + const table = currentRow.parentElement.parentElement; + const thead = table?.querySelector('thead'); + const tbody = table?.querySelector('tbody'); + + if (thead === null) { + if ( + tbody?.children.item(0) === currentRow && + currentRow.children.item(0) === cell + ) { + cell.setAttribute('tabindex', '0'); + } + } else { + cell.setAttribute('tabindex', '-1'); + } + } +}; + +export const handleGridCellKeyPress = (event: KeyboardEvent): void => { + const { key, target } = event; + + const changeActiveCell = (oldCell: HTMLElement, newCell: HTMLElement) => { + newCell.setAttribute('tabindex', '0'); + newCell.classList.add('hds-table__td--gridcell-active'); + + oldCell.setAttribute('tabindex', '-1'); + oldCell.classList.remove('hds-table__td--gridcell-active'); + + newCell.focus(); + }; + + if (target instanceof HTMLElement) { + if (key === 'Enter') { + const focusableElements = target.querySelectorAll( + 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + + if (focusableElements.length === 1) { + const element = focusableElements[0] as HTMLElement; + element.click(); + } else if (focusableElements.length > 1) { + const element = focusableElements[0] as HTMLElement; + element.focus(); + } + } else if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') { + const nextElement = + key === 'ArrowRight' + ? target.nextElementSibling + : target.previousElementSibling; + + if (nextElement !== null && nextElement instanceof HTMLElement) { + changeActiveCell(target, nextElement); + } + } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + const currentRow = target.parentElement; + + if (currentRow instanceof HTMLElement) { + const currentCellIndex = Array.from(currentRow.children).indexOf( + target + ); + const nextRow = + key === 'ArrowDown' + ? currentRow.nextElementSibling + : currentRow.previousElementSibling; + + if (nextRow !== null && nextRow instanceof HTMLElement) { + const nextCell = nextRow.children[currentCellIndex]; + if (nextCell instanceof HTMLElement) { + changeActiveCell(target, nextCell); + } + } else { + if (currentRow?.parentElement?.tagName === 'TBODY') { + const thead = currentRow?.parentElement?.previousElementSibling; + const lastTheadRow = thead?.querySelector('tr:last-child'); + const theadCell = lastTheadRow?.children[currentCellIndex]; + if (key === 'ArrowUp' && theadCell instanceof HTMLElement) { + changeActiveCell(target, theadCell); + } + } else if (currentRow?.parentElement?.tagName === 'THEAD') { + const tbody = currentRow?.parentElement?.nextElementSibling; + const firstTbodyRow = tbody?.querySelector('tr:first-child'); + const tbodyCell = firstTbodyRow?.children[currentCellIndex]; + if (key === 'ArrowDown' && tbodyCell instanceof HTMLElement) { + changeActiveCell(target, tbodyCell); + } + } + } + } + } + } +}; diff --git a/packages/components/src/styles/@hashicorp/design-system-components.scss b/packages/components/src/styles/@hashicorp/design-system-components.scss index 8688eaeff6..8bb775a0ff 100644 --- a/packages/components/src/styles/@hashicorp/design-system-components.scss +++ b/packages/components/src/styles/@hashicorp/design-system-components.scss @@ -12,12 +12,13 @@ // Notice: this list can be automatically edited by the Ember blueprint, please don't remove the start/end comments // START COMPONENTS CSS FILES IMPORTS +// @use "../components/app-header"; +// @use "../components/app-side-nav"; @use "../components/accordion"; +@use "../components/advanced-table"; @use "../components/alert"; @use "../components/app-footer"; @use "../components/app-frame"; -// @use "../components/app-header"; -// @use "../components/app-side-nav"; @use "../components/application-state"; @use "../components/badge"; @use "../components/badge-count"; diff --git a/packages/components/src/styles/components/advanced-table.scss b/packages/components/src/styles/components/advanced-table.scss new file mode 100644 index 0000000000..a4406be2ba --- /dev/null +++ b/packages/components/src/styles/components/advanced-table.scss @@ -0,0 +1,295 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// +// ADVANCED TABLE +// +// + +@use "../mixins/focus-ring" as *; + +$hds-advanced-table-border-radius: 6px; +$hds-advanced-table-border-width: 1px; +$hds-advanced-table-inner-border-radius: $hds-advanced-table-border-radius - $hds-advanced-table-border-width; +$hds-advanced-table-border-color: var(--token-color-border-primary); +$hds-advanced-table-header-height: 48px; +$hds-advanced-table-cell-padding-medium: 14px 16px 13px 16px; // the 1px difference is to account for the bottom border +$hds-advanced-table-cell-padding-short: 6px 16px 5px 16px; // the 1px difference is to account for the bottom border +$hds-advanced-table-cell-padding-tall: 22px 16px 21px 16px; // the 1px difference is to account for the bottom border + +// ADVANCED TABLE + +.hds-advanced-table { + display: grid; + align-items: center; + width: 100%; + border: $hds-advanced-table-border-width solid $hds-advanced-table-border-color; + border-radius: $hds-advanced-table-border-radius; + border-spacing: 0; +} + +// ---------------------------------------------------------------- + +// TABLE HEADER + +.hds-advanced-table__thead { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + + .hds-advanced-table__tr { + display: contents; + // color: var(--token-color-foreground-strong); + // background-color: var(--token-color-surface-strong); + + .hds-advanced-table__th { + position: relative; + align-content: center; + height: 100%; + padding: $hds-advanced-table-cell-padding-medium; + color: var(--token-color-foreground-strong); + text-align: left; + background-color: var(--token-color-surface-strong); + border-top: none; + border-right: none; + border-bottom: $hds-advanced-table-border-width solid $hds-advanced-table-border-color; + border-left: none; + + &:focus { + @include hds-focus-ring-with-after-pseudo-element($top: -1px, $right: -1px, $bottom: -1px, $left: -1px); + } + + // border between two cells (we emulate a cell border slightly detached from the top/bottom borders) + + + .hds-advanced-table__th::before { + position: absolute; + top: 6px; + bottom: 6px; + left: -1px; // we need to offset the border by 1px to render a right border of the previous cell + width: 1px; + background-color: $hds-advanced-table-border-color; + content: ""; + pointer-events: none; + } + } + + // horizontal alignment + + .hds-advanced-table__th--align-center, + .hds-advanced-table__td--align-center { + text-align: center; + + .hds-advanced-table__th-content { + justify-content: center; + } + } + + .hds-advanced-table__th--align-right, + .hds-advanced-table__td--align-right { + text-align: right; + + .hds-advanced-table__th-content { + justify-content: flex-end; + } + } + + // border radius: target first and last th elements in the row + + &:first-of-type { + .hds-advanced-table__th:first-child { + border-top-left-radius: $hds-advanced-table-inner-border-radius; + } + + .hds-advanced-table__th:last-child { + border-top-right-radius: $hds-advanced-table-inner-border-radius; + } + } + } +} + +// multi-select (isSelectable=true) + +.hds-advanced-table__th--is-selectable { + width: 48px; +} + +.hds-advanced-table__th-content { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-start; +} + +.hds-advanced-table__th-button { + display: flex; + flex: none; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + margin: -2px 0; // this is necessary to compensate for the height of the button being 24px while the line height of the text is 20px (the overall height of the cell shoud be 48px and we want to keep the cell's padding as is for consistency with Figma) + padding: 0; + color: var(--token-color-foreground-faint); + background-color: transparent; + border: 1px solid transparent; + border-radius: 3px; + + &:hover, + &.mock-hover { + color: var(--token-color-foreground-primary); + background-color: var(--token-color-surface-interactive); + border-color: var(--token-color-border-strong); + box-shadow: var(--token-elevation-low-box-shadow); + cursor: pointer; + } + + @include hds-focus-ring-with-pseudo-element($radius: inherit); + + &:active, + &.mock-active { + color: var(--token-color-foreground-primary); + background-color: var(--token-color-surface-interactive-active); + border-color: var(--token-color-border-strong); + box-shadow: none; + } +} + +.hds-advanced-table__th-button--is-sorted { + color: var(--token-color-foreground-action); + + &:hover, + &.mock-hover { + color: var(--token-color-foreground-action-hover); + } + + &:active, + &.mock-active { + color: var(--token-color-foreground-action-active); + } +} + +.hds-advanced-table__th-button-aria-label-hidden-segment { + display: none; +} + + +// ---------------------------------------------------------------- + +// TABLE BODY + +.hds-advanced-table__tbody { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + + .hds-advanced-table__tr { + display: contents; + color: var(--token-color-foreground-primary); + background-color: var(--token-color-surface-primary); + + // striped rows + + .hds-advanced-table--striped &:nth-child(even) { + background-color: var(--token-color-surface-faint); + } + + // border radius: target first th (scope of row) and td, and last td elements in the last row + + &:last-of-type { + .hds-advanced-table__th, + .hds-advanced-table__td { + border-bottom: none; + } + + .hds-advanced-table__th:first-child, + .hds-advanced-table__td:first-child { + border-bottom-left-radius: $hds-advanced-table-inner-border-radius; + } + + // a will never be last child, only first child + .hds-advanced-table__td:last-child { + border-bottom-right-radius: $hds-advanced-table-inner-border-radius; + } + } + } + + .hds-advanced-table__th, + .hds-advanced-table__td { +align-content: center; +height: 100%; + text-align: left; + border-top: none; + border-right: none; + border-bottom: $hds-advanced-table-border-width solid $hds-advanced-table-border-color; + border-left: none; + + &:focus { + @include hds-focus-ring-with-after-pseudo-element($top: -1px, $right: -1px, $bottom: -1px, $left: -1px); + } + + // density + + .hds-advanced-table--density-short & { + padding: $hds-advanced-table-cell-padding-short; + } + + .hds-advanced-table--density-medium & { + padding: $hds-advanced-table-cell-padding-medium; + } + + .hds-advanced-table--density-tall & { + padding: $hds-advanced-table-cell-padding-tall; + } + + } + + // horizontal alignment + + .hds-advanced-table__th--align-center, + .hds-advanced-table__td--align-center { + text-align: center; + + .hds-advanced-table__th-content { + justify-content: center; + } + } + + .hds-advanced-table__th--align-right, + .hds-advanced-table__td--align-right { + text-align: right; + + .hds-advanced-table__th-content { + justify-content: flex-end; + } + } + + // vertical alignment (applied at table level) + + .hds-advanced-table__th, + .hds-advanced-table__td { + .hds-advanced-table--valign-top & { + vertical-align: top; + } + + .hds-advanced-table--valign-middle & { + vertical-align: middle; + } + + .hds-advanced-table--valign-baseline & { + vertical-align: baseline; + } + } +} + + +// ---------------------------------------------------------------- + +// TABLE CONTENT + +.hds-advanced-table__checkbox { + display: block; + margin: 2px 0; +} diff --git a/packages/components/src/styles/mixins/_focus-ring.scss b/packages/components/src/styles/mixins/_focus-ring.scss index 525d52d386..179584ffbc 100644 --- a/packages/components/src/styles/mixins/_focus-ring.scss +++ b/packages/components/src/styles/mixins/_focus-ring.scss @@ -75,3 +75,49 @@ } } } + +@mixin hds-focus-ring-with-after-pseudo-element($top: 0, $right: 0, $bottom: 0, $left: 0, $radius: 5px, $color: action) { + position: relative; + z-index: 1; + outline-style: solid; // used to avoid double outline+focus-ring in Safari (see https://github.com/hashicorp/design-system-components/issues/161#issuecomment-1031548656) + outline-color: transparent; + isolation: isolate; // used to create a new stacking context (needed to have the pseudo element below text/icon but not the parent container) + + &::after { + position: absolute; + top: $top; + right: $right; + bottom: $bottom; + left: $left; + z-index: -1; + border-radius: $radius; + content: ""; + } + + // default focus for browsers that still rely on ":focus" + &:focus, + &.mock-focus { + &::after { + box-shadow: var(--token-focus-ring-#{$color}-box-shadow); + } + } + // undo the previous declaration for browsers that support ":focus-visible" but wouldn't normally show default focus styles + &:focus:not(:focus-visible) { + &::after { + box-shadow: none; + } + } + // set focus for browsers that support ":focus-visible" + &:focus-visible { + &::after { + box-shadow: var(--token-focus-ring-#{$color}-box-shadow); + } + } + // remove the focus ring on "active + focused" state (by design) + &:focus:active, + &.mock-focus.mock-active { + &::after { + box-shadow: none; + } + } +} diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts index ace0222b19..c0f55c4bf8 100644 --- a/packages/components/src/template-registry.ts +++ b/packages/components/src/template-registry.ts @@ -7,6 +7,16 @@ import type HdsAccordionComponent from './components/hds/accordion'; import type HdsAccordionItemComponent from './components/hds/accordion/item'; import type HdsAccordionItemButtonComponent from './components/hds/accordion/item/button'; +import type HdsAdvancedTableComponent from './components/hds/advanced-table'; +import type HdsAdvancedTableTdComponent from './components/hds/advanced-table/td'; +import type HdsAdvancedTableThButtonExpandComponent from './components/hds/advanced-table/th-button-expand'; +import type HdsAdvancedTableThButtonSortComponent from './components/hds/advanced-table/th-button-sort'; +import type HdsAdvancedTableThComponent from './components/hds/advanced-table/th'; +import type HdsAdvancedTableThButtonTooltipComponent from './components/hds/advanced-table/th-button-tooltip'; +import type HdsAdvancedTableThSortComponent from './components/hds/advanced-table/th-sort'; +import type HdsAdvancedTableThSelectableComponent from './components/hds/advanced-table/th-selectable'; +import type HdsAdvancedTableTrExpandableGroupComponent from './components/hds/advanced-table/tr-expandable-group.ts'; +import type HdsAdvancedTableTrComponent from './components/hds/advanced-table/tr'; import type HdsAlertComponent from './components/hds/alert'; import type HdsAlertDescriptionComponent from './components/hds/alert/description'; import type HdsAlertTitleComponent from './components/hds/alert/title'; @@ -212,6 +222,28 @@ export default interface HdsComponentsRegistry { 'Hds::Accordion::Item::Button': typeof HdsAccordionItemButtonComponent; 'hds/accordion/item/button': typeof HdsAccordionItemButtonComponent; + // Advanced Table + 'Hds::AdvancedTable': typeof HdsAdvancedTableComponent; + 'hds/advanced-table': typeof HdsAdvancedTableComponent; + 'Hds::AdvancedTable::Td': typeof HdsAdvancedTableTdComponent; + 'hds/advanced-table/td': typeof HdsAdvancedTableTdComponent; + 'Hds::AdvancedTable::Th': typeof HdsAdvancedTableThComponent; + 'hds/advanced-table/th': typeof HdsAdvancedTableThComponent; + 'Hds::AdvancedTable::TrExpandableGroup': typeof HdsAdvancedTableTrExpandableGroupComponent; + 'hds/advanced-table/tr-expandable-group': typeof HdsAdvancedTableTrExpandableGroupComponent; + 'Hds::AdvancedTable::Tr': typeof HdsAdvancedTableTrComponent; + 'hds/advanced-table/tr': typeof HdsAdvancedTableTrComponent; + 'Hds::AdvancedTable::ThButtonExpand': typeof HdsAdvancedTableThButtonExpandComponent; + 'hds/advanced-table/th-button-expand': typeof HdsAdvancedTableThButtonExpandComponent; + 'Hds::AdvancedTable::ThButtonSort': typeof HdsAdvancedTableThButtonSortComponent; + 'hds/advanced-table/th-button-sort': typeof HdsAdvancedTableThButtonSortComponent; + 'Hds::AdvancedTable::ThButtonTooltip': typeof HdsAdvancedTableThButtonTooltipComponent; + 'hds/advanced-table/th-button-tooltip': typeof HdsAdvancedTableThButtonTooltipComponent; + 'Hds::AdvancedTable::ThSort': typeof HdsAdvancedTableThSortComponent; + 'hds/advanced-table/th-sort': typeof HdsAdvancedTableThSortComponent; + 'Hds::AdvancedTable::ThSelectable': typeof HdsAdvancedTableThSelectableComponent; + 'hds/advanced-table/th-selectable': typeof HdsAdvancedTableThSelectableComponent; + // Alert 'Hds::Alert': typeof HdsAlertComponent; 'hds/alert': typeof HdsAlertComponent; diff --git a/showcase/app/router.ts b/showcase/app/router.ts index 592bcd4024..1769bcb707 100644 --- a/showcase/app/router.ts +++ b/showcase/app/router.ts @@ -71,6 +71,7 @@ Router.map(function () { this.route('button'); this.route('snippet'); }); + this.route('advanced-table'); }); this.route('layouts', function () { this.route('app-frame', function () { diff --git a/showcase/app/routes/components/advanced-table.js b/showcase/app/routes/components/advanced-table.js new file mode 100644 index 0000000000..83f88084aa --- /dev/null +++ b/showcase/app/routes/components/advanced-table.js @@ -0,0 +1,48 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Route from '@ember/routing/route'; +import { DENSITIES } from '@hashicorp/design-system-components/components/hds/table'; + +// basic function that clones an array of objects (not deep) +// const clone = (arr) => { +// return arr.map((item) => ({ ...item })); +// }; + +const STATES = ['default', 'hover', 'active', 'focus']; + +export default class ComponentsAdvancedTableRoute extends Route { + async model() { + const responseMusic = await fetch('/api/folk.json'); + // const responseClusters = await fetch('/api/mock-clusters-with-status.json'); + // const responseManyColumns = await fetch('/api/mock-many-columns.json'); + // const responseSelectableData = await fetch( + // '/api/mock-selectable-data.json' + // ); + // const responseUserData = await fetch('/api/mock-users.json'); + const responseSpanning = await fetch('/api/mock-spanning-cells.json'); + const responseNested = await fetch('/api/mock-nested-rows.json'); + + const { data: music } = await responseMusic.json(); + // const clusters = await responseClusters.json(); + // const manycolumns = await responseManyColumns.json(); + // const selectableData = await responseSelectableData.json(); + // const userData = await responseUserData.json(); + const spanningData = await responseSpanning.json(); + const nestedData = await responseNested.json(); + + return { + music: music.map((record) => ({ id: record.id, ...record.attributes })), + spanningData, + nestedData, + // selectableData, + // userData: clone(userData.slice(0, 16)), + // clusters, + // manycolumns, + DENSITIES, + STATES, + }; + } +} diff --git a/showcase/app/styles/app.scss b/showcase/app/styles/app.scss index 680d62c3a5..4d74b117fd 100644 --- a/showcase/app/styles/app.scss +++ b/showcase/app/styles/app.scss @@ -28,12 +28,13 @@ // Notice: this list can be automatically edited by the Ember blueprint, please don't remove the start/end comments // START COMPONENT PAGES IMPORTS @import "./showcase-pages/accordion"; +@import "./showcase-pages/advanced-table"; @import "./showcase-pages/alert"; -@import "./showcase-pages/app-header"; @import "./showcase-pages/app-footer"; -@import "./showcase-pages/application-state"; @import "./showcase-pages/app-frame"; +@import "./showcase-pages/app-header"; @import "./showcase-pages/app-side-nav"; +@import "./showcase-pages/application-state"; @import "./showcase-pages/badge"; @import "./showcase-pages/breadcrumb"; @import "./showcase-pages/button"; diff --git a/showcase/app/styles/showcase-pages/advanced-table.scss b/showcase/app/styles/showcase-pages/advanced-table.scss new file mode 100644 index 0000000000..2e27ba5855 --- /dev/null +++ b/showcase/app/styles/showcase-pages/advanced-table.scss @@ -0,0 +1,6 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// ADVANCED-TABLE \ No newline at end of file diff --git a/showcase/app/templates/components/advanced-table.hbs b/showcase/app/templates/components/advanced-table.hbs new file mode 100644 index 0000000000..ea91f67072 --- /dev/null +++ b/showcase/app/templates/components/advanced-table.hbs @@ -0,0 +1,108 @@ +{{! + Copyright (c) HashiCorp, Inc. + SPDX-License-Identifier: MPL-2.0 +}} + +{{page-title "AdvancedTable Component"}} + +AdvancedTable + +
+ + + <:body as |B|> + + {{B.data.artist}} + +
+ + {{B.data.album}} +
+
+ + + + + + + + + + +
+ +
+ + With cells that span rows and columns + + {{! // maybe a separate object called template with the row/col spans? }} + + <:body as |B|> + + {{#if B.data.lorem}} + + {{B.data.lorem.value}} + + {{/if}} + {{#if B.data.ipsum}} + + {{B.data.ipsum.value}} + + {{/if}} + {{#if B.data.dolor}} + + {{B.data.dolor.value}} + + {{/if}} + {{#if B.data.sit-amet}} + + {{B.data.sit-amet.value}} + + {{/if}} + + + + + With nested rows + + + <:body as |B|> + + + {{B.data.name}} + + + {{B.data.status}} + + + {{B.data.description}} + + + + + +
\ No newline at end of file diff --git a/showcase/app/templates/index.hbs b/showcase/app/templates/index.hbs index 7d5689a96b..43e8b88c06 100644 --- a/showcase/app/templates/index.hbs +++ b/showcase/app/templates/index.hbs @@ -318,4 +318,12 @@ - \ No newline at end of file + + + + +
  • + + AdvancedTable + +
  • \ No newline at end of file diff --git a/showcase/public/api/mock-nested-rows.json b/showcase/public/api/mock-nested-rows.json new file mode 100644 index 0000000000..522c1c2e15 --- /dev/null +++ b/showcase/public/api/mock-nested-rows.json @@ -0,0 +1,42 @@ +[ + { + "id": 1, + "name": "Policy set 1", + "status": "PASS", + "description": "", + "children": [ + { + "id": 11, + "name": "test-advisory-pass.sentinel", + "status": "PASS", + "description": "Sample description for this thing." + }, + { + "id": 12, + "name": "test-hard-mandatory-pass.sentinel", + "status": "PASS", + "description": "Sample description for this thing." + } + ] + }, + { + "id": 2, + "name": "Policy set 1", + "status": "FAIL", + "description": "", + "children": [ + { + "id": 21, + "name": "test-advisory-pass.sentinel", + "status": "PASS", + "description": "Sample description for this thing." + }, + { + "id": 22, + "name": "test-hard-mandatory-pass.sentinel", + "status": "FAIL", + "description": "Sample description for this thing." + } + ] + } +] diff --git a/showcase/public/api/mock-spanning-cells.json b/showcase/public/api/mock-spanning-cells.json new file mode 100644 index 0000000000..7bd3b6ae71 --- /dev/null +++ b/showcase/public/api/mock-spanning-cells.json @@ -0,0 +1,59 @@ +[ + { + "id": 1, + "lorem": { + "value": "Scope Row with rowspan='3'", + "rowspan": 3 + }, + "ipsum": {"value": "Cell Content"}, + "dolor":{"value": "Cell Content"}, + "sit-amet": {"value": "Cell Content"} + }, { + "id": 2, + "ipsum": { + "value": "Cell Content with colspan='2'", + "colspan": 2 + }, + "sit-amet": {"value": "Cell Content"} + }, { + "id": 3, + "ipsum": { + "value": "Cell Content with colspan='3'", + "colspan": 3 + } + }, { + "id": 4, + "lorem": { + "value": "Scope Row with rowspan='2'", + "rowspan": 2 + }, + "ipsum":{ + "value": "Cell Content", + "rowspan": 3 + }, + "dolor": {"value": "Cell Content"}, + "sit-amet": {"value": "Cell Content"} + }, { + "id": 5, + "ipsum": { + "value": "Cell Content with colspan='2'", + "colspan": 2 + }, + "sit-amet": {"value": "Cell Content"} + }, { + "id": 6, + "lorem": { + "value": "Scope Row" + }, + "ipsum": {"value": "Cell Content"}, + "sit-amet": {"value": "Cell Content"} + }, { + "id": 7, + "lorem": { + "value": "Scope Row" + }, + "ipsum": {"value": "Cell Content"}, + "dolor": {"value": "Cell Content"}, + "sit-amet": {"value": "Cell Content"} + } +] diff --git a/showcase/tests/acceptance/components/hds/advanced-table.js b/showcase/tests/acceptance/components/hds/advanced-table.js new file mode 100644 index 0000000000..9457c01c80 --- /dev/null +++ b/showcase/tests/acceptance/components/hds/advanced-table.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { visit } from '@ember/test-helpers'; +import { setupApplicationTest } from 'dummy/tests/helpers'; +import { a11yAudit } from 'ember-a11y-testing/test-support'; + +module('Acceptance | components/advanced-table', function (hooks) { + setupApplicationTest(hooks); + + test('Components/advanced-table page passes automated a11y checks', async function (assert) { + await visit('/components/advanced-table'); + + await a11yAudit(); + + assert.ok(true, 'a11y automation audit passed'); + }); +}); diff --git a/showcase/tests/acceptance/percy-test.js b/showcase/tests/acceptance/percy-test.js index bb3ae6a40e..777d2cab2d 100644 --- a/showcase/tests/acceptance/percy-test.js +++ b/showcase/tests/acceptance/percy-test.js @@ -182,6 +182,10 @@ module('Acceptance | Percy test', function (hooks) { await visit('/utilities/popover-primitive'); await percySnapshot('PopoverPrimitive'); + // MOVE THIS BLOCK IN THE RIGHT POSITION + await visit('/components/advanced-table'); + await percySnapshot('AdvancedTable'); + // DO NOT REMOVE – PERCY SNAPSHOTS END assert.ok(true); diff --git a/showcase/tests/integration/components/hds/advanced-table/index-test.js b/showcase/tests/integration/components/hds/advanced-table/index-test.js new file mode 100644 index 0000000000..de8875d0ee --- /dev/null +++ b/showcase/tests/integration/components/hds/advanced-table/index-test.js @@ -0,0 +1,18 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; + +module('Integration | Component | hds/advanced-table/index', function (hooks) { + setupRenderingTest(hooks); + + test('it should render the component with a CSS class that matches the component name', async function (assert) { + await render(hbs``); + assert.dom('#test-advanced-table').hasClass('hds-advanced-table'); + }); +});