diff --git a/packages/components/package.json b/packages/components/package.json index 4c72d0b25cb..e63acb4661e 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -236,6 +236,13 @@ "./components/hds/dropdown/toggle/button.js": "./dist/_app_/components/hds/dropdown/toggle/button.js", "./components/hds/dropdown/toggle/chevron.js": "./dist/_app_/components/hds/dropdown/toggle/chevron.js", "./components/hds/dropdown/toggle/icon.js": "./dist/_app_/components/hds/dropdown/toggle/icon.js", + "./components/hds/filter-bar/checkbox.js": "./dist/_app_/components/hds/filter-bar/checkbox.js", + "./components/hds/filter-bar/dropdown.js": "./dist/_app_/components/hds/filter-bar/dropdown.js", + "./components/hds/filter-bar/filters-checkbox.js": "./dist/_app_/components/hds/filter-bar/filters-checkbox.js", + "./components/hds/filter-bar/filters-dropdown.js": "./dist/_app_/components/hds/filter-bar/filters-dropdown.js", + "./components/hds/filter-bar.js": "./dist/_app_/components/hds/filter-bar.js", + "./components/hds/filter-bar/radio.js": "./dist/_app_/components/hds/filter-bar/radio.js", + "./components/hds/filter-bar/range.js": "./dist/_app_/components/hds/filter-bar/range.js", "./components/hds/flyout/body.js": "./dist/_app_/components/hds/flyout/body.js", "./components/hds/flyout/description.js": "./dist/_app_/components/hds/flyout/description.js", "./components/hds/flyout/footer.js": "./dist/_app_/components/hds/flyout/footer.js", diff --git a/packages/components/src/components.ts b/packages/components/src/components.ts index 400dc061324..869ddeb795e 100644 --- a/packages/components/src/components.ts +++ b/packages/components/src/components.ts @@ -130,6 +130,16 @@ export * from './components/hds/dropdown/list-item/types.ts'; export * from './components/hds/dropdown/toggle/types.ts'; export * from './components/hds/dropdown/types.ts'; +// FilterBar +export { default as HdsFilterBar } from './components/hds/filter-bar/index.ts'; +export { default as HdsFilterBarCheckbox } from './components/hds/filter-bar/checkbox.ts'; +export { default as HdsFilterBarDropdown } from './components/hds/filter-bar/dropdown.ts'; +export { default as HdsFilterBarFiltersCheckbox } from './components/hds/filter-bar/filters-checkbox.ts'; +export { default as HdsFilterBarFiltersDropdown } from './components/hds/filter-bar/filters-dropdown.ts'; +export { default as HdsFilterBarRadio } from './components/hds/filter-bar/radio.ts'; +export { default as HdsFilterBarRange } from './components/hds/filter-bar/range.ts'; +export * from './components/hds/filter-bar/types.ts'; + // Flyout export { default as HdsFlyout } from './components/hds/flyout/index.ts'; export * from './components/hds/flyout/types.ts'; diff --git a/packages/components/src/components/hds/advanced-table/index.hbs b/packages/components/src/components/hds/advanced-table/index.hbs index 8f8bae33e58..3a8934c884f 100644 --- a/packages/components/src/components/hds/advanced-table/index.hbs +++ b/packages/components/src/components/hds/advanced-table/index.hbs @@ -3,6 +3,11 @@ SPDX-License-Identifier: MPL-2.0 }} +{{#if (has-block "actions")}} +
+ {{yield (hash FilterBar=(component "hds/filter-bar")) to="actions"}} +
+{{/if}}
{{! Body }} -
- {{! ---------------------------------------------------------------------------------------- + {{#unless this.isEmpty}} +
+ {{! ---------------------------------------------------------------------------------------- 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 this._tableModel.sortedRows key=this.identityKey as |record index|}} - {{#if this._tableModel.hasRowsWithChildren}} - + {{#each this._tableModel.sortedRows key=this.identityKey as |record index|}} + {{#if this._tableModel.hasRowsWithChildren}} + + {{yield + (hash + Tr=(component + "hds/advanced-table/tr" + isLastRow=(eq this._tableModel.lastVisibleRow.id T.data.id) + isParentRow=T.isExpandable + depth=T.depth + displayRow=T.shouldDisplayChildRows + data=T.data + ) + Th=(component + "hds/advanced-table/th" + depth=T.depth + isExpandable=T.isExpandable + isExpanded=T.isExpanded + newLabel=T.id + parentId=T.parentId + scope="row" + onClickToggle=T.onClickToggle + ) + Td=(component "hds/advanced-table/td" align=@align) + data=T.data + isOpen=T.isExpanded + rowIndex=T.rowIndex + ) + to="body" + }} + + {{else}} {{yield (hash Tr=(component "hds/advanced-table/tr" - isLastRow=(eq this._tableModel.lastVisibleRow.id T.data.id) - isParentRow=T.isExpandable - depth=T.depth - displayRow=T.shouldDisplayChildRows - data=T.data + selectionScope="row" + isLastRow=(eq this._tableModel.lastVisibleRow.id record.id) + isSelectable=this.isSelectable + onSelectionChange=this.onSelectionRowChange + didInsert=this.didInsertRowCheckbox + willDestroy=this.willDestroyRowCheckbox + selectionAriaLabelSuffix=@selectionAriaLabelSuffix + hasStickyColumn=this.hasStickyFirstColumn + isStickyColumnPinned=this.isStickyColumnPinned + data=record ) Th=(component "hds/advanced-table/th" - depth=T.depth - isExpandable=T.isExpandable - isExpanded=T.isExpanded - newLabel=T.id - parentId=T.parentId scope="row" - onClickToggle=T.onClickToggle + isStickyColumn=this.hasStickyFirstColumn + isStickyColumnPinned=this.isStickyColumnPinned ) Td=(component "hds/advanced-table/td" align=@align) - data=T.data - isOpen=T.isExpanded - rowIndex=T.rowIndex + data=record + rowIndex=index ) to="body" }} - - {{else}} - {{yield - (hash - Tr=(component - "hds/advanced-table/tr" - selectionScope="row" - isLastRow=(eq this._tableModel.lastVisibleRow.id record.id) - isSelectable=this.isSelectable - onSelectionChange=this.onSelectionRowChange - didInsert=this.didInsertRowCheckbox - willDestroy=this.willDestroyRowCheckbox - selectionAriaLabelSuffix=@selectionAriaLabelSuffix - hasStickyColumn=this.hasStickyFirstColumn - isStickyColumnPinned=this.isStickyColumnPinned - data=record - ) - Th=(component - "hds/advanced-table/th" - scope="row" - isStickyColumn=this.hasStickyFirstColumn - isStickyColumnPinned=this.isStickyColumnPinned - ) - Td=(component "hds/advanced-table/td" align=@align) - data=record - rowIndex=index - ) - to="body" - }} - {{/if}} - {{/each}} -
+ {{/if}} + {{/each}} +
+ {{/unless}}
{{#if this.showScrollIndicatorLeft}} @@ -196,10 +203,29 @@ /> {{/if}} - {{#if this.showScrollIndicatorBottom}} -
+ {{#unless this.isEmpty}} + {{#if this.showScrollIndicatorBottom}} +
+ {{/if}} + {{/unless}} + + {{#if this.isEmpty}} +
+
+ {{#if (has-block "emptyState")}} + {{yield to="emptyState"}} + {{else}} + + No data to display + + No data is available in the table to display. + + + {{/if}} +
+
{{/if}}
\ 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 index a5bfae87784..61d49bf72a2 100644 --- a/packages/components/src/components/hds/advanced-table/index.ts +++ b/packages/components/src/components/hds/advanced-table/index.ts @@ -14,6 +14,7 @@ import HdsAdvancedTableTableModel from './models/table.ts'; import type Owner from '@ember/owner'; import type { WithBoundArgs } from '@glint/template'; +import type { ComponentLike } from '@glint/template'; import { HdsAdvancedTableDensityValues, HdsAdvancedTableVerticalAlignmentValues, @@ -30,6 +31,7 @@ import type { HdsAdvancedTableExpandState, HdsAdvancedTableColumnReorderCallback, } from './types.ts'; +import type { HdsFilterBarSignature } from '../filter-bar/index.ts'; import type HdsAdvancedTableColumnType from './models/column.ts'; import type { HdsFormCheckboxBaseSignature } from '../form/checkbox/base.ts'; import type HdsAdvancedTableTd from './td.ts'; @@ -149,6 +151,7 @@ export interface HdsAdvancedTableSignature { hasStickyFirstColumn?: boolean; childrenKey?: string; maxHeight?: string; + isEmpty?: boolean; onColumnReorder?: HdsAdvancedTableColumnReorderCallback; onColumnResize?: (columnKey: string, newWidth?: string) => void; onSelectionChange?: ( @@ -157,6 +160,11 @@ export interface HdsAdvancedTableSignature { onSort?: (sortBy: string, sortOrder: HdsAdvancedTableThSortOrder) => void; }; Blocks: { + actions?: [ + { + FilterBar?: ComponentLike; + }, + ]; body?: [ { Td?: WithBoundArgs; @@ -192,6 +200,7 @@ export interface HdsAdvancedTableSignature { isOpen?: HdsAdvancedTableExpandState; }, ]; + emptyState?: []; }; Element: HTMLDivElement; } @@ -259,6 +268,14 @@ export default class HdsAdvancedTable extends Component -
+
+ {{#if this.onDismiss}} + + {{/if}} {{yield (hash ToggleButton=(component @@ -21,6 +26,15 @@ > {{#if (or PP.isOpen @preserveContentInDom)}} {{yield (hash Header=(component "hds/dropdown/header") close=PP.hidePopover)}} + {{#if @searchEnabled}} + + {{/if}}
    {{yield (hash diff --git a/packages/components/src/components/hds/dropdown/index.ts b/packages/components/src/components/hds/dropdown/index.ts index 12bccfcd4ce..a73c5e6b3ee 100644 --- a/packages/components/src/components/hds/dropdown/index.ts +++ b/packages/components/src/components/hds/dropdown/index.ts @@ -6,6 +6,7 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; import { assert } from '@ember/debug'; +import { modifier } from 'ember-modifier'; import { // map Dropdown's `listPosition` values to PopoverPrimitive's `placement` values @@ -48,6 +49,10 @@ export interface HdsDropdownSignature { enableCollisionDetection?: HdsAnchoredPositionOptions['enableCollisionDetection']; preserveContentInDom?: boolean; matchToggleWidth?: boolean; + searchEnabled?: boolean; + searchPlaceholder?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onDismiss?: (event: MouseEvent, ...args: any[]) => void; }; Blocks: { default: [ @@ -73,6 +78,14 @@ export interface HdsDropdownSignature { } export default class HdsDropdown extends Component { + private _element!: HTMLDivElement; + + private _setUpDropdown = modifier((element: HTMLDivElement) => { + this._element = element; + + return () => {}; + }); + /** * @param listPosition * @type {string} @@ -116,6 +129,17 @@ export default class HdsDropdown extends Component { }; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get onDismiss(): ((event: MouseEvent, ...args: any[]) => void) | false { + const { onDismiss } = this.args; + + if (typeof onDismiss === 'function') { + return onDismiss; + } else { + return false; + } + } + /** * Get the class names to apply to the element * @method classNames @@ -129,6 +153,10 @@ export default class HdsDropdown extends Component { classes.push('hds-dropdown--is-inline'); } + if (this.args.onDismiss) { + classes.push('hds-dropdown--has-dismiss'); + } + return classes.join(' '); } @@ -169,4 +197,20 @@ export default class HdsDropdown extends Component { } } } + + onSearch = (event: Event) => { + const listItems = this._element.querySelectorAll('.hds-dropdown-list-item') as NodeListOf; + const input = event.target as HTMLInputElement; + listItems.forEach((item) => { + if (item.textContent) { + const text = item.textContent.toLowerCase(); + const searchText = input.value.toLowerCase(); + if (text.includes(searchText)) { + item.style.display = ''; + } else { + item.style.display = 'none'; + } + } + }); + }; } diff --git a/packages/components/src/components/hds/filter-bar/checkbox.hbs b/packages/components/src/components/hds/filter-bar/checkbox.hbs new file mode 100644 index 00000000000..6d2e3ecd56f --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/checkbox.hbs @@ -0,0 +1,5 @@ +{{#let @checkbox as |Checkbox|}} + + {{yield}} + +{{/let}} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/checkbox.ts b/packages/components/src/components/hds/filter-bar/checkbox.ts new file mode 100644 index 00000000000..8bee0ba9386 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/checkbox.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import type { WithBoundArgs } from '@glint/template'; + +import type { HdsFilterBarSelectionFilter } from './types.ts'; + +import HdsDropdownListItemCheckbox from '../dropdown/list-item/checkbox.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export interface HdsFilterBarCheckboxSignature { + Args: HdsDropdownSignature['Args'] & { + checkbox?: WithBoundArgs; + value?: string; + keyFilter: HdsFilterBarSelectionFilter[] | undefined; + onChange?: (event: Event) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarCheckbox extends Component { + @action + onChange(event: Event): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(event); + } + } + + get isChecked(): boolean { + const { keyFilter, value } = this.args; + if (Array.isArray(keyFilter)) { + return keyFilter.some((filter) => filter.value === value); + } + return false; + } +} diff --git a/packages/components/src/components/hds/filter-bar/dropdown.hbs b/packages/components/src/components/hds/filter-bar/dropdown.hbs new file mode 100644 index 00000000000..adc686499b4 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/dropdown.hbs @@ -0,0 +1,52 @@ +{{! @glint-nocheck }} + + + + + {{#if (eq @type "range")}} + + {{else}} + {{yield + (hash + Checkbox=(component + "hds/filter-bar/checkbox" checkbox=D.Checkbox keyFilter=this.internalFilters onChange=this.onSelectionChange + ) + Radio=(component + "hds/filter-bar/radio" radio=D.Radio keyFilter=this.internalFilters onChange=this.onSelectionChange + ) + ) + }} + {{/if}} + {{#unless @isLiveFilter}} + + + + + + + {{/unless}} + + \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/dropdown.ts b/packages/components/src/components/hds/filter-bar/dropdown.ts new file mode 100644 index 00000000000..3234a4cacfd --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/dropdown.ts @@ -0,0 +1,293 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import type { WithBoundArgs } from '@glint/template'; + +import HdsDropdown from '../dropdown/index.ts'; + +import HdsFilterBarCheckbox from './checkbox.ts'; +import HdsFilterBarRadio from './radio.ts'; + +import type { + HdsFilterBarFilter, + HdsFilterBarFilters, + HdsFilterBarFilterType, + HdsFilterBarData, + HdsFilterBarSelectionFilter, + HdsFilterBarRangeFilter, + HdsFilterBarRangeFilterSelector, +} from './types.ts'; +import { SELECTORS_DISPLAY_SYMBOL } from './range.ts'; + +export interface HdsFilterBarDropdownSignature { + Args: { + dropdown?: WithBoundArgs; + key: string; + text?: string; + type?: HdsFilterBarFilterType; + filters: HdsFilterBarFilters; + isMultiSelect?: boolean; + isLiveFilter?: boolean; + activeFilterableColumns?: string[]; + searchEnabled?: boolean; + onChange: (key: string, keyFilter?: HdsFilterBarFilter) => void; + }; + Blocks: { + default: [ + { + Checkbox?: WithBoundArgs< + typeof HdsFilterBarCheckbox, + 'checkbox' | 'keyFilter' | 'onChange' + >; + Radio?: WithBoundArgs< + typeof HdsFilterBarRadio, + 'radio' | 'keyFilter' | 'onChange' + >; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarDropdown extends Component { + @tracked internalFilters: HdsFilterBarData | undefined = []; + + private _setUpDropdown = modifier(() => { + if (this.keyFilter) { + this.internalFilters = JSON.parse( + JSON.stringify(this.keyFilter) + ) as HdsFilterBarData; + } + }); + + get type(): HdsFilterBarFilterType { + const { type } = this.args; + + if (!type) { + return 'multi-select'; + } + return type; + } + + get keyFilter(): HdsFilterBarData | undefined { + const { filters, key } = this.args; + + if (!filters) { + return undefined; + } + return filters[key]?.data; + } + + @action + onSelectionChange(event: Event): void { + const addFilter = (value: unknown): void => { + const newFilter = { + text: value as string, + value: value, + } as HdsFilterBarSelectionFilter; + if (this.type === 'single-select') { + this.internalFilters = newFilter; + } else { + if (Array.isArray(this.internalFilters)) { + this.internalFilters.push(newFilter); + } else { + this.internalFilters = [newFilter]; + } + } + }; + + const removeFilter = (value: string): void => { + if (this.type === 'single-select') { + this.internalFilters = undefined; + } else { + if (Array.isArray(this.internalFilters)) { + const newFilter = [] as HdsFilterBarSelectionFilter[]; + this.internalFilters.forEach((filter) => { + if (filter.value != value) { + newFilter.push(filter); + } + }); + this.internalFilters = newFilter; + } else { + this.internalFilters = []; + } + } + }; + + const input = event.target as HTMLInputElement; + + if (input.checked) { + addFilter(input.value); + } else { + removeFilter(input.value); + } + + if (this.args.isLiveFilter) { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + } + + @action + onRangeChange( + selector?: HdsFilterBarRangeFilterSelector, + value?: number + ): void { + const addFilter = (): HdsFilterBarData => { + const newFilter = { + selector: selector, + value: value, + } as HdsFilterBarRangeFilter; + return newFilter; + }; + + if (selector && value) { + this.internalFilters = addFilter(); + } else { + this.internalFilters = undefined; + } + + if (this.args.isLiveFilter) { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + } + } + + @action + onApply(closeDropdown?: () => void): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, this.formattedFilters); + } + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + } + + @action + onClear(closeDropdown?: () => void): void { + this._clearFilters(); + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + } + + @action + onDismiss(): void { + this._clearFilters(); + } + + get formattedFilters(): HdsFilterBarFilter | undefined { + if ( + this.internalFilters === undefined || + (Array.isArray(this.internalFilters) && this.internalFilters.length === 0) + ) { + return undefined; + } + return { + type: this.type, + data: this.internalFilters, + } as HdsFilterBarFilter; + } + + get toggleButtonText(): string { + const { key, filters, text } = this.args; + + let displayText = key; + if (text && text.length > 0) { + displayText = text; + } + + const keyFilter = filters[key]; + + if (!filters || !keyFilter || !keyFilter.data) { + return displayText; + } else if (this.args.type === 'range') { + return `${displayText} ${this._rangeFilterText(keyFilter.data)}`; + } else if (this.args.type === 'single-select') { + return `${displayText}: ${this._singleSelectFilterText(keyFilter.data)}`; + } else { + return `${displayText}: ${this._multiSelectFilterText(keyFilter.data)}`; + } + } + + private _rangeFilterText(filterData: HdsFilterBarData): string { + if ('selector' in filterData && 'value' in filterData) { + return `${SELECTORS_DISPLAY_SYMBOL[filterData.selector]} ${filterData.value}`; + } else { + return ''; + } + } + + private _singleSelectFilterText(filterData: HdsFilterBarData): string { + if ('value' in filterData) { + return filterData.value as string; + } else { + return ''; + } + } + + private _multiSelectFilterText(filterData: HdsFilterBarData): string { + if (Array.isArray(filterData) && filterData.length > 0) { + const charMax = 10; + let filtersString = ''; + + filtersString = filterData + .map((filter) => { + if ('text' in filter && typeof filter.text === 'string') { + if (filter.text.length > charMax) { + return filter.text.slice(0, charMax) + '...'; + } + return filter.text; + } + return ''; + }) + .join(', '); + + return filtersString; + } else { + return ''; + } + } + + get classNames(): string { + const classes = ['hds-filter-bar__dropdown']; + + // add a class based on the @align argument + if (!this._isActiveFilterableColumn()) { + classes.push('hds-filter-bar__dropdown--hidden'); + } + + classes.push(`hds-filter-bar__dropdown--type-${this.type}`); + + return classes.join(' '); + } + + private _isActiveFilterableColumn(): boolean { + if (this.args.activeFilterableColumns) { + return this.args.activeFilterableColumns.includes(this.args.key); + } + return false; + } + + private _clearFilters(): void { + this.internalFilters = undefined; + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.args.key, undefined); + } + } +} diff --git a/packages/components/src/components/hds/filter-bar/filters-checkbox.hbs b/packages/components/src/components/hds/filter-bar/filters-checkbox.hbs new file mode 100644 index 00000000000..6d2e3ecd56f --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filters-checkbox.hbs @@ -0,0 +1,5 @@ +{{#let @checkbox as |Checkbox|}} + + {{yield}} + +{{/let}} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filters-checkbox.ts b/packages/components/src/components/hds/filter-bar/filters-checkbox.ts new file mode 100644 index 00000000000..1c8692c9687 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filters-checkbox.ts @@ -0,0 +1,40 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import type { WithBoundArgs } from '@glint/template'; + +import HdsDropdownListItemCheckbox from '../dropdown/list-item/checkbox.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export interface HdsAdvancedTableFilterBarFiltersCheckboxSignature { + Args: HdsDropdownSignature['Args'] & { + checkbox?: WithBoundArgs; + value?: string; + activeFilterableColumns?: string[]; + onChange?: (event: Event) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsAdvancedTableFilterBarFiltersCheckbox extends Component { + @action + onChange(event: Event): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(event); + } + } + + get isChecked(): boolean { + const { value, activeFilterableColumns } = this.args; + return activeFilterableColumns?.includes(value || '') || false; + } +} diff --git a/packages/components/src/components/hds/filter-bar/filters-dropdown.hbs b/packages/components/src/components/hds/filter-bar/filters-dropdown.hbs new file mode 100644 index 00000000000..21f94aab149 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filters-dropdown.hbs @@ -0,0 +1,26 @@ +{{! @glint-nocheck }} + + + {{yield + (hash + Checkbox=(component + "hds/filter-bar/filters-checkbox" + checkbox=D.Checkbox + activeFilterableColumns=this.internalFilterableColumns + onChange=this.onChange + ) + ) + }} + + + + + + + \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/filters-dropdown.ts b/packages/components/src/components/hds/filter-bar/filters-dropdown.ts new file mode 100644 index 00000000000..a9d5fe5e2f5 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/filters-dropdown.ts @@ -0,0 +1,98 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { modifier } from 'ember-modifier'; +import type { WithBoundArgs } from '@glint/template'; + +import HdsDropdown from './../dropdown/index.ts'; +import HdsFilterBarFiltersCheckbox from './filters-checkbox.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export interface HdsFilterBarFiltersDropdownSignature { + Args: HdsDropdownSignature['Args'] & { + dropdown?: WithBoundArgs; + activeFilterableColumns?: string[]; + onChange: (filterableColumns: string[]) => void; + }; + Blocks: { + default: [ + { + Checkbox?: WithBoundArgs< + typeof HdsFilterBarFiltersCheckbox, + 'checkbox' | 'onChange' | 'activeFilterableColumns' + >; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarFiltersDropdown extends Component< + HdsDropdownSignature & HdsFilterBarFiltersDropdownSignature +> { + @tracked internalFilterableColumns: string[] = []; + + private _updateInternalFilterableColumns = modifier(() => { + const { activeFilterableColumns } = this.args; + + if (activeFilterableColumns) { + this.internalFilterableColumns = activeFilterableColumns; + } else { + this.internalFilterableColumns = []; + } + }); + + @action + onChange(event: Event): void { + const input = event.target as HTMLInputElement; + + if (input.checked) { + this.internalFilterableColumns = [ + ...this.internalFilterableColumns, + input.value, + ]; + } else { + this.internalFilterableColumns = this.internalFilterableColumns?.filter( + (col) => col !== input.value + ); + } + } + + @action + onApply(closeDropdown?: () => void): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.internalFilterableColumns); + } + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + } + + @action + onClear(closeDropdown?: () => void): void { + this.internalFilterableColumns = []; + + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this.internalFilterableColumns); + } + + if (closeDropdown && typeof closeDropdown === 'function') { + closeDropdown(); + } + } + + get classNames(): string { + const classes = ['hds-filter-bar__filters-dropdown']; + + return classes.join(' '); + } +} diff --git a/packages/components/src/components/hds/filter-bar/index.hbs b/packages/components/src/components/hds/filter-bar/index.hbs new file mode 100644 index 00000000000..7a3f9303d03 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/index.hbs @@ -0,0 +1,59 @@ +
    +
    + {{#if @hasSearch}} + + {{/if}} +
    + {{yield (hash ActionsDropdown=(component "hds/dropdown"))}} + +
    +
    + {{#if this.showFilters}} +
    + {{yield + (hash + Dropdown=(component + "hds/filter-bar/dropdown" + onChange=this.onFilter + filters=@filters + isLiveFilter=@isLiveFilter + activeFilterableColumns=this.activeFilterableColumns + ) + ) + }} + {{yield + (hash + FiltersDropdown=(component + "hds/filter-bar/filters-dropdown" + activeFilterableColumns=this.activeFilterableColumns + onChange=this.onFiltersChange + ) + ) + }} + {{#if this.hasActiveFilters}} + + {{/if}} +
    + {{/if}} +
    \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/index.ts b/packages/components/src/components/hds/filter-bar/index.ts new file mode 100644 index 00000000000..04679f53232 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/index.ts @@ -0,0 +1,214 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import { schedule } from '@ember/runloop'; +import { modifier } from 'ember-modifier'; + +import type { ComponentLike, WithBoundArgs } from '@glint/template'; +import type Owner from '@ember/owner'; + +import type { HdsFilterBarFilter, HdsFilterBarFilters } from './types.ts'; +import HdsDropdown from '../dropdown/index.ts'; +import HdsFilterBarDropdown from './dropdown.ts'; +import HdsFilterBarFiltersDropdown from './filters-dropdown.ts'; + +export interface HdsFilterBarSignature { + Args: { + filters: HdsFilterBarFilters; + visibleFilterableColumns?: string[]; + isLiveFilter?: boolean; + hasSearch?: boolean; + showFilters?: boolean; + onFilter?: (filters: HdsFilterBarFilters) => void; + onSearch?: (event: Event) => void; + }; + Blocks: { + default?: [ + { + ActionsDropdown?: ComponentLike; + FiltersDropdown?: WithBoundArgs< + typeof HdsFilterBarFiltersDropdown, + 'onChange' + >; + Dropdown?: WithBoundArgs< + typeof HdsFilterBarDropdown, + 'onChange' | 'filters' | 'isLiveFilter' + >; + }, + ]; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBar extends Component { + @tracked visibleFilterableColumns: string[] = []; + @tracked showFilters: boolean = true; + + private _element!: HTMLDivElement; + private _filtersDropdownToggleElement!: HTMLDivElement; + + private _setUpFilterBar = modifier((element: HTMLDivElement) => { + this._element = element; + + this._filtersDropdownToggleElement = element.querySelector( + '.hds-filter-bar__filters-dropdown .hds-dropdown-toggle-button' + ) as HTMLDivElement; + + return () => {}; + }); + + constructor(owner: Owner, args: HdsFilterBarSignature['Args']) { + super(owner, args); + + const { filters, visibleFilterableColumns, showFilters } = args; + + console.log('Initializing FilterBar with filters:', filters); + + if (showFilters != null) { + this.showFilters = showFilters; + } + + if (visibleFilterableColumns) { + this.visibleFilterableColumns = [...visibleFilterableColumns]; + } + + Object.keys(filters).forEach((k) => { + if (!this.activeFilterableColumns.includes(k)) { + this.activeFilterableColumns.push(k); + } + }); + } + + get hasActiveFilters(): boolean { + return Object.keys(this.args.filters).length > 0; + } + + get activeFilterableColumns(): string[] { + const { filters } = this.args; + const columns: string[] = []; + + Object.keys(filters).forEach((k) => { + columns.push(k); + }); + + return columns.concat(this.visibleFilterableColumns); + } + + @action + onFilter(key: string, keyFilter?: HdsFilterBarFilter): void { + this._triggerFilter(key, keyFilter); + } + + @action + onFiltersChange(visibleFilterableColumns: string[]): void { + const { filters } = this.args; + + this.visibleFilterableColumns = visibleFilterableColumns; + + Object.keys(filters).forEach((k) => { + if (!this.activeFilterableColumns.includes(k)) { + this._triggerFilter(k); + } + }); + + let filterKeyToOpen: string | undefined = undefined; + this.activeFilterableColumns.forEach((k) => { + if (!filters[k]) { + filterKeyToOpen = k; + } + }); + + console.log('Filter key to open:', filterKeyToOpen); + if (filterKeyToOpen) { + // eslint-disable-next-line ember/no-runloop + schedule('afterRender', (): void => { + this._triggerDropdownOpen(filterKeyToOpen as string); + }); + } + } + + @action + clearFilters(): void { + this.visibleFilterableColumns = []; + const { onFilter } = this.args; + if (onFilter && typeof onFilter === 'function') { + onFilter({}); + } + + this._filtersDropdownToggleElement.focus(); + } + + @action + onSearch(event: Event): void { + const { onSearch } = this.args; + if (onSearch && typeof onSearch === 'function') { + onSearch(event); + } + } + + @action + toggleFilters(): void { + this.showFilters = !this.showFilters; + } + + private _triggerFilter(key: string, keyFilter?: HdsFilterBarFilter): void { + const newFilters = this._updateFilter(key, keyFilter); + + const { onFilter } = this.args; + if (onFilter && typeof onFilter === 'function') { + onFilter(newFilters); + } + } + + private _updateFilter( + key: string, + keyFilter?: HdsFilterBarFilter + ): HdsFilterBarFilters { + const { filters } = this.args; + const newFilters = {} as HdsFilterBarFilters; + + Object.keys(filters).forEach((k) => { + newFilters[k] = JSON.parse( + JSON.stringify(filters[k]) + ) as HdsFilterBarFilter; + }); + if ( + keyFilter === undefined || + (Array.isArray(keyFilter) && keyFilter.length === 0) + ) { + delete newFilters[key]; + this.visibleFilterableColumns = this.visibleFilterableColumns.filter( + (colKey) => colKey !== key + ); + // Focus back on the filters dropdown toggle after removing a filter + this._filtersDropdownToggleElement.focus(); + } else { + Object.assign(newFilters, { [key]: keyFilter }); + } + + return { ...newFilters }; + } + + private _triggerDropdownOpen(key: string): void { + const dropdownElement = this._element.querySelector( + `.hds-filter-bar__dropdown[data-filter-key="${key}"]` + ) as HTMLElement; + console.log('Triggering dropdown open for key:', key, dropdownElement); + + if (dropdownElement) { + const toggleButton = dropdownElement.querySelector( + '.hds-dropdown-toggle-button' + ) as HTMLElement; + + if (toggleButton) { + toggleButton.focus(); + toggleButton.click(); + } + } + } +} diff --git a/packages/components/src/components/hds/filter-bar/radio.hbs b/packages/components/src/components/hds/filter-bar/radio.hbs new file mode 100644 index 00000000000..3cadd013836 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/radio.hbs @@ -0,0 +1,5 @@ +{{#let @radio as |Radio|}} + + {{yield}} + +{{/let}} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/radio.ts b/packages/components/src/components/hds/filter-bar/radio.ts new file mode 100644 index 00000000000..8bfc7e7ef2b --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/radio.ts @@ -0,0 +1,45 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import type { WithBoundArgs } from '@glint/template'; + +import type { HdsFilterBarSelectionFilter } from './types.ts'; + +import HdsDropdownListItemRadio from '../dropdown/list-item/radio.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export interface HdsFilterBarRadioSignature { + Args: HdsDropdownSignature['Args'] & { + radio?: WithBoundArgs; + value?: string; + keyFilter: HdsFilterBarSelectionFilter | undefined; + onChange?: (event: Event) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarRadio extends Component { + @action + onChange(event: Event): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(event); + } + } + + get isChecked(): boolean { + const { keyFilter, value } = this.args; + if (keyFilter && value) { + return keyFilter.value === value; + } + return false; + } +} diff --git a/packages/components/src/components/hds/filter-bar/range.hbs b/packages/components/src/components/hds/filter-bar/range.hbs new file mode 100644 index 00000000000..968859cb4af --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/range.hbs @@ -0,0 +1,26 @@ +{{#let @generic as |Generic|}} + + + Number is + + + + + + {{#each this._selectorValues as |selectorValue|}} + + {{/each}} + + + + + +{{/let}} \ No newline at end of file diff --git a/packages/components/src/components/hds/filter-bar/range.ts b/packages/components/src/components/hds/filter-bar/range.ts new file mode 100644 index 00000000000..76844ed73e6 --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/range.ts @@ -0,0 +1,112 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +import Component from '@glimmer/component'; +import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; +import type Owner from '@ember/owner'; +import type { WithBoundArgs } from '@glint/template'; +import { guidFor } from '@ember/object/internals'; + +import type { + HdsFilterBarRangeFilter, + HdsFilterBarRangeFilterSelector, +} from './types.ts'; +import { HdsFilterBarRangeFilterSelectorValues } from './types.ts'; + +import HdsDropdownListItemGeneric from '../dropdown/list-item/generic.ts'; + +import type { HdsDropdownSignature } from '../dropdown/index.ts'; + +export const SELECTORS: HdsFilterBarRangeFilterSelector[] = Object.values( + HdsFilterBarRangeFilterSelectorValues +); + +export const SELECTORS_DISPLAY_TEXT: Record< + HdsFilterBarRangeFilterSelectorValues, + string +> = { + [HdsFilterBarRangeFilterSelectorValues.lessThan]: 'Less than', + [HdsFilterBarRangeFilterSelectorValues.lessThanOrEqualTo]: + 'Less than or equal to', + [HdsFilterBarRangeFilterSelectorValues.equalTo]: 'Equal to', + [HdsFilterBarRangeFilterSelectorValues.greaterThanOrEqualTo]: + 'Greater than or equal to', + [HdsFilterBarRangeFilterSelectorValues.greaterThan]: 'Greater than', +}; + +export const SELECTORS_DISPLAY_SYMBOL: Record< + HdsFilterBarRangeFilterSelectorValues, + string +> = { + [HdsFilterBarRangeFilterSelectorValues.lessThan]: '<', + [HdsFilterBarRangeFilterSelectorValues.lessThanOrEqualTo]: '<=', + [HdsFilterBarRangeFilterSelectorValues.equalTo]: '=', + [HdsFilterBarRangeFilterSelectorValues.greaterThanOrEqualTo]: '>=', + [HdsFilterBarRangeFilterSelectorValues.greaterThan]: '>', +}; + +export interface HdsFilterBarRangeSignature { + Args: HdsDropdownSignature['Args'] & { + generic?: WithBoundArgs; + keyFilter: HdsFilterBarRangeFilter | undefined; + onChange?: ( + selector?: HdsFilterBarRangeFilterSelector, + value?: number + ) => void; + }; + Blocks: { + default: []; + }; + Element: HTMLDivElement; +} + +export default class HdsFilterBarRange extends Component { + @tracked private _selector: HdsFilterBarRangeFilterSelector | undefined; + @tracked private _value: number | undefined; + + private _selectorValues = SELECTORS; + private _selectorInputId = 'selector-input-' + guidFor(this); + private _valueInputId = 'value-input-' + guidFor(this); + + constructor(owner: Owner, args: HdsFilterBarRangeSignature['Args']) { + super(owner, args); + + const { keyFilter } = this.args; + if (keyFilter) { + this._selector = keyFilter.selector; + this._value = keyFilter.value; + } + } + + get stringValue(): string | undefined { + return this._value !== undefined ? this._value.toString() : undefined; + } + + selectorText(selector: HdsFilterBarRangeFilterSelector): string { + return SELECTORS_DISPLAY_TEXT[selector]; + } + + @action + onSelectorChange(event: Event): void { + const select = event.target as HTMLSelectElement; + this._selector = select.value as HdsFilterBarRangeFilterSelector; + this._onChange(); + } + + @action + onValueChange(event: Event): void { + const input = event.target as HTMLInputElement; + this._value = parseFloat(input.value); + this._onChange(); + } + + private _onChange(): void { + const { onChange } = this.args; + if (onChange && typeof onChange === 'function') { + onChange(this._selector, this._value); + } + } +} diff --git a/packages/components/src/components/hds/filter-bar/types.ts b/packages/components/src/components/hds/filter-bar/types.ts new file mode 100644 index 00000000000..7318b479a4e --- /dev/null +++ b/packages/components/src/components/hds/filter-bar/types.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +export interface HdsFilterBarSelectionFilter { + text: string; + value: unknown; +} + +export enum HdsFilterBarRangeFilterSelectorValues { + lessThan = 'less-than', + lessThanOrEqualTo = 'less-than-or-equal-to', + equalTo = 'equal-to', + greaterThanOrEqualTo = 'greater-than-or-equal-to', + greaterThan = 'greater-than', +} + +export type HdsFilterBarRangeFilterSelector = + `${HdsFilterBarRangeFilterSelectorValues}`; + +export interface HdsFilterBarRangeFilter { + selector: HdsFilterBarRangeFilterSelector; + value: number; +} + +export enum HdsFilterBarFilterTypeValues { + multiSelect = 'multi-select', + singleSelect = 'single-select', + range = 'range', +} + +export type HdsFilterBarFilterType = `${HdsFilterBarFilterTypeValues}`; + +export type HdsFilterBarData = + | HdsFilterBarSelectionFilter[] + | HdsFilterBarSelectionFilter + | HdsFilterBarRangeFilter; + +export interface HdsFilterBarFilter { + type?: HdsFilterBarFilterType; + data?: HdsFilterBarData; +} + +// export interface HdsFilterBarFilters { +// [name: string]: HdsFilterBarFilter[] | HdsFilterBarFilter | undefined; +// } + +export interface HdsFilterBarFilters { + [name: string]: HdsFilterBarFilter; +} diff --git a/packages/components/src/styles/@hashicorp/design-system-components.scss b/packages/components/src/styles/@hashicorp/design-system-components.scss index 00c6008b820..dbf9b828cc7 100644 --- a/packages/components/src/styles/@hashicorp/design-system-components.scss +++ b/packages/components/src/styles/@hashicorp/design-system-components.scss @@ -33,6 +33,7 @@ @use "../components/disclosure-primitive"; @use "../components/dismiss-button"; @use "../components/dropdown"; +@use "../components/filter-bar"; @use "../components/flyout"; @use "../components/form"; // multiple components @use "../components/icon"; diff --git a/packages/components/src/styles/components/advanced-table.scss b/packages/components/src/styles/components/advanced-table.scss index 75936fadddd..fdd3679bcc3 100644 --- a/packages/components/src/styles/components/advanced-table.scss +++ b/packages/components/src/styles/components/advanced-table.scss @@ -415,7 +415,8 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); } } -.hds-advanced-table__th-context-menu .hds-dropdown-toggle-icon { +.hds-advanced-table__th-context-menu .hds-dropdown-toggle-icon, +.hds-advanced-table__th-filter-menu .hds-dropdown-toggle-icon { width: $hds-advanced-table-button-size; height: $hds-advanced-table-button-size; margin: -2px 0; @@ -504,6 +505,25 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); align-self: flex-start; } +.hds-advanced-table__th-filter-menu--active { + position: relative; + + &::before { + position: absolute; + top: -4px; + right: -4px; + width: 6px; + height: 6px; + background-color: var(--token-color-foreground-action); + border-radius: 50%; + content: ""; + } +} + +.hds-advanced-table__clear-filters-button { + margin-bottom: 16px; +} + // ---------------------------------------------------------------- // TABLE BODY @@ -745,3 +765,49 @@ $hds-advanced-table-drag-preview-background-color: rgba(204, 227, 254, 30%); border-radius: var(--token-border-radius-medium); box-shadow: var(--token-elevation-mid-box-shadow); } + +// ---------------------------------------------------------------- + +// FILTER BAR +.hds-advanced-table__actions .hds-filter-bar { + border-bottom: none; + border-radius: $hds-advanced-table-border-radius $hds-advanced-table-border-radius 0 0; +} + +.hds-advanced-table__actions + .hds-advanced-table__container { + border-top-left-radius: 0; + border-top-right-radius: 0; + + .hds-advanced-table__thead .hds-advanced-table__tr:first-of-type .hds-advanced-table__th { + &:first-child { + border-top-left-radius: 0; + } + + &:last-child { + border-top-right-radius: 0; + } + } +} + +/// ---------------------------------------------------------------- + +// EMPTY STATE +.hds-advanced-table__empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 400px; + background-color: var(--token-color-surface-primary); + border: 1px solid var(--token-color-border-primary); + border-bottom-right-radius: $hds-advanced-table-border-radius; + border-bottom-left-radius: $hds-advanced-table-border-radius; +} + +.hds-advanced-table__empty-state__content { + max-width: 450px; +} + +.hds-advanced-table:not(:has(+ .hds-advanced-table__empty-state)) { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/packages/components/src/styles/components/dropdown.scss b/packages/components/src/styles/components/dropdown.scss index 0c35374cd8b..e82f6a0bb2c 100644 --- a/packages/components/src/styles/components/dropdown.scss +++ b/packages/components/src/styles/components/dropdown.scss @@ -602,3 +602,58 @@ $hds-dropdown-toggle-border-radius: $hds-button-border-radius; vertical-align: bottom; } } + +// SEARCH + +.hds-dropdown__search { + flex: none; + padding: 8px; + border-bottom: 1px solid var(--token-color-border-primary); +} + +// DISMISS BUTTON +.hds-dropdown__dismiss { + color: var(--token-color-foreground-primary); + background-color: var(--token-color-surface-faint); + border: 1px solid var(--token-color-border-strong); + border-right: none; + border-radius: var(--token-border-radius-small) 0 0 var(--token-border-radius-small); + + &:hover, + &.mock-hover { + background-color: var(--token-color-surface-interactive); + cursor: pointer; + } + + &:focus, + &.mock-focus { + @include hds-button-state-focus(); + border-color: var(--token-color-focus-action-internal); + + &::before { + border-color: var(--token-color-focus-action-external); + } + } + + &:active, + &.mock-active { + background-color: var(--token-color-surface-interactive-active); + border-color: var(--token-color-border-strong); + + &::before { + border-color: transparent; + } + } +} + +.hds-dropdown--has-dismiss { + display: flex; + + .hds-dropdown-toggle-button { + border-left: none; + + border-top-left-radius: 0; + border-bottom-left-radius: 0; + box-shadow: none; + } +} diff --git a/packages/components/src/styles/components/filter-bar.scss b/packages/components/src/styles/components/filter-bar.scss new file mode 100644 index 00000000000..31b2b288b16 --- /dev/null +++ b/packages/components/src/styles/components/filter-bar.scss @@ -0,0 +1,69 @@ +/** + * Copyright (c) HashiCorp, Inc. + * SPDX-License-Identifier: MPL-2.0 + */ + +// +// FILTER BAR +// + +.hds-filter-bar { + display: grid; + gap: 8px; + padding: 8px; + background-color: var(--token-color-surface-faint); + border: 1px solid var(--token-color-border-primary); + border-radius: var(--token-border-radius-medium); + + .hds-filter-bar__filters { + display: flex; + flex-wrap: wrap; + gap: 8px 12px; + align-items: end; + padding-top: 8px; + border-top: 1px solid var(--token-color-border-primary); + + .hds-dropdown__list .hds-form-text-input { + width: auto; + } + } + + .hds-filter-bar__actions { + display: flex; + flex-direction: row; + gap: 8px; + align-items: end; + justify-content: space-between; + } + + .hds-filter-bar__active-filters { + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } +} + +.hds-filter-bar__actions__right { + display: flex; + flex-direction: row; + gap: 8px; +} + +.hds-filter-bar__search { + --token-form-control-padding: 4px; +} + +// DROPDOWN +.hds-filter-bar__dropdown--hidden.hds-segmented-group { + display: none; +} + +.hds-filter-bar__dropdown--type-range .hds-dropdown-list-item { + padding: 8px 16px; +} + +.hds-filter-bar__dropdown--type-range .hds-form-label { + margin-bottom: 4px; +} diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts index 66183aa31c6..06a735ebd98 100644 --- a/packages/components/src/template-registry.ts +++ b/packages/components/src/template-registry.ts @@ -97,6 +97,13 @@ import type HdsDropdownListItemTitleComponent from './components/hds/dropdown/li import type HdsDropdownToggleButtonComponent from './components/hds/dropdown/toggle/button'; import type HdsDropdownToggleChevronComponent from './components/hds/dropdown/toggle/chevron'; import type HdsDropdownToggleIconComponent from './components/hds/dropdown/toggle/icon'; +import type HdsFilterBarComponent from './components/hds/filter-bar'; +import type HdsFilterBarDropdownComponent from './components/hds/filter-bar/dropdown'; +import type HdsFilterBarCheckboxComponent from './components/hds/filter-bar/checkbox'; +import type HdsFilterBarRadioComponent from './components/hds/filter-bar/radio'; +import type HdsFilterBarFiltersDropdownComponent from './components/hds/filter-bar/filters-dropdown'; +import type HdsFilterBarFiltersCheckboxComponent from './components/hds/filter-bar/filters-checkbox'; +import type HdsFilterBarRangeComponent from './components/hds/filter-bar/range'; import type HdsFlyoutBodyComponent from './components/hds/flyout/body'; import type HdsFlyoutDescriptionComponent from './components/hds/flyout/description'; import type HdsFlyoutFooterComponent from './components/hds/flyout/footer'; @@ -566,6 +573,22 @@ export default interface HdsComponentsRegistry { 'Hds::Dropdown::Toggle::Icon': typeof HdsDropdownToggleIconComponent; 'hds/dropdown/toggle/icon': typeof HdsDropdownToggleIconComponent; + // Filter Bar + 'Hds::FilterBar': typeof HdsFilterBarComponent; + 'hds/filter-bar': typeof HdsFilterBarComponent; + 'Hds::FilterBar::Dropdown': typeof HdsFilterBarDropdownComponent; + 'hds/filter-bar/dropdown': typeof HdsFilterBarDropdownComponent; + 'Hds::FilterBar::Checkbox': typeof HdsFilterBarCheckboxComponent; + 'hds/filter-bar/checkbox': typeof HdsFilterBarCheckboxComponent; + 'Hds::FilterBar::Radio': typeof HdsFilterBarRadioComponent; + 'hds/filter-bar/radio': typeof HdsFilterBarRadioComponent; + 'Hds::FilterBar::FiltersDropdown': typeof HdsFilterBarFiltersDropdownComponent; + 'hds/filter-bar/filters-dropdown': typeof HdsFilterBarFiltersDropdownComponent; + 'Hds::FilterBar::FiltersCheckbox': typeof HdsFilterBarFiltersCheckboxComponent; + 'hds/filter-bar/filters-checkbox': typeof HdsFilterBarFiltersCheckboxComponent; + 'Hds::FilterBar::Range': typeof HdsFilterBarRangeComponent; + 'hds/filter-bar/range': typeof HdsFilterBarRangeComponent; + // Flyout 'Hds::Flyout': typeof HdsFlyoutComponent; 'hds/flyout': typeof HdsFlyoutComponent; diff --git a/showcase/app/components/mock/app/main/generic-advanced-table.gts b/showcase/app/components/mock/app/main/generic-advanced-table.gts index c238ae7606e..c0a6d420ed3 100644 --- a/showcase/app/components/mock/app/main/generic-advanced-table.gts +++ b/showcase/app/components/mock/app/main/generic-advanced-table.gts @@ -4,111 +4,35 @@ */ import Component from '@glimmer/component'; import { action } from '@ember/object'; +import { tracked } from '@glimmer/tracking'; import { deepTracked } from 'ember-deep-tracked'; import { get } from '@ember/helper'; +import { on } from '@ember/modifier'; +import style from 'ember-style-modifier/modifiers/style'; // HDS components import { HdsAdvancedTable, + HdsButton, + HdsFilterBar, + HdsLayoutFlex, HdsLinkInline, HdsBadge, HdsBadgeColorValues, + HdsFormToggleField, + HdsTextBody, + HdsTextDisplay, type HdsAdvancedTableOnSelectionChangeSignature, + type HdsFilterBarRangeFilterSelector, } from '@hashicorp/design-system-components/components'; import type { HdsAdvancedTableSignature } from '@hashicorp/design-system-components/components/hds/advanced-table/index'; +import type { HdsFilterBarSignature } from '@hashicorp/design-system-components/components/hds/filter-bar/index'; export interface MockAppMainGenericAdvancedTableSignature { Element: HTMLDivElement; } -const SAMPLE_COLUMNS = [ - { - isSortable: true, - label: 'Name', - key: 'name', - width: 'max-content', - }, - { - label: 'Project name', - key: 'project-name', - isSortable: true, - width: 'max-content', - }, - { - label: 'Current run ID', - key: 'current-run-id', - isSortable: true, - width: 'max-content', - }, - { - label: 'Run status', - key: 'run-status', - isSortable: true, - width: 'max-content', - }, - { - label: 'Current run applied', - key: 'current-run-applied', - isSortable: true, - width: 'max-content', - }, - { - label: 'VCS repo', - key: 'vcs-repo', - isSortable: true, - width: 'max-content', - }, - { - label: 'Module count', - key: 'module-count', - isSortable: true, - width: 'max-content', - }, - { - label: 'Modules', - key: 'modules', - isSortable: true, - width: 'max-content', - }, - { - label: 'Provider count', - key: 'provider-count', - isSortable: true, - width: 'max-content', - }, - { - label: 'Providers', - key: 'providers', - isSortable: true, - width: 'max-content', - }, - { - label: 'Terraform version', - key: 'terraform-version', - isSortable: true, - width: 'max-content', - }, - { - label: 'State terraform version', - key: 'state-terraform-version', - isSortable: true, - width: 'max-content', - }, - { - label: 'Created', - key: 'created', - isSortable: true, - width: 'max-content', - }, - { - label: 'Updated', - key: 'updated', - isSortable: true, - width: 'max-content', - }, -]; - const SAMPLE_MODEL = [ { name: 'zoguve-guw-mannaz', @@ -151,7 +75,7 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:08:14 am', - 'vcs-repo': 'example/sClKKTBbyCIzf@d8NxH2', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 31, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 42, @@ -168,7 +92,7 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 09:07:14 am', - 'vcs-repo': 'example/y0^(Nm*63', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 58, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 140, @@ -185,7 +109,7 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:06:14 am', - 'vcs-repo': 'example/ljPWe[4', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 32, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 50, @@ -202,7 +126,7 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:05:14 am', - 'vcs-repo': 'example/E*fcS4mn@BoDgZu0O5', + 'vcs-repo': 'example/a))!hzfpKcBl0', 'module-count': 94, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 113, @@ -236,13 +160,13 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:03:14 am', - 'vcs-repo': 'example/(DCFjSEKcBuU44J8AB87', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 114, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 107, providers: 'susnup-da-zuw', 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'state-terraform-version': '0.16.0', created: 'Feb 27 2025', updated: 'Feb 27 2025', }, @@ -253,13 +177,13 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 09:02:14 am', - 'vcs-repo': 'example/9YURY8', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 106, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 185, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 26 2025', updated: 'Feb 26 2025', }, @@ -270,13 +194,13 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 09:01:14 am', - 'vcs-repo': 'example/9YURY8', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 124, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 175, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 25 2025', updated: 'Feb 25 2025', }, @@ -287,13 +211,13 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 09:00:14 am', - 'vcs-repo': 'example/d2s3B46I10', + 'vcs-repo': 'example/&j[RmmtjpQX6', 'module-count': 70, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 168, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 24 2025', updated: 'Feb 24 2025', }, @@ -309,8 +233,8 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 168, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 23 2025', updated: 'Feb 23 2025', }, @@ -321,13 +245,13 @@ const SAMPLE_MODEL = [ 'run-status': 'errored', 'run-status-color': HdsBadgeColorValues.Critical, 'current-run-applied': 'Mar 06, 2025 08:59:14 am', - 'vcs-repo': 'example/v@C6&hBTou11', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 106, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 61, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', - 'state-terraform-version': '0.15.0', + 'terraform-version': '0.14.5', + 'state-terraform-version': '0.16.0', created: 'Feb 22 2025', updated: 'Feb 22 2025', }, @@ -338,12 +262,12 @@ const SAMPLE_MODEL = [ 'run-status': 'applied', 'run-status-color': HdsBadgeColorValues.Success, 'current-run-applied': 'Mar 06, 2025 08:58:14 am', - 'vcs-repo': 'example/@t23^12', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 14, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 143, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 21 2025', updated: 'Feb 21 2025', @@ -355,12 +279,12 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 08:58:14 am', - 'vcs-repo': 'example/@t23^12', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 14, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 143, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 20 2025', updated: 'Feb 20 2025', @@ -377,7 +301,7 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 98, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 19 2025', updated: 'Feb 19 2025', @@ -394,7 +318,7 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 170, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 18 2025', updated: 'Feb 18 2025', @@ -406,12 +330,12 @@ const SAMPLE_MODEL = [ 'run-status': 'planned', 'run-status-color': HdsBadgeColorValues.Warning, 'current-run-applied': 'Mar 06, 2025 08:57:14 am', - 'vcs-repo': 'example/t*vN3@*BxJnG116', + 'vcs-repo': 'example/d2s3B46I10', 'module-count': 139, modules: 'wad-bedzeaje-rogmejca', 'provider-count': 170, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 17 2025', updated: 'Feb 17 2025', @@ -428,7 +352,7 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 83, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 16 2025', updated: 'Feb 16 2025', @@ -445,7 +369,7 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 152, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 15 2025', updated: 'Feb 15 2025', @@ -462,13 +386,127 @@ const SAMPLE_MODEL = [ modules: 'wad-bedzeaje-rogmejca', 'provider-count': 11, providers: 'susnup-da-zuw', - 'terraform-version': '0.14.0', + 'terraform-version': '0.14.5', 'state-terraform-version': '0.15.0', created: 'Feb 14 2025', updated: 'Feb 14 2025', }, ]; +const SAMPLE_MODEL_VALUES = { + name: Array.from(new Set(SAMPLE_MODEL.map((item) => item['name']))).map( + (value) => ({ value, label: value }), + ), + 'project-name': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['project-name'])), + ).map((value) => ({ value, label: value })), + 'run-status': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['run-status'])), + ).map((value) => ({ value, label: value })), + 'vcs-repo': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['vcs-repo'])), + ).map((value) => ({ value, label: value })), + 'terraform-version': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['terraform-version'])), + ).map((value) => ({ value, label: value })), + 'state-terraform-version': Array.from( + new Set(SAMPLE_MODEL.map((item) => item['state-terraform-version'])), + ).map((value) => ({ value, label: value })), +}; + +const SAMPLE_COLUMNS = [ + { + isSortable: true, + label: 'Name', + key: 'name', + width: 'max-content', + filterType: 'checkbox', + }, + { + label: 'Project name', + key: 'project-name', + isSortable: true, + width: 'max-content', + filterType: 'checkbox', + }, + { + label: 'Current run ID', + key: 'current-run-id', + isSortable: true, + width: 'max-content', + }, + { + label: 'Run status', + key: 'run-status', + isSortable: true, + width: 'max-content', + filterType: 'checkbox', + }, + { + label: 'Current run applied', + key: 'current-run-applied', + isSortable: true, + width: 'max-content', + }, + { + label: 'VCS repo', + key: 'vcs-repo', + isSortable: true, + width: 'max-content', + filterType: 'checkbox', + }, + { + label: 'Module count', + key: 'module-count', + isSortable: true, + width: 'max-content', + }, + { + label: 'Modules', + key: 'modules', + isSortable: true, + width: 'max-content', + }, + { + label: 'Provider count', + key: 'provider-count', + isSortable: true, + width: 'max-content', + }, + { + label: 'Providers', + key: 'providers', + isSortable: true, + width: 'max-content', + }, + { + label: 'Terraform version', + key: 'terraform-version', + isSortable: true, + width: 'max-content', + filterType: 'radio', + }, + { + label: 'State terraform version', + key: 'state-terraform-version', + isSortable: true, + width: 'max-content', + filterType: 'radio', + }, + { + label: 'Created', + key: 'created', + isSortable: true, + width: 'max-content', + }, + { + label: 'Updated', + key: 'updated', + isSortable: true, + width: 'max-content', + }, +]; + const updateModelWithSelectAllState = ( modelData: HdsAdvancedTableSignature['Args']['model'], selectAllState: boolean, @@ -499,6 +537,10 @@ export default class MockAppMainGenericAdvancedTable extends Component { + this.filters = filters; + }; + + onSearch = (event: Event) => { + const value = event.target.value; + if (value.length > 0) { + window.alert(`✅ Search executed with value: ${value}`); + } + }; + + get demoModelFilteredData() { + const filterItem = ( + item: HdsFilterBarSignature['Args']['filters'], + ): boolean => { + if (Object.keys(this.filters).length === 0) return true; + let match = true; + Object.keys(this.filters).forEach((key) => { + const filter = this.filters[key]; + if (filter && filter.data) { + if (filter.type === 'range') { + if (!this.isRangeFilterMatch(item[key], filter.data)) { + match = false; + } + } else if (filter.type === 'single-select') { + if (!this.isSingleSelectFilterMatch(item[key], filter.data)) { + match = false; + } + } else { + if (!this.isMultiSelectFilterMatch(item[key], filter.data)) { + match = false; + } + } + } + }); + return match; + }; + + const filteredData = this.demoModel.filter(filterItem); + this.emptyData = !(filteredData.length > 0); + return filteredData; + } + + isRangeFilterMatch( + itemValue: unknown, + filterData: HdsFilterBarSignature['Args']['filters']['data'], + ): boolean { + const selector = filterData.selector as HdsFilterBarRangeFilterSelector; + const number = Number(itemValue); + + if (isNaN(number)) { + return false; + } else { + switch (selector) { + case 'less-than': + return number < filterData.value; + case 'less-than-or-equal-to': + return number <= filterData.value; + case 'equal-to': + return number === filterData.value; + case 'greater-than-or-equal-to': + return number >= filterData.value; + case 'greater-than': + return number > filterData.value; + default: + return false; + } + } + } + + isSingleSelectFilterMatch( + itemValue: unknown, + filterData: HdsFilterBarSignature['Args']['filters']['data'], + ): boolean { + return itemValue === filterData.value; + } + + isMultiSelectFilterMatch( + itemValue: unknown, + filterData: HdsFilterBarSignature['Args']['filters']['data'], + ): boolean { + const filterValues = filterData.map( + (d: HdsFilterBarSignature['Args']['filters']['data']['value']) => d.value, + ); + return filterValues.includes(itemValue); + } + + clearFilters = () => { + this.filters = {}; + }; + + onLiveFilterToggle = (event: Event) => { + const target = event.target as HTMLInputElement; + this.isLiveFilter = target.checked; + }; + + onSeparatedFilterBar = (event: Event) => { + const target = event.target as HTMLInputElement; + this.isSeparatedFilterBar = target.checked; + }; + }