diff --git a/.changeset/wet-files-joke.md b/.changeset/wet-files-joke.md new file mode 100644 index 00000000000..1aeb5e22a37 --- /dev/null +++ b/.changeset/wet-files-joke.md @@ -0,0 +1,11 @@ +--- +"@hashicorp/design-system-components": minor +--- + + +`AdvancedTable` - Added support for column reordering. +- Added `@hasReorderableColumns` argument. When set to `true`, enables column reordering. +- Added optional `@columnOrder` argument for setting the initial order of columns by their keys. +- Added optional `@onColumnReorder` argument which accepts a callback function that is called when reordering is completed. +- Added optional `@reorderedMessageText` which overrides the default message text that is rendered in the table caption when a column is reordered. + diff --git a/packages/components/package.json b/packages/components/package.json index 7656ff12b40..a99475c364a 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -154,11 +154,14 @@ "./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-context-menu.js": "./dist/_app_/components/hds/advanced-table/th-context-menu.js", + "./components/hds/advanced-table/th-reorder-drop-target.js": "./dist/_app_/components/hds/advanced-table/th-reorder-drop-target.js", + "./components/hds/advanced-table/th-reorder-handle.js": "./dist/_app_/components/hds/advanced-table/th-reorder-handle.js", "./components/hds/advanced-table/th-resize-handle.js": "./dist/_app_/components/hds/advanced-table/th-resize-handle.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.js": "./dist/_app_/components/hds/advanced-table/tr.js", + "./components/hds/advanced-table/utils.js": "./dist/_app_/components/hds/advanced-table/utils.js", "./components/hds/alert/description.js": "./dist/_app_/components/hds/alert/description.js", "./components/hds/alert.js": "./dist/_app_/components/hds/alert.js", "./components/hds/alert/title.js": "./dist/_app_/components/hds/alert/title.js", diff --git a/packages/components/src/components/hds/advanced-table/index.hbs b/packages/components/src/components/hds/advanced-table/index.hbs index 03be21112e2..8f8bae33e58 100644 --- a/packages/components/src/components/hds/advanced-table/index.hbs +++ b/packages/components/src/components/hds/advanced-table/index.hbs @@ -2,16 +2,19 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: MPL-2.0 }} +
{{! Caption }}
{{@caption}} {{this.sortedMessageText}} + {{this.reorderedMessageText}}
{{! Grid }} @@ -28,7 +31,12 @@ {{this._setUpScrollWrapper}} > {{! Header }} -
+
- {{#each this._tableModel.columns as |column|}} + {{#each this._tableModel.orderedColumns as |column|}} {{#if column.isSortable}} {{column.label}} @@ -64,7 +77,9 @@ @align={{column.align}} @column={{column}} @hasExpandAllButton={{this._tableModel.hasRowsWithChildren}} + @hasReorderableColumns={{@hasReorderableColumns}} @hasResizableColumns={{@hasResizableColumns}} + @hasSelectableRows={{this.isSelectable}} @isExpanded={{this._tableModel.expandState}} @isExpandable={{column.isExpandable}} @isStickyColumn={{this._isStickyColumn column}} @@ -75,6 +90,9 @@ @onClickToggle={{this._tableModel.toggleAll}} @onColumnResize={{@onColumnResize}} @onPinFirstColumn={{this._onPinFirstColumn}} + @onReorderDragEnd={{fn (mut this._tableModel.reorderDraggedColumn) null}} + @onReorderDragStart={{fn (mut this._tableModel.reorderDraggedColumn)}} + @onReorderDrop={{this._tableModel.moveColumnToDropTarget}} {{this._registerThElement column}} > {{column.label}} @@ -82,6 +100,10 @@ {{/if}} {{/each}} + + {{#if this.showScrollIndicatorTop}} +
+ {{/if}}
{{! Body }} @@ -107,6 +129,7 @@ isParentRow=T.isExpandable depth=T.depth displayRow=T.shouldDisplayChildRows + data=T.data ) Th=(component "hds/advanced-table/th" @@ -140,6 +163,7 @@ selectionAriaLabelSuffix=@selectionAriaLabelSuffix hasStickyColumn=this.hasStickyFirstColumn isStickyColumnPinned=this.isStickyColumnPinned + data=record ) Th=(component "hds/advanced-table/th" @@ -172,13 +196,6 @@ /> {{/if}} - {{#if this.showScrollIndicatorTop}} -
- {{/if}} - {{#if this.showScrollIndicatorBottom}}
void; onSelectionChange?: ( selection: HdsAdvancedTableOnSelectionChangeSignature @@ -192,6 +197,8 @@ export interface HdsAdvancedTableSignature { } export default class HdsAdvancedTable extends Component { + @service hdsIntl!: HdsIntlService; + @tracked private _selectAllCheckbox?: HdsFormCheckboxBaseSignature['Element'] = undefined; @@ -209,6 +216,7 @@ export default class HdsAdvancedTable extends Component column.isSortable); - const sortableColumnLabels = sortableColumns.map( - (column) => column.label - ); - - assert( - `Cannot have sortable columns if there are nested rows. Sortable columns are ${sortableColumnLabels.toString()}`, - sortableColumns.length === 0 - ); - - assert( - 'Cannot have a sticky first column if there are nested rows.', - !hasStickyFirstColumn - ); - - assert( - `Cannot have resizable columns if there are nested rows.`, - !hasResizableColumns - ); - } + this._runAssertions(); if (hasStickyFirstColumn) { this.hasPinnedFirstColumn = true; @@ -381,13 +372,13 @@ export default class HdsAdvancedTable extends Component { - this._tableHeight = element.clientHeight; + this._tableHeight = element.offsetHeight; const hasFirstColumnPxWidth = this._tableModel.columns[0]?.pxWidth !== undefined; @@ -469,7 +460,6 @@ export default class HdsAdvancedTable extends Component column.isSortable); + const sortableColumnLabels = sortableColumns.map( + (column) => column.label + ); + + assert( + 'Cannot have reorderable columns if there are nested rows.', + !hasReorderableColumns + ); + + assert( + `Cannot have sortable columns if there are nested rows. Sortable columns are ${sortableColumnLabels.toString()}`, + sortableColumns.length === 0 + ); + + assert( + 'Cannot have a sticky first column if there are nested rows.', + hasStickyFirstColumn === undefined + ); + + assert( + `Cannot have resizable columns if there are nested rows.`, + !hasResizableColumns + ); + } + + if (hasReorderableColumns) { + assert( + 'Cannot have both reorderable columns and a sticky first column.', + hasStickyFirstColumn === undefined + ); + } + } + private _setUpThead = modifier((element: HTMLDivElement) => { this._theadElement = element; }); + private _onColumnReorder: HdsAdvancedTableColumnReorderCallback = ({ + column, + newOrder, + insertedAt, + }) => { + const { reorderedMessageText } = this.args; + + if (reorderedMessageText !== undefined) { + this.reorderedMessageText = reorderedMessageText; + } else { + const newPosition = insertedAt + 1; + const translatedReorderedMessageText = this.hdsIntl.t( + 'hds.advanced-table.reordered-message', + { + default: `Moved ${column.label} column to position ${newPosition}`, + columnLabel: column.label, + newPosition, + } + ); + + this.reorderedMessageText = translatedReorderedMessageText; + } + + this.args.onColumnReorder?.({ + column, + newOrder, + insertedAt, + }); + }; + onSelectionChangeCallback( checkbox?: HdsFormCheckboxBaseSignature['Element'], selectionKey?: string @@ -552,7 +615,21 @@ export default class HdsAdvancedTable extends Component number = undefined; + + // elements @tracked thElement?: HTMLDivElement = undefined; + @tracked + reorderHandleElement?: HdsAdvancedTableThReorderHandleSignature['Element'] = + undefined; + @tracked + thContextMenuToggleElement?: HdsDropdownToggleButtonSignature['Element'] = + undefined; // width properties @tracked transientWidth?: `${number}px` = undefined; // used for transient width changes @@ -47,10 +60,16 @@ export default class HdsAdvancedTableColumn { @tracked originalWidth: string = this.width; // used to restore the width when resetting @tracked widthDebts: Record = {}; // used to track width changes imposed by other columns - @tracked sortingFunction?: (a: unknown, b: unknown) => number = undefined; - table: HdsAdvancedTableModel; + get cells(): HdsAdvancedTableCell[] { + return this.table.flattenedVisibleRows.map((row) => { + const cell = row.cells.find((cell) => cell.columnKey === this.key); + + return cell!; + }); + } + get appliedWidth(): string { return this.transientWidth ?? this.width; } @@ -93,13 +112,13 @@ export default class HdsAdvancedTableColumn { } get index(): number { - const { columns } = this.table; + const { orderedColumns } = this.table; - if (columns.length === 0) { + if (orderedColumns.length === 0) { return -1; } - return columns.findIndex((column) => column.key === this.key); + return orderedColumns.findIndex((column) => column.key === this.key); } get isFirst(): boolean { @@ -115,15 +134,15 @@ export default class HdsAdvancedTableColumn { next?: HdsAdvancedTableColumn; } { const { index, table } = this; - const { columns } = table; + const { orderedColumns } = table; if (index === -1) { return {}; } return { - previous: this.isFirst ? undefined : columns[index - 1], - next: this.isLast ? undefined : columns[index + 1], + previous: this.isFirst ? undefined : orderedColumns[index - 1], + next: this.isLast ? undefined : orderedColumns[index + 1], }; } @@ -260,6 +279,22 @@ export default class HdsAdvancedTableColumn { this.maxWidth = maxWidth ?? DEFAULT_MAX_WIDTH; } + @action focusReorderHandle(): void { + if (this.thElement === undefined) { + return; + } + + // focus the th element first (parent) to ensure the handle is visible + this.thElement.focus({ preventScroll: true }); + + if (this.reorderHandleElement === undefined) { + return; + } + + // then focus the reorder handle element + this.reorderHandleElement.focus(); + } + // Sets the column width in pixels, ensuring it respects the min and max width constraints. setPxTransientWidth(newPxWidth: number): void { const pxMinWidth = this.pxMinWidth ?? 1; @@ -279,6 +314,6 @@ export default class HdsAdvancedTableColumn { restoreWidth(): void { this.settleWidthDebts(); - this.width = this.originalWidth ?? this.width; + this.width = this.originalWidth; } } diff --git a/packages/components/src/components/hds/advanced-table/models/row.ts b/packages/components/src/components/hds/advanced-table/models/row.ts index 8fd16051d6b..7a56621526d 100644 --- a/packages/components/src/components/hds/advanced-table/models/row.ts +++ b/packages/components/src/components/hds/advanced-table/models/row.ts @@ -7,13 +7,14 @@ import { tracked } from '@glimmer/tracking'; import { action } from '@ember/object'; import { guidFor } from '@ember/object/internals'; -import type { HdsAdvancedTableColumn } from '../types'; +import type { HdsAdvancedTableColumn, HdsAdvancedTableCell } from '../types'; interface HdsAdvancedTableRowArgs { [key: string]: unknown; columns: HdsAdvancedTableColumn[]; id?: string; childrenKey?: string; + columnOrder?: string[]; } export default class HdsAdvancedTableRow { @@ -23,6 +24,8 @@ export default class HdsAdvancedTableRow { [key: string]: unknown; @tracked isOpen: boolean = false; + @tracked cells: HdsAdvancedTableCell[] = []; + @tracked columnOrder: string[] = []; children: HdsAdvancedTableRow[] = []; childrenKey: string; @@ -35,7 +38,35 @@ export default class HdsAdvancedTableRow { return this.isOpen && this.hasChildren; } + get orderedCells(): HdsAdvancedTableCell[] { + return this.columnOrder.map((key) => { + const cell = this.cells.find((cell) => cell.columnKey === key); + + if (cell === undefined) { + throw new Error( + `Cell in the column with key ${key} not found for the row.` + ); + } + + return cell; + }); + } + constructor(args: HdsAdvancedTableRowArgs) { + const { columns } = args; + + this.cells = columns.map((column) => { + const cell = args[column.key ?? '']; + + return { + columnKey: column.key ?? '', + content: cell, + }; + }); + + this.columnOrder = + args.columnOrder ?? this.cells.map((cell) => cell.columnKey); + // set row data Object.assign(this, args); @@ -45,8 +76,12 @@ export default class HdsAdvancedTableRow { if (Array.isArray(childModels)) { this.children = childModels.map( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - (child) => new HdsAdvancedTableRow(child) + (child) => + new HdsAdvancedTableRow({ + ...(child as HdsAdvancedTableRowArgs), + columns: args.columns, + childrenKey: this.childrenKey, + }) ); } } diff --git a/packages/components/src/components/hds/advanced-table/models/table.ts b/packages/components/src/components/hds/advanced-table/models/table.ts index 7141f7a3938..3bb0ea560f2 100644 --- a/packages/components/src/components/hds/advanced-table/models/table.ts +++ b/packages/components/src/components/hds/advanced-table/models/table.ts @@ -6,12 +6,19 @@ import HdsAdvancedTableRow from './row.ts'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { isEmpty } from '@ember/utils'; import HdsAdvancedTableColumn from './column.ts'; -import { HdsAdvancedTableThSortOrderValues } from '../types.ts'; +import { + HdsAdvancedTableColumnReorderSideValues, + HdsAdvancedTableThSortOrderValues, +} from '../types.ts'; import type { HdsAdvancedTableSignature } from '../index.ts'; import type { HdsAdvancedTableExpandState, + HdsAdvancedTableCell, + HdsAdvancedTableColumnReorderCallback, + HdsAdvancedTableColumnReorderSide, HdsAdvancedTableSortingFunction, } from '../types'; @@ -19,11 +26,14 @@ type HdsAdvancedTableTableArgs = Pick< HdsAdvancedTableSignature['Args'], | 'model' | 'columns' + | 'columnOrder' | 'childrenKey' | 'hasResizableColumns' | 'sortBy' | 'sortOrder' | 'onSort' + | 'onColumnReorder' + | 'onColumnResize' >; function getVisibleRows(rows: HdsAdvancedTableRow[]): HdsAdvancedTableRow[] { @@ -39,11 +49,17 @@ function getVisibleRows(rows: HdsAdvancedTableRow[]): HdsAdvancedTableRow[] { } function getChildrenCount(rows: HdsAdvancedTableRow[]): number { - return rows.reduce((acc, row) => acc + 1 + getChildrenCount(row.children), 0); + return rows.reduce( + (acc, row) => acc + 1 + getChildrenCount(row.children ?? []), + 0 + ); } export default class HdsAdvancedTableTableModel { @tracked columns: HdsAdvancedTableColumn[] = []; + @tracked columnOrder: string[] = []; + @tracked reorderDraggedColumn: HdsAdvancedTableColumn | null = null; + @tracked reorderHoveredColumn: HdsAdvancedTableColumn | null = null; @tracked rows: HdsAdvancedTableRow[] = []; @tracked sortBy: HdsAdvancedTableTableArgs['sortBy'] = undefined; @tracked sortOrder: HdsAdvancedTableTableArgs['sortOrder'] = @@ -52,16 +68,19 @@ export default class HdsAdvancedTableTableModel { childrenKey?: HdsAdvancedTableTableArgs['childrenKey']; hasResizableColumns?: HdsAdvancedTableTableArgs['hasResizableColumns']; + onColumnReorder?: HdsAdvancedTableColumnReorderCallback; onSort?: HdsAdvancedTableSignature['Args']['onSort']; constructor(args: HdsAdvancedTableTableArgs) { const { model, columns, + columnOrder, childrenKey, hasResizableColumns, sortBy, sortOrder, + onColumnReorder, onSort, } = args; @@ -70,6 +89,43 @@ export default class HdsAdvancedTableTableModel { this.onSort = onSort; this.setupData({ model, columns, sortBy, sortOrder }); + + // set initial column order + this.columnOrder = isEmpty(columnOrder) + ? this.columns.map((column) => column.key) + : columnOrder!; // ensured non-empty + + this.onColumnReorder = onColumnReorder; + } + + get hasColumnBeingDragged(): boolean { + return this.reorderDraggedColumn !== null; + } + + get reorderDraggedColumnCells(): HdsAdvancedTableCell[] { + if (this.reorderDraggedColumn === null) { + return []; + } + + const { key } = this.reorderDraggedColumn; + + return this.flattenedVisibleRows.map((row) => { + const cell = row.cells.find((cell) => cell.columnKey === key); + + return cell!; + }); + } + + get orderedColumns(): HdsAdvancedTableColumn[] { + return this.columnOrder.map((key) => { + const column = this.columns.find((column) => column.key === key); + + if (!column) { + throw new Error(`Column with key ${key} not found`); + } + + return column; + }); } get sortCriteria(): string | HdsAdvancedTableSortingFunction { @@ -246,4 +302,130 @@ export default class HdsAdvancedTableTableModel { this.openAll(); } } + + @action + stepColumn(column: HdsAdvancedTableColumn, step: number): void { + const { table } = column; + const oldIndex = table.orderedColumns.indexOf(column); + const newIndex = oldIndex + step; + + // Check if the new position is within the array bounds. + if (newIndex < 0 || newIndex >= table.orderedColumns.length) { + return; + } + + const targetColumn = table.orderedColumns[newIndex]; + + if (targetColumn === undefined) { + return; + } + + // Determine the side based on the step direction. + const side: HdsAdvancedTableColumnReorderSide = + step > 0 + ? HdsAdvancedTableColumnReorderSideValues.Right + : HdsAdvancedTableColumnReorderSideValues.Left; + + table.moveColumnToTarget(column, targetColumn, side); + } + + @action + moveColumnToTerminalPosition( + column: HdsAdvancedTableColumn, + position: 'start' | 'end' + ): void { + const firstColumn = this.orderedColumns.find((column) => column.isFirst); + + const { + targetColumn, + side, + }: { + targetColumn?: HdsAdvancedTableColumn; + side: HdsAdvancedTableColumnReorderSide; + } = + position === 'start' + ? { + targetColumn: firstColumn, + side: HdsAdvancedTableColumnReorderSideValues.Left, + } + : { + targetColumn: this.orderedColumns[this.orderedColumns.length - 1], + side: HdsAdvancedTableColumnReorderSideValues.Right, + }; + + if (targetColumn === undefined) { + return; + } + + // Move the column to the target position + this.moveColumnToTarget(column, targetColumn, side); + } + + @action + moveColumnToTarget( + sourceColumn: HdsAdvancedTableColumn, + targetColumn: HdsAdvancedTableColumn, + side: HdsAdvancedTableColumnReorderSide + ): void { + const oldIndex = this.orderedColumns.indexOf(sourceColumn); + const newIndex = this.orderedColumns.indexOf(targetColumn); + + if (oldIndex !== -1 && newIndex !== -1) { + const updated = [...this.columnOrder]; + + updated.splice(oldIndex, 1); // Remove from old position + + // Calculate the insertion index based on the side + // If dropping to the right of the target, insert after the target + // If dropping to the left of the target, insert before the target + // Adjust for the shift in indices caused by removing the source column + const adjustedIndex = + side === HdsAdvancedTableColumnReorderSideValues.Right + ? newIndex > oldIndex + ? newIndex + : newIndex + 1 + : newIndex > oldIndex + ? newIndex - 1 + : newIndex; + + updated.splice(adjustedIndex, 0, sourceColumn.key); // Insert at new position + + this.columnOrder = updated; + + for (const row of this.rows) { + row.columnOrder = updated; + } + + // we need to wait until the reposition has finished + requestAnimationFrame(() => { + sourceColumn.thElement?.scrollIntoView({ + behavior: 'smooth', + block: 'nearest', + inline: 'center', + }); + + sourceColumn.isBeingDragged = false; + + this.onColumnReorder?.({ + column: sourceColumn, + newOrder: updated, + insertedAt: updated.indexOf(sourceColumn.key), + }); + }); + } + } + + @action + moveColumnToDropTarget( + targetColumn: HdsAdvancedTableColumn, + side: HdsAdvancedTableColumnReorderSide + ) { + const sourceColumn = this.reorderDraggedColumn; + + if (sourceColumn == null || sourceColumn === targetColumn) { + return; + } + + this.moveColumnToTarget(sourceColumn, targetColumn, side); + } } diff --git a/packages/components/src/components/hds/advanced-table/th-context-menu.hbs b/packages/components/src/components/hds/advanced-table/th-context-menu.hbs index 6f9746e1b64..03841bf0b42 100644 --- a/packages/components/src/components/hds/advanced-table/th-context-menu.hbs +++ b/packages/components/src/components/hds/advanced-table/th-context-menu.hbs @@ -2,21 +2,28 @@ Copyright (c) HashiCorp, Inc. SPDX-License-Identifier: MPL-2.0 }} - - +{{#if (gt this._options.length 0)}} + + - {{#each this._options as |option|}} - - {{option.label}} - - {{/each}} - \ No newline at end of file + {{#each this._options as |option|}} + {{#if (eq option.key "separator")}} + + {{else if option.action}} + + {{option.label}} + + {{/if}} + {{/each}} + +{{/if}} \ No newline at end of file diff --git a/packages/components/src/components/hds/advanced-table/th-context-menu.ts b/packages/components/src/components/hds/advanced-table/th-context-menu.ts index 7d1da4dfa69..629753f9a96 100644 --- a/packages/components/src/components/hds/advanced-table/th-context-menu.ts +++ b/packages/components/src/components/hds/advanced-table/th-context-menu.ts @@ -4,32 +4,34 @@ */ import Component from '@glimmer/component'; -import { action } from '@ember/object'; import { service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { scheduleOnce } from '@ember/runloop'; +import { modifier } from 'ember-modifier'; import type HdsAdvancedTableColumn from './models/column.ts'; import type { HdsDropdownSignature } from '../dropdown/index.ts'; import type { HdsDropdownToggleIconSignature } from '../dropdown/toggle/icon.ts'; import type { HdsAdvancedTableSignature } from './index.ts'; -import { tracked } from '@glimmer/tracking'; +import type { HdsAdvancedTableThReorderHandleSignature } from './th-reorder-handle.ts'; import type { HdsAdvancedTableThResizeHandleSignature } from './th-resize-handle.ts'; +import type { HdsDropdownToggleButtonSignature } from '../dropdown/toggle/button.ts'; import type HdsIntlService from '../../../services/hds-intl.ts'; interface HdsAdvancedTableThContextMenuOption { key: string; - label: string; - icon: HdsDropdownToggleIconSignature['Args']['icon']; - action: ( - column: HdsAdvancedTableColumn, - dropdownCloseCallback: () => void - ) => void; + icon?: HdsDropdownToggleIconSignature['Args']['icon']; + label?: string; + action?: (dropdownCloseCallback: () => void) => void; } export interface HdsAdvancedTableThContextMenuSignature { Args: { column: HdsAdvancedTableColumn; - isStickyColumn?: boolean; hasResizableColumns?: boolean; + hasReorderableColumns?: boolean; + isStickyColumn?: boolean; + reorderHandleElement?: HdsAdvancedTableThReorderHandleSignature['Element']; resizeHandleElement?: HdsAdvancedTableThResizeHandleSignature['Element']; onColumnResize?: HdsAdvancedTableSignature['Args']['onColumnResize']; onPinFirstColumn?: () => void; @@ -42,77 +44,168 @@ export default class HdsAdvancedTableThContextMenu extends Component this._moveColumn(), + }, + ]; + + if (!column.isFirst) { + const translatedMoveColumnToStartLabel = this.hdsIntl.t( + 'hds.advanced-table.th-context-menu.move-column-to-start', + { default: 'Move column to start' } ); - options = [ - ...options, + reorderOptions = [ + ...reorderOptions, { - key: 'reset-column-width', - label: translatedResetWidthLabel, - icon: 'rotate-ccw', - action: this.resetColumnWidth.bind(this), + key: 'move-column-to-start', + label: translatedMoveColumnToStartLabel, + icon: 'start', + action: (close) => this._moveColumnToPosition('start', close), }, ]; } - if (isStickyColumn !== undefined && column.isFirst) { - const translatedPinLabel = this.hdsIntl.t( - 'hds.advanced-table.th-context-menu.pin', - { default: 'Pin column' } + if (!column.isLast) { + const translatedMoveColumnToEndLabel = this.hdsIntl.t( + 'hds.advanced-table.th-context-menu.move-column-to-end', + { default: 'Move column to end' } ); - const translatedUnpinLabel = this.hdsIntl.t( - 'hds.advanced-table.th-context-menu.unpin', - { default: 'Unpin column' } - ); - options = [ - ...options, + reorderOptions = [ + ...reorderOptions, { - key: 'pin-first-column', - label: isStickyColumn ? translatedUnpinLabel : translatedPinLabel, - icon: isStickyColumn ? 'pin-off' : 'pin', - action: this.pinFirstColumn.bind(this), + key: 'move-column-to-end', + label: translatedMoveColumnToEndLabel, + icon: 'end', + action: (close) => this._moveColumnToPosition('end', close), }, ]; } - return options; + return reorderOptions; + } + + get _stickyColumnOptions(): HdsAdvancedTableThContextMenuOption[] { + const { isStickyColumn } = this.args; + + const translatedPinLabel = this.hdsIntl.t( + 'hds.advanced-table.th-context-menu.pin', + { default: 'Pin column' } + ); + const translatedUnpinLabel = this.hdsIntl.t( + 'hds.advanced-table.th-context-menu.unpin', + { default: 'Unpin column' } + ); + + return [ + { + key: 'pin-first-column', + label: isStickyColumn ? translatedUnpinLabel : translatedPinLabel, + icon: isStickyColumn ? 'pin-off' : 'pin', + action: this._pinFirstColumn.bind(this), + }, + ]; + } + + get _options(): HdsAdvancedTableThContextMenuOption[] { + const { + column, + hasReorderableColumns, + hasResizableColumns, + isStickyColumn, + } = this.args; + + let allGroups: HdsAdvancedTableThContextMenuOption[][] = []; + + if (hasResizableColumns) { + allGroups = [...allGroups, this._resizeOptions]; + } + + if (hasReorderableColumns && isStickyColumn === undefined) { + allGroups = [...allGroups, this._reorderOptions]; + } + + // we don't allow pinning/unpinning of the sticky column if columns are reorderable + if ( + isStickyColumn !== undefined && + column.isFirst && + !hasReorderableColumns + ) { + allGroups = [...allGroups, this._stickyColumnOptions]; + } + + return allGroups.reduce( + (options, group, index) => { + // Add a separator before each group except the first + if (index > 0) { + return [...options, { key: 'separator' }, ...group]; + } + return [...options, ...group]; + }, + [] + ); } - @action - resizeColumn() { + private _registerDropdownToggleElement = modifier( + (element: HdsDropdownToggleButtonSignature['Element']) => { + this.args.column.thContextMenuToggleElement = element; + } + ); + + private _resizeColumn() { this.args.resizeHandleElement?.focus(); } - @action - resetColumnWidth( - column: HdsAdvancedTableColumn, - dropdownCloseCallback: () => void - ): void { - const { onColumnResize } = this.args; + private _resetColumnWidth(dropdownCloseCallback: () => void): void { + const { column, onColumnResize } = this.args; column.restoreWidth(); @@ -123,11 +216,31 @@ export default class HdsAdvancedTableThContextMenu extends Component void + private _moveColumn() { + // eslint-disable-next-line ember/no-runloop + scheduleOnce( + 'afterRender', + this, + this.args.column.focusReorderHandle.bind(this) + ); + } + + private _moveColumnToPosition( + position: 'start' | 'end', + dropdownCloseCallback?: () => void ): void { + const { column } = this.args; + + column.table.moveColumnToTerminalPosition(column, position); + + requestAnimationFrame(() => { + dropdownCloseCallback?.(); + + column.thContextMenuToggleElement?.focus(); + }); + } + + private _pinFirstColumn(dropdownCloseCallback: () => void): void { const { onPinFirstColumn } = this.args; if (typeof onPinFirstColumn === 'function') { diff --git a/packages/components/src/components/hds/advanced-table/th-reorder-drop-target.hbs b/packages/components/src/components/hds/advanced-table/th-reorder-drop-target.hbs new file mode 100644 index 00000000000..82879861538 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/th-reorder-drop-target.hbs @@ -0,0 +1,9 @@ + \ 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 index 5858e6bcc7e..13ce48d14f6 100644 --- a/packages/components/src/components/hds/advanced-table/th-sort.ts +++ b/packages/components/src/components/hds/advanced-table/th-sort.ts @@ -21,12 +21,15 @@ import type { HdsAdvancedTableHorizontalAlignment, HdsAdvancedTableThSortOrder, HdsAdvancedTableThSortOrderLabels, + HdsAdvancedTableColumnReorderSide, } from './types.ts'; +import type { HdsAdvancedTableThReorderHandleSignature } from './th-reorder-handle.ts'; import type { HdsAdvancedTableThButtonSortSignature } from './th-button-sort.ts'; import { onFocusTrapDeactivate } from '../../../modifiers/hds-advanced-table-cell/dom-management.ts'; import type { HdsAdvancedTableThSignature } from './th.ts'; import type { HdsAdvancedTableSignature } from './index.ts'; import type { HdsAdvancedTableThResizeHandleSignature } from './th-resize-handle.ts'; +import type HdsAdvancedTableColumn from './models/column.ts'; export const ALIGNMENTS: string[] = Object.values( HdsAdvancedTableHorizontalAlignmentValues @@ -37,7 +40,9 @@ export interface HdsAdvancedTableThSortSignature { Args: { column?: HdsAdvancedTableThSignature['Args']['column']; align?: HdsAdvancedTableHorizontalAlignment; + hasReorderableColumns?: HdsAdvancedTableSignature['Args']['hasReorderableColumns']; hasResizableColumns?: HdsAdvancedTableSignature['Args']['hasResizableColumns']; + hasSelectableRows?: HdsAdvancedTableSignature['Args']['isSelectable']; onClickSort?: HdsAdvancedTableThButtonSortSignature['Args']['onClick']; sortOrder?: HdsAdvancedTableThSortOrder; tooltip?: string; @@ -48,6 +53,12 @@ export interface HdsAdvancedTableThSortSignature { isStickyColumnPinned?: boolean; onColumnResize?: HdsAdvancedTableSignature['Args']['onColumnResize']; onPinFirstColumn?: () => void; + onReorderDragEnd?: () => void; + onReorderDragStart?: (column: HdsAdvancedTableColumn) => void; + onReorderDrop?: ( + column: HdsAdvancedTableColumn, + side: HdsAdvancedTableColumnReorderSide + ) => void; }; Blocks: { default?: []; @@ -61,6 +72,8 @@ export default class HdsAdvancedTableThSort extends Component { + this._reorderHandleElement = element; + } + ); + private _registerResizeHandleElement = modifier( (element: HdsAdvancedTableThResizeHandleSignature['Element']) => { this._resizeHandleElement = element; diff --git a/packages/components/src/components/hds/advanced-table/th.hbs b/packages/components/src/components/hds/advanced-table/th.hbs index 83f3599e885..69a283d99b0 100644 --- a/packages/components/src/components/hds/advanced-table/th.hbs +++ b/packages/components/src/components/hds/advanced-table/th.hbs @@ -66,21 +66,30 @@ {{/if}} {{#if @column}} - {{#if this.showContextMenu}} - + + {{#if (and @hasReorderableColumns (not @isStickyColumn))}} + {{/if}} - {{#if (and @hasResizableColumns (not @column.isLast))}} + {{#if (and @hasResizableColumns (not @column.isLast) (not @column.table.hasColumnBeingDragged))}} + + {{#if @column}} + {{#if (and @column.table.hasColumnBeingDragged (not @isStickyColumn))}} + + {{/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 index eebd3d5236d..47293a8df88 100644 --- a/packages/components/src/components/hds/advanced-table/th.ts +++ b/packages/components/src/components/hds/advanced-table/th.ts @@ -19,9 +19,11 @@ import type { HdsAdvancedTableHorizontalAlignment, HdsAdvancedTableScope, HdsAdvancedTableExpandState, + HdsAdvancedTableColumnReorderSide, } from './types.ts'; -import type { HdsAdvancedTableSignature } from './index.ts'; +import type { HdsAdvancedTableThReorderHandleSignature } from './th-reorder-handle.ts'; import type { HdsAdvancedTableThResizeHandleSignature } from './th-resize-handle.ts'; +import type { HdsAdvancedTableSignature } from './index.ts'; export const ALIGNMENTS: string[] = Object.values( HdsAdvancedTableHorizontalAlignmentValues @@ -35,7 +37,9 @@ export interface HdsAdvancedTableThSignature { colspan?: number; depth?: number; hasExpandAllButton?: boolean; - hasResizableColumns?: boolean; + hasReorderableColumns?: HdsAdvancedTableSignature['Args']['hasReorderableColumns']; + hasResizableColumns?: HdsAdvancedTableSignature['Args']['hasResizableColumns']; + hasSelectableRows?: HdsAdvancedTableSignature['Args']['isSelectable']; isExpanded?: HdsAdvancedTableExpandState; isExpandable?: boolean; isStickyColumn?: boolean; @@ -51,6 +55,12 @@ export interface HdsAdvancedTableThSignature { onClickToggle?: () => void; onColumnResize?: HdsAdvancedTableSignature['Args']['onColumnResize']; onPinFirstColumn?: () => void; + onReorderDragEnd?: () => void; + onReorderDragStart?: (column: HdsAdvancedTableColumn) => void; + onReorderDrop?: ( + column: HdsAdvancedTableColumn, + side: HdsAdvancedTableColumnReorderSide + ) => void; willDestroyExpandButton?: (button: HTMLButtonElement) => void; }; Blocks: { @@ -65,6 +75,8 @@ export default class HdsAdvancedTableTh extends Component { + this._reorderHandleElement = element; + } + ); + private _registerResizeHandleElement = modifier( (element: HdsAdvancedTableThResizeHandleSignature['Element']) => { this._resizeHandleElement = element; diff --git a/packages/components/src/components/hds/advanced-table/tr.hbs b/packages/components/src/components/hds/advanced-table/tr.hbs index 9f00ba1932d..20746db86cd 100644 --- a/packages/components/src/components/hds/advanced-table/tr.hbs +++ b/packages/components/src/components/hds/advanced-table/tr.hbs @@ -21,5 +21,5 @@ /> {{/if}} - {{yield}} + {{yield (hash orderedCells=@data.orderedCells)}}
\ 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 index ae2433b3296..5c87097e3eb 100644 --- a/packages/components/src/components/hds/advanced-table/tr.ts +++ b/packages/components/src/components/hds/advanced-table/tr.ts @@ -14,6 +14,7 @@ import type { import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base.ts'; import type { HdsAdvancedTableSignature } from './index.ts'; import type { HdsAdvancedTableThSelectableSignature } from './th-selectable.ts'; +import type HdsAdvancedTableRow from './models/row.ts'; export interface BaseHdsAdvancedTableTrSignature { Args: { @@ -22,6 +23,7 @@ export interface BaseHdsAdvancedTableTrSignature { isSelectable?: boolean; isSelected?: boolean; isParentRow?: boolean; + data?: HdsAdvancedTableRow; selectionAriaLabelSuffix?: string; selectionKey?: string; selectionScope?: HdsAdvancedTableScope; @@ -42,7 +44,11 @@ export interface BaseHdsAdvancedTableTrSignature { isStickyColumnPinned?: boolean; }; Blocks: { - default?: []; + default?: [ + { + orderedCells?: HdsAdvancedTableRow['cells']; + }, + ]; }; Element: HTMLDivElement; } diff --git a/packages/components/src/components/hds/advanced-table/types.ts b/packages/components/src/components/hds/advanced-table/types.ts index 4a66a1a7787..9eee584519b 100644 --- a/packages/components/src/components/hds/advanced-table/types.ts +++ b/packages/components/src/components/hds/advanced-table/types.ts @@ -113,3 +113,31 @@ export interface HdsAdvancedTableOnSelectionChangeSignature { } export type HdsAdvancedTableModel = Array>; + +export type HdsAdvancedTableColumnResizeCallback = ( + columnKey: string, + newWidth?: string +) => void; + +export type HdsAdvancedTableColumnReorderCallback = ({ + column, + newOrder, + insertedAt, +}: { + column: HdsAdvancedTableColumn; + newOrder: string[]; + insertedAt: number; +}) => void; + +export interface HdsAdvancedTableCell { + columnKey: string; + content: unknown; +} + +export enum HdsAdvancedTableColumnReorderSideValues { + Left = 'left', + Right = 'right', +} + +export type HdsAdvancedTableColumnReorderSide = + `${HdsAdvancedTableColumnReorderSideValues}`; diff --git a/packages/components/src/components/hds/advanced-table/utils.ts b/packages/components/src/components/hds/advanced-table/utils.ts new file mode 100644 index 00000000000..4bb8046e5e0 --- /dev/null +++ b/packages/components/src/components/hds/advanced-table/utils.ts @@ -0,0 +1,16 @@ +import { buildWaiter } from '@ember/test-waiters'; + +const waiter = buildWaiter('raf-waiter'); + +// a utility that wraps requestAnimationFrame and integrates with Ember's test waiters +export function requestAnimationFrameWaiter(callback: () => void) { + const token = waiter.beginAsync(); + + return requestAnimationFrame(() => { + try { + callback(); + } finally { + waiter.endAsync(token); + } + }); +} diff --git a/packages/components/src/styles/components/advanced-table.scss b/packages/components/src/styles/components/advanced-table.scss index cc69c46220f..75936fadddd 100644 --- a/packages/components/src/styles/components/advanced-table.scss +++ b/packages/components/src/styles/components/advanced-table.scss @@ -23,6 +23,7 @@ $hds-advanced-table-cell-padding-medium: 14px 16px 13px 16px; // the 1px differe $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 $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown triggers in the header cell +$hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); // ADVANCED TABLE @@ -111,6 +112,76 @@ $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown t z-index: 1; isolation: isolate; } + + &:hover, + &.mock-hover, + &:focus-within { + .hds-advanced-table__th-reorder-handle { + visibility: visible; + opacity: 1; + } + } + + &.hds-advanced-table__th--is-being-dragged .hds-advanced-table__th-reorder-handle { + opacity: 0; + } + } + + .hds-advanced-table__th-reorder-drop-target { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 1; + } + + // is being dragged + .hds-advanced-table__th-reorder-drop-target--is-being-dragged { + background-color: var(--token-color-surface-primary); + opacity: 0.5; + } + + // is dragging over + .hds-advanced-table__th-reorder-drop-target--is-dragging-over { + position: absolute; + top: 0; + right: 0; + left: 0; + z-index: 1; + + &::after { + position: absolute; + top: 0; + bottom: 0; + width: 4px; + background-color: var(--token-color-palette-blue-300); + content: ""; + pointer-events: none; + } + } + + // dragging over left + .hds-advanced-table__th-reorder-drop-target--is-dragging-over--left::after { + left: 0; + transform: translateX(-3px); + } + + // dragging over left and is first + .hds-advanced-table__th-reorder-drop-target--is-dragging-over--left.hds-advanced-table__th-reorder-drop-target--is-first::after { + border-radius: $hds-advanced-table-inner-border-radius 0 0 $hds-advanced-table-inner-border-radius; + transform: translateX(0); + } + + // dragging over right + .hds-advanced-table__th-reorder-drop-target--is-dragging-over--right::after { + right: 0; + transform: translateX(3px); + } + + // dragging over right and is last + .hds-advanced-table__th-reorder-drop-target--is-dragging-over--right.hds-advanced-table__th-reorder-drop-target--is-last::after { + border-radius: 0 $hds-advanced-table-inner-border-radius $hds-advanced-table-inner-border-radius 0; + transform: translateX(0); } .hds-advanced-table__th-resize-handle { @@ -218,6 +289,14 @@ $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown t } } + .hds-advanced-table__scroll-indicator-top { + top: 0; + width: 100%; + height: 8px; + // the rgb value is equivalent to neutral/600, need to use rgba for the right opacity + background: linear-gradient(to top, rgba(59, 61, 69, 0%) 0%, rgba(59, 61, 69, 8%) 100%); + } + // Resizable columns &.hds-advanced-table__thead--has-resizable-columns { .hds-advanced-table__th .hds-advanced-table__th-content-text { @@ -237,6 +316,11 @@ $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown t background-color: var(--token-color-surface-strong); // need to subtract width of cell top border for default state, otherwise border of header+cell is 3.5px border-bottom: calc(3px - #{$hds-advanced-table-border-width} / 2) solid $hds-advanced-table-border-color; + + .hds-advanced-table__scroll-indicator-top { + top: unset; + bottom: -11px; + } } &.hds-advanced-table__thead--is-pinned { @@ -270,6 +354,67 @@ $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown t min-width: 0; } +.hds-advanced-table__th-reorder-handle { + position: absolute; + bottom: 0; + left: 50%; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + transform: translateX(-50%) translateY(50%); + visibility: hidden; + opacity: 0; + + .hds-advanced-table__th-reorder-handle__inner { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 16px; + color: var(--token-color-foreground-faint); + background-color: var(--token-color-surface-interactive); + border: 1px solid var(--token-color-border-primary); + border-radius: var(--token-border-radius-small); + box-shadow: var(--token-elevation-low-box-shadow); + } + + .hds-icon { + width: 12px; + height: 12px; + } + + &:hover, + &.mock-hover { + cursor: grab; + + .hds-advanced-table__th-reorder-handle__inner { + background-color: var(--token-color-surface-interactive-hover); + } + } + + &:active, + &.mock-active { + cursor: grabbing; + + .hds-advanced-table__th-reorder-handle__inner { + background-color: var(--token-color-surface-interactive-hover); + } + } + + &:focus, + &.mock-focus { + @include hds-focus-ring-with-pseudo-element-focus-always-visible($position: absolute, $top: 3px, $bottom: 3px); + + .hds-advanced-table__th-reorder-handle__inner { + background-color: var(--token-color-surface-interactive); + } + } +} + .hds-advanced-table__th-context-menu .hds-dropdown-toggle-icon { width: $hds-advanced-table-button-size; height: $hds-advanced-table-button-size; @@ -585,14 +730,18 @@ $hds-advanced-table-button-size: 24px; // the size of the buttons and dropdown t background: linear-gradient(to right, rgba(59, 61, 69, 0%) 0%, rgba(59, 61, 69, 8%) 100%); } -.hds-advanced-table__scroll-indicator-top { - height: 8px; - // the rgb value is equivalent to neutral/600, need to use rgba for the right opacity - background: linear-gradient(to top, rgba(59, 61, 69, 0%) 0%, rgba(59, 61, 69, 8%) 100%); -} - .hds-advanced-table__scroll-indicator-bottom { height: 8px; // the rgb value is equivalent to neutral/600, need to use rgba for the right opacity background: linear-gradient(to bottom, rgba(59, 61, 69, 0%) 0%, rgba(59, 61, 69, 8%) 100%); } + +.hds-advanced-table__th-reorder-drag-preview { + position: absolute; + top: -9999px; + left: -9999px; + background-color: $hds-advanced-table-drag-preview-background-color; + border: 5px solid var(--token-color-focus-action-external); + border-radius: var(--token-border-radius-medium); + box-shadow: var(--token-elevation-mid-box-shadow); +} diff --git a/packages/components/src/styles/mixins/_focus-ring.scss b/packages/components/src/styles/mixins/_focus-ring.scss index 330f7225676..911e654adb6 100644 --- a/packages/components/src/styles/mixins/_focus-ring.scss +++ b/packages/components/src/styles/mixins/_focus-ring.scss @@ -83,3 +83,46 @@ } } } + +// This mixin is used for the _rare_ cases where we want the focus ring to be visible when the user clicks the element (not just if they focus it with a keyboard). +@mixin hds-focus-ring-with-pseudo-element-focus-always-visible( + $top: 0, + $right: 0, + $bottom: 0, + $left: 0, + $radius: 5px, + $color: action, + $position: relative +) { + position: $position; + 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) + + &::before { + 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 { + &::before { + 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 { + &::before { + box-shadow: none; + } + } +} diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts index 66b79a5e3d1..66183aa31c6 100644 --- a/packages/components/src/template-registry.ts +++ b/packages/components/src/template-registry.ts @@ -14,6 +14,8 @@ import type HdsAdvancedTableThButtonSortComponent from './components/hds/advance import type HdsAdvancedTableThComponent from './components/hds/advanced-table/th'; import type HdsAdvancedTableThButtonTooltipComponent from './components/hds/advanced-table/th-button-tooltip'; import type HdsAdvancedTableThContextMenu from './components/hds/advanced-table/th-context-menu'; +import type HdsAdvancedTableThReorderDropTarget from './components/hds/advanced-table/th-reorder-drop-target'; +import type HdsAdvancedTableThReorderHandle from './components/hds/advanced-table/th-reorder-handle'; import type HdsAdvancedTableThResizeHandle from './components/hds/advanced-table/th-resize-handle'; import type HdsAdvancedTableThSortComponent from './components/hds/advanced-table/th-sort'; import type HdsAdvancedTableThSelectableComponent from './components/hds/advanced-table/th-selectable'; @@ -279,6 +281,10 @@ export default interface HdsComponentsRegistry { 'Hds::AdvancedTable::ThButtonTooltip': typeof HdsAdvancedTableThButtonTooltipComponent; 'Hds::AdvancedTable::ThContextMenu': typeof HdsAdvancedTableThContextMenu; 'hds/advanced-table/th-context-menu': typeof HdsAdvancedTableThContextMenu; + 'Hds::AdvancedTable::ThReorderDropTarget': typeof HdsAdvancedTableThReorderDropTarget; + 'hds/advanced-table/th-reorder-drop-target': typeof HdsAdvancedTableThReorderDropTarget; + 'Hds::AdvancedTable::ThReorderHandle': typeof HdsAdvancedTableThReorderHandle; + 'hds/advanced-table/th-reorder-handle': typeof HdsAdvancedTableThReorderHandle; 'Hds::AdvancedTable::ThResizeHandle': typeof HdsAdvancedTableThResizeHandle; 'hds/advanced-table/th-resize-handle': typeof HdsAdvancedTableThResizeHandle; 'hds/advanced-table/th-button-tooltip': typeof HdsAdvancedTableThButtonTooltipComponent; diff --git a/packages/components/translations/hds/components/advanced-table/en-us.yaml b/packages/components/translations/hds/components/advanced-table/en-us.yaml new file mode 100644 index 00000000000..b6074f30b8b --- /dev/null +++ b/packages/components/translations/hds/components/advanced-table/en-us.yaml @@ -0,0 +1 @@ +reordered-message: Moved {columnLabel} column to position {newPosition} diff --git a/packages/components/translations/hds/components/advanced-table/th-context-menu/en-us.yaml b/packages/components/translations/hds/components/advanced-table/th-context-menu/en-us.yaml index e5c171d0503..04eda4a752f 100644 --- a/packages/components/translations/hds/components/advanced-table/th-context-menu/en-us.yaml +++ b/packages/components/translations/hds/components/advanced-table/th-context-menu/en-us.yaml @@ -1,3 +1,6 @@ +move-column: Move column +move-column-to-start: Move column to start +move-column-to-end: Move column to end resize: Resize column reset-width: Reset column width pin: Pin column diff --git a/packages/components/translations/hds/components/advanced-table/th-reorder-handle/en-us.yaml b/packages/components/translations/hds/components/advanced-table/th-reorder-handle/en-us.yaml new file mode 100644 index 00000000000..6cea384470f --- /dev/null +++ b/packages/components/translations/hds/components/advanced-table/th-reorder-handle/en-us.yaml @@ -0,0 +1,2 @@ +aria-description: Use left and right arrow keys while focused on this button to move the column to a new position. +aria-label: "Reorder {columnLabel} column" diff --git a/showcase/app/controllers/page-components/advanced-table.ts b/showcase/app/controllers/page-components/advanced-table.ts index c60990d1bc0..90d9299bc54 100644 --- a/showcase/app/controllers/page-components/advanced-table.ts +++ b/showcase/app/controllers/page-components/advanced-table.ts @@ -28,6 +28,29 @@ const customSortingCriteriaArray = [ 'pending', ]; +const musicColumns = [ + { + key: 'artist', + label: 'Artist', + tooltip: 'More information.', + }, + { + key: 'album', + label: 'Album', + tooltip: 'More information.', + width: '350px', + }, + { + key: 'year', + label: 'Release Year', + tooltip: 'More information.', + }, + { + key: 'other', + label: 'Additional Actions', + }, +]; + const updateModelWithSelectAllState = ( modelData: SelectableItem[] | User[], selectAllState: boolean, @@ -464,28 +487,7 @@ export default class PageComponentsAdvancedTableController extends Controller { } // COLUMN RESIZING DEMO - columnResizeColumns = [ - { - key: 'artist', - label: 'Artist', - tooltip: 'More information.', - }, - { - key: 'album', - label: 'Album', - tooltip: 'More information.', - width: '350px', - }, - { - key: 'year', - label: 'Release Year', - tooltip: 'More information.', - }, - { - key: 'other', - label: 'Additional Actions', - }, - ]; + columnResizeColumns = musicColumns; columnResizeColumnsWithSorting = this.columnResizeColumns.map( (column, index) => { @@ -496,6 +498,18 @@ export default class PageComponentsAdvancedTableController extends Controller { }, ); + // COLUMN REORDERING DEMO + columnReorderColumns = musicColumns; + + columnReorderColumnsWithSorting = this.columnReorderColumns.map( + (column, index) => { + return { + ...column, + isSortable: index !== this.columnReorderColumns.length - 1, // last column is not sortable + }; + }, + ); + @action noop() { // no-op diff --git a/showcase/app/mocks/infrastructure-data.ts b/showcase/app/mocks/infrastructure-data.ts new file mode 100644 index 00000000000..1895fe90f29 --- /dev/null +++ b/showcase/app/mocks/infrastructure-data.ts @@ -0,0 +1,131 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import type { HdsIconSignature } from '@hashicorp/design-system-components/components/hds/icon/index'; + +export interface InfrastructureResource { + resource_id: string; + status: 'active' | 'pending' | 'failing' | 'establishing'; + namespace: string; + provider_name: 'aws' | 'gcp' | 'azure' | 'kubernetes'; + created_at: Date; + last_run_time: Date; + lease_duration: string; + workspace: string; + datacenter: string; + job_spec_version: number; + attached_policies: string[]; + target_endpoint: string; + audit_device_path: string; + tags: string[]; + icon: HdsIconSignature['Args']['name']; +} + +const infrastructureResources: InfrastructureResource[] = [ + { + resource_id: 'd9b1a2c3-f4e5-6a7b-8c9d-0e1f2a3b4c5d', + status: 'active', + namespace: 'admin/secrets', + provider_name: 'aws', + created_at: new Date('2025-08-15T14:30:00Z'), + last_run_time: new Date('2025-09-10T11:55:12Z'), + lease_duration: '720h', + workspace: 'prod-us-east-1', + datacenter: 'us-east-1', + job_spec_version: 2, + attached_policies: ['root-access', 'audit-writer'], + target_endpoint: 'pki_int.vault.internal:8200', + audit_device_path: '/var/log/vault_audit.log', + tags: ['pki', 'production', 'vault'], + icon: 'vault-color', + }, + { + resource_id: 'e8c2b3d4-a5f6-7b8c-9d0e-1f2a3b4c5d6e', + status: 'establishing', + namespace: 'default', + provider_name: 'azure', + created_at: new Date('2025-09-01T10:00:00Z'), + last_run_time: new Date('2025-09-01T10:05:30Z'), + lease_duration: 'N/A', + workspace: 'dev-network-staging', + datacenter: 'eastus2', + job_spec_version: 5, + attached_policies: ['network-contributor'], + target_endpoint: 'resource-group:rg-dev-net', + audit_device_path: 'N/A', + tags: ['networking', 'terraform-cloud'], + icon: 'terraform-color', + }, + { + resource_id: 'f7d3c4e5-b6a7-8c9d-0e1f-2a3b4c5d6e7f', + status: 'active', + namespace: 'api-gateway', + provider_name: 'gcp', + created_at: new Date('2025-07-20T08:00:00Z'), + last_run_time: new Date('2025-09-10T12:30:00Z'), + lease_duration: '24h', + workspace: 'prod-api', + datacenter: 'us-central1-a', + job_spec_version: 8, + attached_policies: ['service-reader'], + target_endpoint: 'payments-api.service.consul', + audit_device_path: '/var/log/consul_audit.log', + tags: ['api', 'service-mesh', 'consul'], + icon: 'consul-color', + }, + { + resource_id: 'a6e4d5f6-c7b8-9d0e-1f2a-3b4c5d6e7f8a', + status: 'failing', + namespace: 'batch-processing', + provider_name: 'kubernetes', + created_at: new Date('2025-09-09T18:00:00Z'), + last_run_time: new Date('2025-09-09T18:15:00Z'), + lease_duration: '1h', + workspace: 'analytics-jobs', + datacenter: 'on-prem-dc1', + job_spec_version: 12, + attached_policies: ['job-runner', 'logs-reader'], + target_endpoint: 'task:redis-cache', + audit_device_path: 'N/A', + tags: ['batch', 'analytics', 'nomad'], + icon: 'nomad-color', + }, + { + resource_id: 'b5f5e6a7-d8c9-0e1f-2a3b-4c5d6e7f8a9b', + status: 'active', + namespace: 'ssh-targets', + provider_name: 'aws', + created_at: new Date('2025-06-01T00:00:00Z'), + last_run_time: new Date('2025-09-10T09:45:21Z'), + lease_duration: '8h', + workspace: 'corp-ssh-access', + datacenter: 'us-west-2', + job_spec_version: 1, + attached_policies: ['dba-access-prod'], + target_endpoint: 'tcp://db-prod-1.rds.amazonaws.com:5432', + audit_device_path: '/var/log/boundary_session.log', + tags: ['database', 'ssh', 'rds', 'boundary'], + icon: 'boundary-color', + }, + { + resource_id: 'c4a6f7b8-e9d0-1f2a-3b4c-5d6e7f8a9b0c', + status: 'pending', + namespace: 'web-app', + provider_name: 'gcp', + created_at: new Date('2025-09-10T12:51:00Z'), + last_run_time: new Date('2025-09-10T12:51:00Z'), + lease_duration: 'N/A', + workspace: 'webapp-staging', + datacenter: 'us-west1-b', + job_spec_version: 3, + attached_policies: ['deployer-role'], + target_endpoint: 'cloud-run:frontend-staging-app', + audit_device_path: 'N/A', + tags: ['frontend', 'waypoint', 'cicd'], + icon: 'waypoint-color', + }, +]; + +export default infrastructureResources; diff --git a/showcase/app/routes/page-components/advanced-table.ts b/showcase/app/routes/page-components/advanced-table.ts index e718f7ca5e0..c876457ba81 100644 --- a/showcase/app/routes/page-components/advanced-table.ts +++ b/showcase/app/routes/page-components/advanced-table.ts @@ -15,6 +15,7 @@ import policies from 'showcase/mocks/policy-data'; import selectableItems from 'showcase/mocks/selectable-item-data'; import spanningCells from 'showcase/mocks/spanning-cell-data'; import users from 'showcase/mocks/user-data'; +import infrastructureResources from 'showcase/mocks/infrastructure-data'; export type PageComponentsAdvancedTableModel = ModelFrom; @@ -28,6 +29,7 @@ export default class PageComponentsAdvancedTableRoute extends Route { userDataShort: structuredClone(users.slice(0, 5)), clusters, spanningManualData: spanningCells, + infrastructureResources, selectableData: selectableItems, selectableDataDemo1: structuredClone(selectableItems), selectableDataDemo2: structuredClone(selectableItems), diff --git a/showcase/app/styles/showcase-pages/advanced-table.scss b/showcase/app/styles/showcase-pages/advanced-table.scss index 69557896650..9f6bbef0307 100644 --- a/showcase/app/styles/showcase-pages/advanced-table.scss +++ b/showcase/app/styles/showcase-pages/advanced-table.scss @@ -41,6 +41,10 @@ body.page-components-advanced-table { width: 400px; } + .shw-component-advanced-table-fixed-width-wrapper--wide { + width: 600px; + } + .shw-component-advanced-table-with-pagination-demo-wrapper { .hds-table + .hds-pagination { margin-top: 16px; @@ -83,4 +87,10 @@ body.page-components-advanced-table { white-space: nowrap; text-overflow: ellipsis; } + + .shw-component-advanced-table-reorder-handle-container + .hds-advanced-table__th-reorder-handle { + visibility: visible; + opacity: 1; + } } diff --git a/showcase/app/templates/page-components/advanced-table.hbs b/showcase/app/templates/page-components/advanced-table.hbs index d473e2d70f8..f8f3bedf4bb 100644 --- a/showcase/app/templates/page-components/advanced-table.hbs +++ b/showcase/app/templates/page-components/advanced-table.hbs @@ -621,6 +621,240 @@ + Reorderable columns + + + <:body as |B|> + {{! @glint-expect-error - this argument shouldn't be required, will be fixed by https://hashicorp.atlassian.net/browse/HDS-5167}} + + {{#each R.orderedCells as |C|}} + {{#if (eq C.columnKey "artist")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.artist}} + {{else}} + + {{#if (eq C.columnKey "album")}} +
+ {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.album}} +
+ {{else if (eq C.columnKey "year")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{else}} + + + + + + {{/if}} +
+ {{/if}} + {{/each}} +
+ +
+ + Reorderable columns with sorting + + + <:body as |B|> + + {{#each R.orderedCells as |C|}} + {{#if (eq C.columnKey "artist")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.artist}} + {{else}} + + {{#if (eq C.columnKey "album")}} +
+ {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.album}} +
+ {{else if (eq C.columnKey "year")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{else}} + + + + + + {{/if}} +
+ {{/if}} + {{/each}} +
+ +
+ + Reorderable columns with sticky header + + + <:body as |B|> + + {{#each R.orderedCells as |C|}} + {{#if (eq C.columnKey "artist")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.artist}} + {{else}} + + {{#if (eq C.columnKey "album")}} +
+ {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.album}} +
+ {{else if (eq C.columnKey "year")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{else}} + + + + + + {{/if}} +
+ {{/if}} + {{/each}} +
+ +
+ + Reorderable columns with horizontal overflow + +
+ + <:body as |B|> + + {{#each R.orderedCells as |C|}} + + {{#if (eq C.columnKey "status")}} + {{#if (eq (get B.data "status") "failing")}} + + {{else if (eq (get B.data "status") "active")}} + + {{else if (eq (get B.data "status") "pending")}} + + {{else if (eq (get B.data "status") "establishing")}} + + {{/if}} + {{else if (or (eq C.columnKey "created_at") (eq C.columnKey "last_run_time"))}} + {{#if (eq C.columnKey "created_at")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{format-date (get B.data "created_at")}} + {{else}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{format-date (get B.data "last_run_time")}} + {{/if}} + {{else if (or (eq C.columnKey "attached_policies") (eq C.columnKey "tags"))}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{#each C.content as |content|}} + + {{/each}} + + {{else}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{C.content}} + {{/if}} + + {{/each}} + + + +
+ + Reorderable columns with selectable rows + +
+ + <:body as |B|> + + {{#each R.orderedCells as |C|}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{C.content}} + + {{/each}} + + + +
+ + + Resizable columns {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} @@ -721,6 +955,48 @@ + Resizable and reorderable columns + + + <:body as |B|> + {{! @glint-expect-error - this argument shouldn't be required, will be fixed by https://hashicorp.atlassian.net/browse/HDS-5167}} + + {{#each R.orderedCells as |C|}} + {{#if (eq C.columnKey "artist")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.artist}} + {{else}} + + {{#if (eq C.columnKey "album")}} +
+ {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + {{B.data.album}} +
+ {{else if (eq C.columnKey "year")}} + {{! @glint-expect-error - will be fixed by https://hashicorp.atlassian.net/browse/HDS-5090}} + + {{else}} + + + + + + {{/if}} +
+ {{/if}} + {{/each}} +
+ +
+ Pinnable first column @@ -2396,7 +2672,7 @@
@@ -2410,4 +2686,22 @@ + + + ThReorderHandle + + + {{#each @model.STATES as |state|}} + + + + {{/each}} + + \ No newline at end of file diff --git a/showcase/tests/integration/components/hds/advanced-table/index-test.js b/showcase/tests/integration/components/hds/advanced-table/index-test.js index 74ed755e9b9..193505bc8c8 100644 --- a/showcase/tests/integration/components/hds/advanced-table/index-test.js +++ b/showcase/tests/integration/components/hds/advanced-table/index-test.js @@ -11,6 +11,7 @@ import { focus, setupOnerror, find, + findAll, settled, triggerEvent, triggerKeyEvent, @@ -54,6 +55,90 @@ async function simulateRightPointerDrag(handle) { await triggerEvent(window, 'pointerup', { button: 0 }); } +function getColumnByLabel(columns, label) { + return columns.find((col) => col.label === label); +} + +async function getColumnOrder(columns) { + const thElements = await findAll('.hds-advanced-table__th'); + + return thElements.map((th) => { + const column = getColumnByLabel(columns, th.textContent.trim()); + + return column ? column.key : null; + }); +} + +async function startReorderDrag(handleElement) { + return triggerEvent(handleElement, 'dragstart'); +} + +function getTargetElementFromColumnIndex(index) { + const dropTargets = findAll('.hds-advanced-table__th-reorder-drop-target'); + const target = dropTargets[index]; + + if (target === null) { + throw new Error( + `Target column at index ${index} not found after drag started.`, + ); + } + + return target; +} + +function getDragTargetPosition(targetElement, targetPosition) { + const targetRect = targetElement.getBoundingClientRect(); + let clientX; + + switch (targetPosition) { + case 'left': + clientX = targetRect.left + 1; + break; + case 'right': + clientX = targetRect.right - 1; + break; + default: + throw new Error( + `Invalid targetPosition: ${targetPosition}. Use 'left' or 'right'.`, + ); + } + + return { clientX, clientY: targetRect.top + targetRect.height / 2 }; +} + +async function dragOverTarget(target, { clientX, clientY }) { + await triggerEvent(target, 'dragenter', { clientX, clientY }); + await triggerEvent(target, 'dragover', { clientX, clientY }); +} + +async function simulateColumnReorderDrag({ + handleElement, + targetElement, + targetIndex, + targetPosition = 'left', +}) { + await startReorderDrag(handleElement); + + const target = targetElement ?? getTargetElementFromColumnIndex(targetIndex); + const { clientX, clientY } = getDragTargetPosition(target, targetPosition); + + const eventOptions = { clientX, clientY }; + + await dragOverTarget(target, eventOptions); + + // return the target event options for further use, if needed + return { target, eventOptions }; +} + +async function simulateColumnReorderDrop({ + target, + handleElement, + eventOptions, +}) { + await triggerEvent(target, 'drop', eventOptions); + await triggerEvent(handleElement, 'dragend'); +} + // we're using this for multiple tests so we'll declare context once and use it when we need it. const setTableData = (context) => { context.set('model', [ @@ -178,6 +263,19 @@ const setNestedTableData = (context) => { ]); }; +const setReorderableColumnsTableData = (context) => { + context.set('model', [ + { id: '1', artist: 'Nick Drake', album: 'Pink Moon', year: '1972' }, + { id: '2', artist: 'The Beatles', album: 'Abbey Road', year: '1969' }, + { id: '3', artist: 'Melanie', album: 'Candles in the Rain', year: '1971' }, + ]); + context.set('columns', [ + { key: 'artist', label: 'Artist' }, + { key: 'album', label: 'Album' }, + { key: 'year', label: 'Year' }, + ]); +}; + const setResizableColumnsTableData = (context) => { context.set('model', [ { id: '1', col1: 'A', col2: 'B' }, @@ -282,6 +380,444 @@ const hbsResizableColumnsAdvancedTable = hbs`
module('Integration | Component | hds/advanced-table/index', function (hooks) { setupRenderingTest(hooks); + module('column reordering', function (hooks) { + hooks.beforeEach(function () { + setReorderableColumnsTableData(this); + }); + + test('it renders reorder handles when reordering is enabled', async function (assert) { + this.set('hasReorderableColumns', false); + + await render( + hbs``, + ); + + assert + .dom('.hds-advanced-table__th-reorder-handle') + .doesNotExist( + 'No reorder handles are rendered when reordering is disabled', + ); + + this.set('hasReorderableColumns', true); + + assert + .dom('.hds-advanced-table__th-reorder-handle') + .exists({ count: 3 }, 'All columns have a reorder handle'); + }); + + test('it does not render a reorder handle on the row selection column', async function (assert) { + await render( + hbs``, + ); + + const selectAllThSelector = + '[role="columnheader"].hds-advanced-table__th--is-selectable'; + const reorderHandleSelector = '.hds-advanced-table__th-reorder-handle'; + + assert + .dom(`${selectAllThSelector} ${reorderHandleSelector}`) + .doesNotExist( + 'No reorder handle is rendered on the row selection column', + ); + }); + + test('columns can be reordered by dragging and dropping', async function (assert) { + await render( + hbs``, + ); + + let columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + this.columns.map((col) => col.key), + 'Initial column order is correct', + ); + + const expectedDropTargetIndex = 2; + const expectedDropTargetDropSide = 'right'; + + // get the first reorder handle + const reorderHandle = find('.hds-advanced-table__th-reorder-handle'); + + // drag to the right side of the last column + const { target, eventOptions } = await simulateColumnReorderDrag({ + handleElement: reorderHandle, + targetIndex: expectedDropTargetIndex, + targetPosition: expectedDropTargetDropSide, + }); + + // get all drop targets for test reference + const dropTargets = findAll( + '.hds-advanced-table__th-reorder-drop-target', + ); + const originDropTarget = dropTargets[0]; + const destinationDropTarget = dropTargets[expectedDropTargetIndex]; + + assert + .dom(originDropTarget) + .hasClass( + 'hds-advanced-table__th-reorder-drop-target--is-being-dragged', + 'First column is being dragged', + ); + assert + .dom(destinationDropTarget) + .hasClass( + 'hds-advanced-table__th-reorder-drop-target--is-dragging-over', + ) + .hasClass( + `hds-advanced-table__th-reorder-drop-target--is-dragging-over--${expectedDropTargetDropSide}`, + ); + + await simulateColumnReorderDrop({ + target, + handleElement: reorderHandle, + eventOptions, + }); + + columnOrder = await getColumnOrder(this.columns); + + assert + .dom('.hds-advanced-table__th-reorder-drop-target') + .doesNotExist('Drop targets are removed after drop'); + assert.deepEqual( + columnOrder, + [this.columns[1].key, this.columns[2].key, this.columns[0].key], + 'Columns are reordered correctly after drag and drop', + ); + }); + + test('dropping a target on the nearest side of the next sibling does not reorder columns', async function (assert) { + await render( + hbs``, + ); + + const initialColumnOrder = this.columns.map((col) => col.key); + + let columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + initialColumnOrder, + 'Initial column order is correct', + ); + + const reorderHandle = find('.hds-advanced-table__th-reorder-handle'); + + const { target, eventOptions } = await simulateColumnReorderDrag({ + handleElement: reorderHandle, + targetIndex: 1, + targetPosition: 'left', + }); + + const dropTargets = findAll( + '.hds-advanced-table__th-reorder-drop-target', + ); + const originDropTarget = dropTargets[0]; + const destinationDropTarget = dropTargets[1]; + + assert + .dom(originDropTarget) + .hasClass( + 'hds-advanced-table__th-reorder-drop-target--is-being-dragged', + 'First column is being dragged', + ); + assert + .dom(destinationDropTarget) + .doesNotHaveClass( + 'hds-advanced-table__th-reorder-drop-target--is-dragging-over', + ) + .doesNotHaveClass( + 'hds-advanced-table__th-reorder-drop-target--is-dragging-over--left', + ); + + await simulateColumnReorderDrop({ + target, + handleElement: reorderHandle, + eventOptions, + }); + + columnOrder = await getColumnOrder(this.columns); + + assert + .dom('.hds-advanced-table__th-reorder-drop-target') + .doesNotExist('Drop targets are removed after drop'); + assert.deepEqual( + columnOrder, + initialColumnOrder, + 'Columns order is unchanged after drop on the nearest side', + ); + }); + + test('it should show the context menu with the correct options when reordering is enabled', async function (assert) { + await render( + hbs``, + ); + + const thElements = findAll('.hds-advanced-table__th'); // find all header cells + + assert.ok( + thElements[0].querySelector('.hds-advanced-table__th-context-menu'), + 'context menu exists', + ); + + const firstContextMenuToggle = thElements[0].querySelector( + '.hds-dropdown-toggle-icon', + ); + await click(firstContextMenuToggle); + assert.dom('[data-test-context-option-key="reorder-column"]').exists(); + assert + .dom('[data-test-context-option-key="move-column-to-start"]') + .doesNotExist(); + assert + .dom('[data-test-context-option-key="move-column-to-end"]') + .exists(); + + const secondContextMenuToggle = thElements[1].querySelector( + '.hds-dropdown-toggle-icon', + ); + await click(secondContextMenuToggle); + assert.dom('[data-test-context-option-key="reorder-column"]').exists(); + assert + .dom('[data-test-context-option-key="move-column-to-start"]') + .exists(); + assert + .dom('[data-test-context-option-key="move-column-to-end"]') + .exists(); + + const lastContextMenuToggle = thElements[ + thElements.length - 1 + ].querySelector('.hds-dropdown-toggle-icon'); + await click(lastContextMenuToggle); + assert.dom('[data-test-context-option-key="reorder-column"]').exists(); + assert + .dom('[data-test-context-option-key="move-column-to-start"]') + .exists(); + assert + .dom('[data-test-context-option-key="move-column-to-end"]') + .doesNotExist(); + }); + + test('clicking the "Move column" context menu option focuses the reorder handle', async function (assert) { + await render( + hbs``, + ); + + const thElements = findAll('.hds-advanced-table__th'); + + const firstContextMenuToggle = thElements[0].querySelector( + '.hds-dropdown-toggle-icon', + ); + await click(firstContextMenuToggle); + await click('[data-test-context-option-key="reorder-column"]'); + + const firstReorderHandle = thElements[0].querySelector( + '.hds-advanced-table__th-reorder-handle', + ); + + assert.dom(firstReorderHandle).isFocused(); + }); + + test('clicking the "Move column to start" context menu option moves the column to the start', async function (assert) { + await render( + hbs``, + ); + + const thElements = findAll('.hds-advanced-table__th'); + + const secondContextMenuToggle = thElements[1].querySelector( + '.hds-dropdown-toggle-icon', + ); + await click(secondContextMenuToggle); + await click('[data-test-context-option-key="move-column-to-start"]'); + + const columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + [this.columns[1].key, this.columns[0].key, this.columns[2].key], + 'The second column is moved to the start', + ); + }); + + test('clicking the "Move column to end" context menu option moves the column to the end', async function (assert) { + await render( + hbs``, + ); + + const thElements = findAll('.hds-advanced-table__th'); + + const secondContextMenuToggle = thElements[1].querySelector( + '.hds-dropdown-toggle-icon', + ); + await click(secondContextMenuToggle); + await click('[data-test-context-option-key="move-column-to-end"]'); + + const columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + [this.columns[0].key, this.columns[2].key, this.columns[1].key], + 'The second column is moved to the end', + ); + }); + + test('pressing "Left Arrow" and "Right Arrow" keys when the reorder handle is focused moves the column', async function (assert) { + await render( + hbs``, + ); + + const thElements = findAll('.hds-advanced-table__th'); + const firstThElement = thElements[0]; + const firstReorderHandle = thElements[0].querySelector( + '.hds-advanced-table__th-reorder-handle', + ); + await focus(firstThElement); + await focus(firstReorderHandle); + assert.dom(firstReorderHandle).isFocused(); + + await triggerKeyEvent(firstReorderHandle, 'keydown', 'ArrowRight'); + let columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + [this.columns[1].key, this.columns[0].key, this.columns[2].key], + 'The first column is moved to the right', + ); + assert.dom(firstReorderHandle).isFocused(); + + await triggerKeyEvent(firstReorderHandle, 'keydown', 'ArrowRight'); + columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + [this.columns[1].key, this.columns[2].key, this.columns[0].key], + 'The second column is moved to the right', + ); + assert.dom(firstReorderHandle).isFocused(); + + await triggerKeyEvent(firstReorderHandle, 'keydown', 'ArrowLeft'); + columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + [this.columns[1].key, this.columns[0].key, this.columns[2].key], + 'The third column is moved back to the left', + ); + assert.dom(firstReorderHandle).isFocused(); + }); + + test('passing in columnOrder sets the initial order of the table columns', async function (assert) { + await render( + hbs``, + ); + + const columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + ['album', 'year', 'artist'], + 'The initial column order is set correctly', + ); + }); + + test('updating columnOrder externally changes the order of the table columns', async function (assert) { + this.set('columnOrder', ['artist', 'album', 'year']); + + await render( + hbs``, + ); + + let columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + ['artist', 'album', 'year'], + 'The initial column order is set correctly', + ); + + this.set('columnOrder', ['year', 'album', 'artist']); + columnOrder = await getColumnOrder(this.columns); + assert.deepEqual( + columnOrder, + ['year', 'album', 'artist'], + 'The column order is updated correctly', + ); + }); + + test('it throws an assertion if @hasStickyFirstColumn is true and @hasReorderableColumns is true', async function (assert) { + const errorMessage = + 'Cannot have both reorderable columns and a sticky first column.'; + + setupOnerror(function (error) { + assert.strictEqual(error.message, `Assertion Failed: ${errorMessage}`); + }); + + await render( + hbs``, + ); + + assert.throws(function () { + throw new Error(errorMessage); + }); + }); + }); + test('it should render the component with a CSS class that matches the component name', async function (assert) { setSortableTableData(this); @@ -379,6 +915,34 @@ module('Integration | Component | hds/advanced-table/index', function (hooks) { .hasClass('hds-advanced-table--valign-top'); }); + test('it throws an assertion if @hasReorderableColumns and has nested rows', async function (assert) { + const errorMessage = + 'Cannot have reorderable columns if there are nested rows.'; + + setNestedTableData(this); + assert.expect(2); + setupOnerror(function (error) { + assert.strictEqual(error.message, `Assertion Failed: ${errorMessage}`); + }); + await render(hbs` + <:body as |B|> + + {{B.data.name}} + {{B.data.age}} + + + `); + + assert.throws(function () { + throw new Error(errorMessage); + }); + }); + test('it throws an assertion if @isStriped and has nested rows', async function (assert) { const errorMessage = '@isStriped must not be true if there are nested rows.'; diff --git a/showcase/tests/unit/components/hds/advanced-table/models/column-test.js b/showcase/tests/unit/components/hds/advanced-table/models/column-test.js index f867437a7a6..5854adfd993 100644 --- a/showcase/tests/unit/components/hds/advanced-table/models/column-test.js +++ b/showcase/tests/unit/components/hds/advanced-table/models/column-test.js @@ -260,22 +260,24 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('index getter returns correct column position', function (assert) { + const mockColumns = [ + new HdsAdvancedTableColumn({ + column: { label: 'First', key: 'first' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Second', key: 'second' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Third', key: 'third' }, + table: null, + }), + ]; // Create mock table with multiple columns const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'First', key: 'first' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Second', key: 'second' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Third', key: 'third' }, - table: null, - }), - ], + columns: mockColumns, + orderedColumns: mockColumns, }; // Set table reference for each column @@ -299,7 +301,7 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('index getter returns -1 when table has no columns', function (assert) { - const mockTable = { columns: [] }; + const mockTable = { columns: [], orderedColumns: [] }; const column = new HdsAdvancedTableColumn({ column: { label: 'Test', key: 'test' }, table: mockTable, @@ -309,13 +311,15 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('index getter returns -1 when column key does not exist in table', function (assert) { + const columns = [ + new HdsAdvancedTableColumn({ + column: { label: 'Other', key: 'other' }, + table: null, + }), + ]; const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'Other', key: 'other' }, - table: null, - }), - ], + columns, + orderedColumns: columns, }; mockTable.columns[0].table = mockTable; @@ -332,17 +336,19 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('isFirst getter identifies first column correctly', function (assert) { + const columns = [ + new HdsAdvancedTableColumn({ + column: { label: 'First', key: 'first' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Second', key: 'second' }, + table: null, + }), + ]; const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'First', key: 'first' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Second', key: 'second' }, - table: null, - }), - ], + columns, + orderedColumns: columns, }; mockTable.columns.forEach((col) => (col.table = mockTable)); @@ -358,7 +364,7 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('isFirst getter returns false when index is -1', function (assert) { - const mockTable = { columns: [] }; + const mockTable = { columns: [], orderedColumns: [] }; const column = new HdsAdvancedTableColumn({ column: { label: 'Test', key: 'test' }, table: mockTable, @@ -368,21 +374,23 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('isLast getter identifies last column correctly', function (assert) { + const columns = [ + new HdsAdvancedTableColumn({ + column: { label: 'First', key: 'first' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Second', key: 'second' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Third', key: 'third' }, + table: null, + }), + ]; const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'First', key: 'first' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Second', key: 'second' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Third', key: 'third' }, - table: null, - }), - ], + columns, + orderedColumns: columns, }; mockTable.columns.forEach((col) => (col.table = mockTable)); @@ -402,7 +410,7 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('isLast getter returns false when index is -1', function (assert) { - const mockTable = { columns: [] }; + const mockTable = { columns: [], orderedColumns: [] }; const column = new HdsAdvancedTableColumn({ column: { label: 'Test', key: 'test' }, table: mockTable, @@ -412,21 +420,23 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('siblings getter returns correct previous and next columns', function (assert) { + const columns = [ + new HdsAdvancedTableColumn({ + column: { label: 'First', key: 'first' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Second', key: 'second' }, + table: null, + }), + new HdsAdvancedTableColumn({ + column: { label: 'Third', key: 'third' }, + table: null, + }), + ]; const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'First', key: 'first' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Second', key: 'second' }, - table: null, - }), - new HdsAdvancedTableColumn({ - column: { label: 'Third', key: 'third' }, - table: null, - }), - ], + columns, + orderedColumns: columns, }; mockTable.columns.forEach((col) => (col.table = mockTable)); @@ -472,7 +482,7 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('siblings getter returns empty object when index is -1', function (assert) { - const mockTable = { columns: [] }; + const mockTable = { columns: [], orderedColumns: [] }; const column = new HdsAdvancedTableColumn({ column: { label: 'Test', key: 'test' }, table: mockTable, @@ -483,13 +493,15 @@ module('Unit | Component | hds/advanced-table/models/column', function () { }); test('siblings getter works with single column', function (assert) { + const columns = [ + new HdsAdvancedTableColumn({ + column: { label: 'Only', key: 'only' }, + table: null, + }), + ]; const mockTable = { - columns: [ - new HdsAdvancedTableColumn({ - column: { label: 'Only', key: 'only' }, - table: null, - }), - ], + columns, + orderedColumns: columns, }; mockTable.columns[0].table = mockTable;