diff --git a/.changeset/dark-signs-enjoy.md b/.changeset/dark-signs-enjoy.md
new file mode 100644
index 00000000000..77af0799da9
--- /dev/null
+++ b/.changeset/dark-signs-enjoy.md
@@ -0,0 +1,16 @@
+---
+"@hashicorp/design-system-components": minor
+---
+
+
+`FilterBar` - Added new Filter Bar component
+
+
+
+`AdvancedTable` - Added support for filtering within the table with new `actions` named block and `FilterBar` contextual component
+
+
+
+`AdvancedTable` - Added argument `isEmpty` and named block `emptyState` for setting an empty state for the table
+
+
diff --git a/packages/components/package.json b/packages/components/package.json
index 6081e4dacc7..70d9e3cb863 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -235,6 +235,17 @@
"./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/date.js": "./dist/_app_/components/hds/filter-bar/date.js",
+ "./components/hds/filter-bar/filter-group.js": "./dist/_app_/components/hds/filter-bar/filter-group.js",
+ "./components/hds/filter-bar/filters-dropdown.js": "./dist/_app_/components/hds/filter-bar/filters-dropdown.js",
+ "./components/hds/filter-bar/generic.js": "./dist/_app_/components/hds/filter-bar/generic.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/filter-bar/tabs.js": "./dist/_app_/components/hds/filter-bar/tabs.js",
+ "./components/hds/filter-bar/tabs/panel.js": "./dist/_app_/components/hds/filter-bar/tabs/panel.js",
+ "./components/hds/filter-bar/tabs/tab.js": "./dist/_app_/components/hds/filter-bar/tabs/tab.js",
"./components/hds/flyout.js": "./dist/_app_/components/hds/flyout.js",
"./components/hds/form/character-count.js": "./dist/_app_/components/hds/form/character-count.js",
"./components/hds/form/checkbox/base.js": "./dist/_app_/components/hds/form/checkbox/base.js",
diff --git a/packages/components/src/components.ts b/packages/components/src/components.ts
index fb1b5c4d9b8..a3e010b35b1 100644
--- a/packages/components/src/components.ts
+++ b/packages/components/src/components.ts
@@ -130,6 +130,20 @@ 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 HdsFilterBarDate } from './components/hds/filter-bar/date.ts';
+export { default as HdsFilterBarFiltersDropdown } from './components/hds/filter-bar/filters-dropdown.ts';
+export { default as HdsFilterBarFilterGroup } from './components/hds/filter-bar/filter-group.ts';
+export { default as HdsFilterBarGeneric } from './components/hds/filter-bar/generic.ts';
+export { default as HdsFilterBarRadio } from './components/hds/filter-bar/radio.ts';
+export { default as HdsFilterBarRange } from './components/hds/filter-bar/range.ts';
+export { default as HdsFilterBarTabs } from './components/hds/filter-bar/tabs/index.ts';
+export { default as HdsFilterBarTabsPanel } from './components/hds/filter-bar/tabs/panel.ts';
+export { default as HdsFilterBarTabsTab } from './components/hds/filter-bar/tabs/tab.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 45eb3875f77..2c3c96b85c8 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}}
{{#if this.showScrollIndicatorLeft}}
@@ -195,10 +202,35 @@
/>
{{/if}}
- {{#if this.showScrollIndicatorBottom}}
-
+ {{#unless this.isEmpty}}
+ {{#if this.showScrollIndicatorBottom}}
+
+ {{/if}}
+ {{/unless}}
+
+ {{#if this.isEmpty}}
+
+
+ {{#if (has-block "emptyState")}}
+ {{yield to="emptyState"}}
+ {{else}}
+
+ {{hds-t
+ "hds.components.advanced-table.empty-state.title"
+ default="No data available"
+ }}
+
+ {{hds-t
+ "hds.components.advanced-table.empty-state.description"
+ default="There is currently no data to display in the table."
+ }}
+
+
+ {{/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
+
+ {{yield}}
+
+
\ 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..b720e7eacc5
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/checkbox.ts
@@ -0,0 +1,39 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+import type { HdsFilterBarFilter } from './types.ts';
+
+export interface HdsFilterBarCheckboxSignature {
+ Args: {
+ value?: string;
+ keyFilter: HdsFilterBarFilter | 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 (keyFilter && Array.isArray(keyFilter.data)) {
+ return keyFilter.data.some((filter) => filter.value === value);
+ }
+ return false;
+ }
+}
diff --git a/packages/components/src/components/hds/filter-bar/date.hbs b/packages/components/src/components/hds/filter-bar/date.hbs
new file mode 100644
index 00000000000..bbecdc321f1
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/date.hbs
@@ -0,0 +1,65 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+
+ {{this.selectorLabelText}}
+
+
+ {{#each this._selectorValues as |selectorValue|}}
+
+ {{/each}}
+
+
+ {{#if (eq this._selector "between")}}
+
+
+
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/date.ts b/packages/components/src/components/hds/filter-bar/date.ts
new file mode 100644
index 00000000000..447374d1f50
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/date.ts
@@ -0,0 +1,197 @@
+/**
+ * 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 { guidFor } from '@ember/object/internals';
+import { service } from '@ember/service';
+
+import type HdsIntlService from '../../../services/hds-intl';
+import type { HdsFormTextInputTypes } from '../form/text-input/types.ts';
+
+import type {
+ HdsFilterBarFilter,
+ HdsFilterBarDateFilterSelector,
+ HdsFilterBarDateFilterValue,
+} from './types.ts';
+import { HdsFilterBarDateFilterSelectorValues } from './types.ts';
+
+export const DATE_SELECTORS: HdsFilterBarDateFilterSelector[] = Object.values(
+ HdsFilterBarDateFilterSelectorValues
+);
+
+export const DATE_SELECTORS_TEXT: Record<
+ HdsFilterBarDateFilterSelector,
+ string
+> = {
+ [HdsFilterBarDateFilterSelectorValues.before]: 'before',
+ [HdsFilterBarDateFilterSelectorValues.exactly]: 'exactly',
+ [HdsFilterBarDateFilterSelectorValues.after]: 'after',
+ [HdsFilterBarDateFilterSelectorValues.between]: 'between',
+};
+
+export const DATE_SELECTORS_INPUT_TEXT: Record<
+ HdsFilterBarDateFilterSelector,
+ string
+> = {
+ [HdsFilterBarDateFilterSelectorValues.before]: 'Before',
+ [HdsFilterBarDateFilterSelectorValues.exactly]: 'Exactly',
+ [HdsFilterBarDateFilterSelectorValues.after]: 'After',
+ [HdsFilterBarDateFilterSelectorValues.between]: 'Between',
+};
+
+export interface HdsFilterBarDateSignature {
+ Args: {
+ keyFilter: HdsFilterBarFilter | undefined;
+ type?: 'date' | 'time' | 'datetime';
+ onChange?: (
+ selector?: HdsFilterBarDateFilterSelector,
+ value?: HdsFilterBarDateFilterValue
+ ) => void;
+ };
+ Blocks: {
+ default: [];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBarDate extends Component {
+ @service hdsIntl!: HdsIntlService;
+
+ @tracked private _selector: HdsFilterBarDateFilterSelector | undefined;
+ @tracked private _value: string | undefined;
+ @tracked private _betweenValueStart: string | undefined;
+ @tracked private _betweenValueEnd: string | undefined;
+
+ private _selectorValues = DATE_SELECTORS;
+ private _selectorInputId = 'selector-input-' + guidFor(this);
+ private _valueInputId = 'value-input-' + guidFor(this);
+ private _betweenValueStartInputId =
+ 'between-value-start-input-' + guidFor(this);
+ private _betweenValueEndInputId = 'between-value-end-input-' + guidFor(this);
+
+ constructor(owner: Owner, args: HdsFilterBarDateSignature['Args']) {
+ super(owner, args);
+
+ const { keyFilter } = this.args;
+ if (
+ keyFilter &&
+ (keyFilter.type === 'date' ||
+ keyFilter.type === 'time' ||
+ keyFilter.type === 'datetime')
+ ) {
+ const data = keyFilter.data;
+ this._selector = data.selector;
+ if (data.selector === 'between') {
+ if (
+ data.value &&
+ typeof data.value === 'object' &&
+ 'start' in data.value &&
+ 'end' in data.value
+ ) {
+ this._betweenValueStart = data.value.start;
+ this._betweenValueEnd = data.value.end;
+ }
+ } else {
+ this._value = data.value as string;
+ }
+ }
+ }
+
+ get type(): 'date' | 'time' | 'datetime' {
+ return this.args.type || 'date';
+ }
+
+ get inputType(): HdsFormTextInputTypes {
+ if (this.type === 'datetime') {
+ return 'datetime-local';
+ }
+ return this.type;
+ }
+
+ get selectorLabelText(): string {
+ return this.hdsIntl.t(`hds.components.filter-bar.date.${this.type}.label`, {
+ default: 'Date is',
+ });
+ }
+
+ @action
+ onSelectorChange(event: Event): void {
+ const select = event.target as HTMLSelectElement;
+ this._selector = select.value as HdsFilterBarDateFilterSelector;
+ if (this._selector === 'between') {
+ this._value = undefined;
+ } else {
+ this._betweenValueStart = undefined;
+ this._betweenValueEnd = undefined;
+ }
+ this._onChange();
+ }
+
+ @action
+ onValueChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._value = input.value;
+ this._onChange();
+ }
+
+ @action
+ onBetweenValueStartChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._betweenValueStart = input.value;
+ this._onChange();
+ }
+
+ @action
+ onBetweenValueEndChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._betweenValueEnd = input.value;
+ this._onChange();
+ }
+
+ @action
+ onClear(): void {
+ this._resetInputValues();
+ this._onChange();
+ }
+
+ private _onChange(): void {
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ if (
+ this._selector === 'between' &&
+ this._betweenValueStart !== undefined &&
+ this._betweenValueEnd !== undefined
+ ) {
+ onChange(this._selector, {
+ start: this._betweenValueStart,
+ end: this._betweenValueEnd,
+ });
+ } else {
+ onChange(this._selector, this._value);
+ }
+ }
+ }
+
+ private _selectorText = (
+ selector: HdsFilterBarDateFilterSelector
+ ): string => {
+ return this.hdsIntl.t(
+ `hds.components.filter-bar.date.selector-input.${selector}`,
+ {
+ default: DATE_SELECTORS_INPUT_TEXT[selector],
+ }
+ );
+ };
+
+ private _resetInputValues = (): void => {
+ this._selector = undefined;
+ this._value = undefined;
+ this._betweenValueStart = undefined;
+ this._betweenValueEnd = undefined;
+ };
+}
diff --git a/packages/components/src/components/hds/filter-bar/filter-group.hbs b/packages/components/src/components/hds/filter-bar/filter-group.hbs
new file mode 100644
index 00000000000..fd06873ac21
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/filter-group.hbs
@@ -0,0 +1,54 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+{{#let @tab as |Tab|}}
+
+ {{@text}}
+
+{{/let}}
+{{#let @panel as |Panel|}}
+
+ {{#if @searchEnabled}}
+
+
+
+ {{/if}}
+ {{#if (eq @type "range")}}
+
+ {{else if (eq @type "date")}}
+
+ {{else if (eq @type "datetime")}}
+
+ {{else if (eq @type "time")}}
+
+ {{else if (eq @type "generic")}}
+ {{yield
+ (hash Generic=(component "hds/filter-bar/generic" keyFilter=this.keyFilter onChange=this.onGenericChange))
+ }}
+ {{else}}
+
+
+
+
+
+ {{yield
+ (hash
+ Checkbox=(component "hds/filter-bar/checkbox" keyFilter=this.keyFilter onChange=this.onSelectionChange)
+ Radio=(component "hds/filter-bar/radio" keyFilter=this.keyFilter onChange=this.onSelectionChange)
+ )
+ }}
+
+
+ {{/if}}
+
+{{/let}}
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/filter-group.ts b/packages/components/src/components/hds/filter-bar/filter-group.ts
new file mode 100644
index 00000000000..1c94aee2ec7
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/filter-group.ts
@@ -0,0 +1,279 @@
+/**
+ * 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 HdsFilterBarTabsTab from './tabs/tab.ts';
+import HdsFilterBarTabsPanel from './tabs/panel.ts';
+import type { HdsTabsPanelSignature } from '../tabs/panel.ts';
+
+import HdsFilterBarGeneric from './generic.ts';
+import HdsFilterBarCheckbox from './checkbox.ts';
+import HdsFilterBarRadio from './radio.ts';
+
+import type {
+ HdsFilterBarFilter,
+ HdsFilterBarFilters,
+ HdsFilterBarFilterType,
+ HdsFilterBarData,
+ HdsFilterBarGenericFilter,
+ HdsFilterBarGenericFilterData,
+ HdsFilterBarRangeFilterData,
+ HdsFilterBarRangeFilterSelector,
+ HdsFilterBarRangeFilterValue,
+ HdsFilterBarDateFilterData,
+ HdsFilterBarDateFilterSelector,
+ HdsFilterBarDateFilterValue,
+} from './types.ts';
+
+export interface HdsFilterBarFilterGroupSignature {
+ Args: {
+ tab?: WithBoundArgs;
+ panel?: WithBoundArgs;
+ key: string;
+ text: string;
+ type?: HdsFilterBarFilterType;
+ filters: HdsFilterBarFilters;
+ searchEnabled?: boolean;
+ onChange: (key: string, keyFilter?: HdsFilterBarFilter) => void;
+ };
+ Blocks: {
+ default: [
+ {
+ Generic?: WithBoundArgs;
+ Checkbox?: WithBoundArgs<
+ typeof HdsFilterBarCheckbox,
+ 'keyFilter' | 'onChange'
+ >;
+ Radio?: WithBoundArgs<
+ typeof HdsFilterBarRadio,
+ 'keyFilter' | 'onChange'
+ >;
+ },
+ ];
+ };
+ Element: HdsTabsPanelSignature['Element'];
+}
+
+export default class HdsFilterBarFilterGroup extends Component {
+ @tracked internalFilters: HdsFilterBarData | undefined = [];
+
+ private _panelElement!: HdsTabsPanelSignature['Element'];
+
+ private _setUpFilterPanel = modifier(
+ (element: HdsTabsPanelSignature['Element']) => {
+ this._panelElement = element;
+
+ if (this.keyFilter) {
+ this.internalFilters = JSON.parse(
+ JSON.stringify(this.keyFilter.data)
+ ) as HdsFilterBarData;
+ }
+ }
+ );
+
+ get type(): HdsFilterBarFilterType {
+ const { type } = this.args;
+
+ if (!type) {
+ return 'multi-select';
+ }
+ return type;
+ }
+
+ get keyFilter(): HdsFilterBarFilter | undefined {
+ const { filters, key } = this.args;
+
+ if (!filters) {
+ return undefined;
+ }
+ return filters[key];
+ }
+
+ get numFilters(): number {
+ const { filters, key } = this.args;
+ if (filters && key in filters) {
+ const keyFilters = filters[key]?.data;
+ if (Array.isArray(keyFilters)) {
+ return keyFilters.length;
+ } else if (keyFilters) {
+ return 1;
+ }
+ }
+ return 0;
+ }
+
+ @action
+ onSelectionChange(event: Event): void {
+ const addFilter = (value: unknown): void => {
+ const newFilter = {
+ value: value,
+ } as HdsFilterBarGenericFilterData;
+ 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 HdsFilterBarGenericFilterData[];
+ 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);
+ }
+
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(this.args.key, this.formattedFilters);
+ }
+ }
+
+ @action
+ onRangeChange(
+ selector?: HdsFilterBarRangeFilterSelector,
+ value?: HdsFilterBarRangeFilterValue
+ ): void {
+ const addFilter = (): HdsFilterBarData => {
+ const newFilter = {
+ selector: selector,
+ value: value,
+ } as HdsFilterBarRangeFilterData;
+ return newFilter;
+ };
+
+ if (selector && value) {
+ this.internalFilters = addFilter();
+ } else {
+ this.internalFilters = undefined;
+ }
+
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(this.args.key, this.formattedFilters);
+ }
+ }
+
+ @action
+ onDateChange(
+ selector?: HdsFilterBarDateFilterSelector,
+ value?: HdsFilterBarDateFilterValue
+ ): void {
+ const addFilter = (): HdsFilterBarData => {
+ const newFilter = {
+ selector: selector,
+ value: value,
+ } as HdsFilterBarDateFilterData;
+ return newFilter;
+ };
+
+ if (selector && value) {
+ this.internalFilters = addFilter();
+ } else {
+ this.internalFilters = undefined;
+ }
+
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(this.args.key, this.formattedFilters);
+ }
+ }
+
+ @action
+ onGenericChange(filter?: HdsFilterBarGenericFilter): void {
+ if (filter) {
+ this.internalFilters = filter.data;
+ filter.text = this.args.text;
+ } else {
+ this.internalFilters = undefined;
+ }
+
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(this.args.key, filter);
+ }
+ }
+
+ @action
+ onClear(): void {
+ this.internalFilters = undefined;
+
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(this.args.key, this.formattedFilters);
+ }
+ }
+
+ get formattedFilters(): HdsFilterBarFilter | undefined {
+ if (
+ this.internalFilters === undefined ||
+ (Array.isArray(this.internalFilters) && this.internalFilters.length === 0)
+ ) {
+ return undefined;
+ }
+ return {
+ type: this.type,
+ text: this.args.text,
+ data: this.internalFilters,
+ } as HdsFilterBarFilter;
+ }
+
+ get classNames(): string {
+ const classes = ['hds-filter-bar__filter-group'];
+
+ classes.push(`hds-filter-bar__dropdown--type-${this.type}`);
+
+ return classes.join(' ');
+ }
+
+ private onSearch = (event: Event) => {
+ const listItems = this._panelElement.querySelectorAll(
+ '.hds-filter-bar__filters-dropdown__filter-option'
+ );
+ 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.classList.remove(
+ 'hds-filter-bar__filters-dropdown__filter-option--hidden'
+ );
+ } else {
+ item.classList.add(
+ 'hds-filter-bar__filters-dropdown__filter-option--hidden'
+ );
+ }
+ }
+ });
+ };
+}
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..508da838c09
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/filters-dropdown.hbs
@@ -0,0 +1,49 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+
+
+ {{yield
+ (hash
+ FilterGroup=(component
+ "hds/filter-bar/filter-group" tab=T.Tab panel=T.Panel onChange=this.onFilter filters=this.internalFilters
+ )
+ close=D.close
+ )
+ }}
+
+
+
+
+ {{#unless this.isLiveFilter}}
+
+ {{/unless}}
+
+
+
+
\ 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..75832d3ba53
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/filters-dropdown.ts
@@ -0,0 +1,143 @@
+/**
+ * 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 Owner from '@ember/owner';
+import type { WithBoundArgs } from '@glint/template';
+
+import HdsFilterBarFilterGroup from './filter-group.ts';
+import type { HdsFilterBarFilters, HdsFilterBarFilter } from './types.ts';
+
+import type { HdsDropdownSignature } from '../dropdown/index.ts';
+
+export interface HdsFilterBarFiltersDropdownSignature {
+ Args: HdsDropdownSignature['Args'] & {
+ filters: HdsFilterBarFilters;
+ isLiveFilter: boolean;
+ onFilter?: (filters: HdsFilterBarFilters) => void;
+ };
+ Blocks: {
+ default: [
+ {
+ FilterGroup?: WithBoundArgs<
+ typeof HdsFilterBarFilterGroup,
+ 'tab' | 'panel' | 'filters' | 'onChange'
+ >;
+ },
+ ];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBarFiltersDropdown extends Component<
+ HdsDropdownSignature & HdsFilterBarFiltersDropdownSignature
+> {
+ @tracked internalFilters: HdsFilterBarFilters = {};
+
+ constructor(
+ owner: Owner,
+ args: HdsFilterBarFiltersDropdownSignature['Args']
+ ) {
+ super(owner, args);
+
+ const { filters } = this.args;
+
+ if (filters) {
+ this.internalFilters = { ...filters };
+ }
+ }
+
+ private _syncFilters = modifier(
+ (_element, [_filters]: [HdsFilterBarFilters | undefined]) => {
+ if (_filters) {
+ this.internalFilters = _filters;
+ }
+ }
+ );
+
+ get isLiveFilter(): boolean {
+ return this.args.isLiveFilter || false;
+ }
+
+ @action
+ onFilter(key: string, keyFilter?: HdsFilterBarFilter): void {
+ this.internalFilters = this._updateFilter(key, keyFilter);
+
+ if (this.isLiveFilter) {
+ this._applyFilters();
+ }
+ }
+
+ @action
+ onApply(closeDropdown?: () => void): void {
+ this._applyFilters(closeDropdown);
+ }
+
+ @action
+ onClear(closeDropdown?: () => void): void {
+ const { onFilter } = this.args;
+ this.internalFilters = {};
+
+ if (onFilter && typeof onFilter === 'function') {
+ onFilter(this.internalFilters);
+ }
+
+ if (closeDropdown && typeof closeDropdown === 'function') {
+ closeDropdown();
+ }
+ }
+
+ get classNames(): string {
+ const classes = ['hds-filter-bar__filters-dropdown'];
+
+ return classes.join(' ');
+ }
+
+ private _updateFilter(
+ key: string,
+ keyFilter?: HdsFilterBarFilter
+ ): HdsFilterBarFilters {
+ const newFilters = {} as HdsFilterBarFilters;
+
+ Object.keys(this.internalFilters).forEach((k) => {
+ newFilters[k] = JSON.parse(
+ JSON.stringify(this.internalFilters[k])
+ ) as HdsFilterBarFilter;
+ });
+ if (
+ keyFilter === undefined ||
+ (Array.isArray(keyFilter) && keyFilter.length === 0)
+ ) {
+ delete newFilters[key];
+ } else {
+ Object.assign(newFilters, { [key]: keyFilter });
+ }
+
+ return { ...newFilters };
+ }
+
+ private _applyFilters = (closeDropdown?: () => void): void => {
+ const { onFilter } = this.args;
+ if (onFilter && typeof onFilter === 'function') {
+ onFilter(this.internalFilters);
+ }
+
+ if (closeDropdown && typeof closeDropdown === 'function') {
+ closeDropdown();
+ }
+ };
+
+ private _onClose = (): void => {
+ const { filters } = this.args;
+ if (filters) {
+ this.internalFilters = { ...filters };
+ } else {
+ this.internalFilters = {};
+ }
+ };
+}
diff --git a/packages/components/src/components/hds/filter-bar/generic.hbs b/packages/components/src/components/hds/filter-bar/generic.hbs
new file mode 100644
index 00000000000..fb56f97a73f
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/generic.hbs
@@ -0,0 +1,18 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+ {{yield (hash updateFilter=this.updateFilter)}}
+
+
+
+
+
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/generic.ts b/packages/components/src/components/hds/filter-bar/generic.ts
new file mode 100644
index 00000000000..aa439a29563
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/generic.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+import type { HdsFilterBarFilter, HdsFilterBarGenericFilter } from './types.ts';
+
+export interface HdsFilterBarGenericSignature {
+ Args: {
+ keyFilter: HdsFilterBarFilter | undefined;
+ onChange?: (filter?: HdsFilterBarGenericFilter) => void;
+ };
+ Blocks: {
+ default: [
+ {
+ updateFilter: (filter: HdsFilterBarGenericFilter) => void;
+ },
+ ];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBarGeneric extends Component {
+ @action
+ updateFilter(filter: HdsFilterBarGenericFilter): void {
+ console.log('Update filter action triggered', filter);
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange(filter);
+ }
+ }
+
+ @action
+ onClear(): void {
+ console.log('Clear action triggered');
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ onChange();
+ }
+ }
+}
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..66aef3fd6ab
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/index.hbs
@@ -0,0 +1,98 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+
+ {{yield
+ (hash
+ FiltersDropdown=(component
+ "hds/filter-bar/filters-dropdown" filters=@filters isLiveFilter=@isLiveFilter onFilter=this.onFilter
+ )
+ )
+ }}
+ {{#if @hasSearch}}
+
+ {{/if}}
+
+ {{yield (hash ActionsGeneric=(component "hds/yield"))}}
+ {{yield (hash ActionsDropdown=(component "hds/dropdown"))}}
+
+
+ {{#if this._isExpanded}}
+
+ {{#if this.hasActiveFilters}}
+ {{#each-in @filters as |key filter|}}
+ {{#if filter.data}}
+ {{#if (eq filter.type "single-select")}}
+
+ {{else if (eq filter.type "range")}}
+
+ {{else if (or (eq filter.type "date") (eq filter.type "datetime") (eq filter.type "time"))}}
+
+ {{else if (eq filter.type "search")}}
+
+ {{else if (eq filter.type "generic")}}
+
+ {{else if (eq filter.type "multi-select")}}
+ {{#each (this._filterArrayData filter.data) as |item|}}
+
+ {{/each}}
+ {{/if}}
+ {{/if}}
+ {{/each-in}}
+
+ {{else}}
+
+ {{hds-t "hds.components.filter-bar.no-filters-applied" default="No filters applied"}}
+
+ {{/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..9ab97ff9c7a
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/index.ts
@@ -0,0 +1,287 @@
+/**
+ * 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 { service } from '@ember/service';
+
+import type { WithBoundArgs } from '@glint/template';
+
+import type HdsIntlService from '../../../services/hds-intl';
+import type {
+ HdsFilterBarFilters,
+ HdsFilterBarFilter,
+ HdsFilterBarFilterType,
+ HdsFilterBarData,
+ HdsFilterBarGenericFilterData,
+} from './types.ts';
+import HdsDropdown from '../dropdown/index.ts';
+import HdsYield from '../yield/index.ts';
+import HdsFilterBarFiltersDropdown from './filters-dropdown.ts';
+import { isArray } from '@ember/array';
+
+import { RANGE_SELECTORS_TEXT } from './range.ts';
+import { DATE_SELECTORS_TEXT } from './date.ts';
+
+export interface HdsFilterBarSignature {
+ Args: {
+ filters: HdsFilterBarFilters;
+ isLiveFilter?: boolean;
+ hasSearch?: boolean;
+ onFilter?: (filters: HdsFilterBarFilters) => void;
+ };
+ Blocks: {
+ default?: [
+ {
+ ActionsDropdown?: WithBoundArgs;
+ ActionsGeneric?: WithBoundArgs;
+ FiltersDropdown?: WithBoundArgs<
+ typeof HdsFilterBarFiltersDropdown,
+ 'filters' | 'isLiveFilter' | 'onFilter'
+ >;
+ },
+ ];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBar extends Component {
+ @service hdsIntl!: HdsIntlService;
+
+ @tracked _isExpanded: boolean = false;
+
+ get searchValue(): string {
+ const { filters } = this.args;
+ if (filters['search']) {
+ return this._filterText(filters['search']);
+ }
+ return '';
+ }
+
+ @action
+ onFilter(filters: HdsFilterBarFilters): void {
+ const { onFilter } = this.args;
+ if (onFilter && typeof onFilter === 'function') {
+ onFilter(filters);
+
+ if (Object.keys(filters).length > 0) {
+ this._isExpanded = true;
+ } else {
+ this._isExpanded = false;
+ }
+ }
+ }
+
+ @action
+ clearFilters(): void {
+ const { onFilter } = this.args;
+ if (onFilter && typeof onFilter === 'function') {
+ onFilter({});
+ this._isExpanded = false;
+ }
+ }
+
+ @action
+ onSearch(event: Event): void {
+ const { filters } = this.args;
+ const input = event.target as HTMLInputElement;
+ const value = input?.value;
+
+ const newFilters = {} as HdsFilterBarFilters;
+
+ Object.keys(filters).forEach((k) => {
+ newFilters[k] = JSON.parse(
+ JSON.stringify(filters[k])
+ ) as HdsFilterBarFilter;
+ });
+
+ if (value.length > 0) {
+ newFilters['search'] = {
+ type: 'search',
+ text: 'Search',
+ data: { value },
+ };
+ } else {
+ delete newFilters['search'];
+ }
+
+ this.onFilter({ ...newFilters });
+ }
+
+ @action
+ toggleExpand(): void {
+ this._isExpanded = !this._isExpanded;
+ }
+
+ get hasActiveFilters(): boolean {
+ return Object.keys(this.args.filters).length > 0;
+ }
+
+ private onFilterDismiss = (key: string, filterValue?: unknown): void => {
+ const { filters } = this.args;
+ if (filters && filters[key]) {
+ const keyFilter: HdsFilterBarFilter = filters[key];
+ const newFilters = {} as HdsFilterBarFilters;
+
+ Object.keys(filters).forEach((k) => {
+ newFilters[k] = JSON.parse(
+ JSON.stringify(filters[k])
+ ) as HdsFilterBarFilter;
+ });
+
+ if (keyFilter.type === 'multi-select' && isArray(keyFilter.data)) {
+ const newKeyfilter = keyFilter.data?.filter(
+ (item) => item.value !== filterValue
+ );
+ if (newKeyfilter.length === 0) {
+ delete newFilters[key];
+ } else {
+ newFilters[key] = {
+ type: 'multi-select',
+ text: keyFilter.text,
+ data: newKeyfilter,
+ };
+ }
+ } else {
+ delete newFilters[key];
+ }
+
+ this.onFilter({ ...newFilters });
+ }
+ };
+
+ private _filterData = (
+ data: HdsFilterBarData
+ ): HdsFilterBarGenericFilterData => {
+ if ('value' in data) {
+ return { value: data.value };
+ }
+ return { value: '' };
+ };
+
+ private _filterText = (filter: HdsFilterBarFilter): string => {
+ const result = this._filterData(filter.data);
+ const resultText = result?.value as string;
+ return resultText ?? '';
+ };
+
+ private _filterArrayData = (data: HdsFilterBarData): { value: unknown }[] => {
+ if (isArray(data)) {
+ return data.map((item) => this._filterData(item));
+ }
+ return [];
+ };
+
+ private _filterKeyText = (key: string, data: HdsFilterBarFilter): string => {
+ if (data.text) {
+ return data.text;
+ } else {
+ return key;
+ }
+ };
+
+ private _rangeFilterText = (filter: HdsFilterBarFilter): string => {
+ const data = filter.data;
+
+ if (filter.type === 'range' && 'selector' in data && 'value' in data) {
+ const selector = data.selector as keyof typeof RANGE_SELECTORS_TEXT;
+ if (
+ selector === 'between' &&
+ typeof data.value === 'object' &&
+ data.value !== null
+ ) {
+ const separatorText = this.hdsIntl.t(
+ 'hds.components.filter-bar.filter-text.range-filter.separator',
+ {
+ default: 'and',
+ }
+ );
+ return `${RANGE_SELECTORS_TEXT[selector]} ${data.value.start} ${separatorText} ${data.value.end}`;
+ } else if (typeof data.value !== 'object') {
+ return `${RANGE_SELECTORS_TEXT[selector]} ${data.value}`;
+ }
+ return '';
+ } else {
+ return '';
+ }
+ };
+
+ private _dateFilterText = (filter: HdsFilterBarFilter): string => {
+ const data = filter.data;
+
+ if (
+ (filter.type === 'date' ||
+ filter.type === 'datetime' ||
+ filter.type === 'time') &&
+ 'selector' in data &&
+ 'value' in data
+ ) {
+ const selector = data.selector as keyof typeof DATE_SELECTORS_TEXT;
+ if (
+ selector === 'between' &&
+ typeof data.value === 'object' &&
+ data.value !== null
+ ) {
+ const separatorText = this.hdsIntl.t(
+ 'hds.components.filter-bar.filter-text.date-filter.separator',
+ {
+ default: 'and',
+ }
+ );
+ const startDateText = this._dateDisplayText(
+ data.value.start as string,
+ filter.type
+ );
+ const endDateText = this._dateDisplayText(
+ data.value.end as string,
+ filter.type
+ );
+ return `${DATE_SELECTORS_TEXT[selector]} ${startDateText} ${separatorText} ${endDateText}`;
+ } else if (data.value !== null && typeof data.value !== 'object') {
+ const dateText = this._dateDisplayText(
+ data.value as string,
+ filter.type
+ );
+ return `${DATE_SELECTORS_TEXT[selector]} ${dateText}`;
+ }
+ return '';
+ } else {
+ return '';
+ }
+ };
+
+ private _dateDisplayText = (
+ dateString: string,
+ filterType: HdsFilterBarFilterType
+ ): string => {
+ let date;
+ if (filterType === 'time') {
+ date = new Date(`1970-01-01T${dateString}`);
+ } else {
+ date = new Date(dateString);
+ }
+
+ let options = {};
+ if (filterType === 'date') {
+ options = { dateStyle: 'short' };
+ } else if (filterType === 'time') {
+ options = { timeStyle: 'short' };
+ } else {
+ options = { dateStyle: 'short', timeStyle: 'short' };
+ }
+
+ const newDate = new Intl.DateTimeFormat(undefined, options);
+ return newDate.format(date);
+ };
+
+ private _genericFilterText = (filter: HdsFilterBarFilter): string => {
+ if ('dismissTagText' in filter) {
+ return filter.dismissTagText ?? '';
+ } else {
+ return '';
+ }
+ };
+}
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..e19db76b411
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/radio.hbs
@@ -0,0 +1,9 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+ {{yield}}
+
+
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/radio.ts b/packages/components/src/components/hds/filter-bar/radio.ts
new file mode 100644
index 00000000000..04a4cc08f82
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/radio.ts
@@ -0,0 +1,44 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { action } from '@ember/object';
+
+import type { HdsFilterBarFilter } from './types.ts';
+
+export interface HdsFilterBarRadioSignature {
+ Args: {
+ value?: string;
+ keyFilter: HdsFilterBarFilter | 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 &&
+ keyFilter.type === 'single-select' &&
+ value &&
+ 'value' in keyFilter.data
+ ) {
+ return keyFilter.data.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..d2291762e07
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/range.hbs
@@ -0,0 +1,67 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+
+
+ {{hds-t "hds.components.filter-bar.range.label" default="Number is"}}
+
+
+
+ {{#each this._selectorValues as |selectorValue|}}
+
+ {{/each}}
+
+
+ {{#if (eq this._selector "between")}}
+
+
+
+
+ {{else}}
+
+ {{/if}}
+
+
+
+
+
\ 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..ad6ba0fffd1
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/range.ts
@@ -0,0 +1,189 @@
+/**
+ * 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 { guidFor } from '@ember/object/internals';
+import { service } from '@ember/service';
+
+import type HdsIntlService from '../../../services/hds-intl';
+import type {
+ HdsFilterBarFilter,
+ HdsFilterBarRangeFilterSelector,
+ HdsFilterBarRangeFilterValue,
+} from './types.ts';
+import { HdsFilterBarRangeFilterSelectorValues } from './types.ts';
+
+export const RANGE_SELECTORS: HdsFilterBarRangeFilterSelector[] = Object.values(
+ HdsFilterBarRangeFilterSelectorValues
+);
+
+export const RANGE_SELECTORS_TEXT: Record<
+ HdsFilterBarRangeFilterSelector,
+ string
+> = {
+ [HdsFilterBarRangeFilterSelectorValues.lessThan]: '<',
+ [HdsFilterBarRangeFilterSelectorValues.lessThanOrEqualTo]: '≤',
+ [HdsFilterBarRangeFilterSelectorValues.equalTo]: '=',
+ [HdsFilterBarRangeFilterSelectorValues.greaterThanOrEqualTo]: '≥',
+ [HdsFilterBarRangeFilterSelectorValues.greaterThan]: '>',
+ [HdsFilterBarRangeFilterSelectorValues.between]: 'between',
+};
+
+export const RANGE_SELECTORS_INPUT_TEXT: Record<
+ HdsFilterBarRangeFilterSelector,
+ 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 (>)',
+ [HdsFilterBarRangeFilterSelectorValues.between]: 'Between',
+};
+
+export interface HdsFilterBarRangeSignature {
+ Args: {
+ keyFilter: HdsFilterBarFilter | undefined;
+ onChange?: (
+ selector?: HdsFilterBarRangeFilterSelector,
+ value?: HdsFilterBarRangeFilterValue
+ ) => void;
+ };
+ Blocks: {
+ default: [];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBarRange extends Component {
+ @service hdsIntl!: HdsIntlService;
+
+ @tracked private _selector: HdsFilterBarRangeFilterSelector | undefined;
+ @tracked private _value: number | undefined;
+ @tracked private _betweenValueStart: number | undefined;
+ @tracked private _betweenValueEnd: number | undefined;
+
+ private _selectorValues = RANGE_SELECTORS;
+ private _selectorInputId = 'selector-input-' + guidFor(this);
+ private _valueInputId = 'value-input-' + guidFor(this);
+ private _betweenValueStartInputId =
+ 'between-value-start-input-' + guidFor(this);
+ private _betweenValueEndInputId = 'between-value-end-input-' + guidFor(this);
+
+ constructor(owner: Owner, args: HdsFilterBarRangeSignature['Args']) {
+ super(owner, args);
+
+ const { keyFilter } = this.args;
+ if (keyFilter && keyFilter.type === 'range') {
+ const data = keyFilter.data;
+ this._selector = data?.selector;
+ if (data.selector === 'between') {
+ if (data.value && typeof data.value === 'object') {
+ this._betweenValueStart = Number(data.value.start);
+ this._betweenValueEnd = Number(data.value.end);
+ }
+ } else {
+ this._value = Number(data.value);
+ }
+ }
+ }
+
+ get stringValue(): string | undefined {
+ return this._value !== undefined ? this._value.toString() : undefined;
+ }
+
+ get stringBetweenValueStart(): string | undefined {
+ return this._betweenValueStart !== undefined
+ ? this._betweenValueStart.toString()
+ : undefined;
+ }
+
+ get stringBetweenValueEnd(): string | undefined {
+ return this._betweenValueEnd !== undefined
+ ? this._betweenValueEnd.toString()
+ : undefined;
+ }
+
+ @action
+ onSelectorChange(event: Event): void {
+ const select = event.target as HTMLSelectElement;
+ this._selector = select.value as HdsFilterBarRangeFilterSelector;
+ if (this._selector === 'between') {
+ this._value = undefined;
+ } else {
+ this._betweenValueStart = undefined;
+ this._betweenValueEnd = undefined;
+ }
+ this._onChange();
+ }
+
+ @action
+ onValueChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._value = parseFloat(input.value);
+ this._onChange();
+ }
+
+ @action
+ onBetweenValueStartChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._betweenValueStart = parseFloat(input.value);
+ this._onChange();
+ }
+
+ @action
+ onBetweenValueEndChange(event: Event): void {
+ const input = event.target as HTMLInputElement;
+ this._betweenValueEnd = parseFloat(input.value);
+ this._onChange();
+ }
+
+ @action
+ onClear(): void {
+ this._resetInputValues();
+ this._onChange();
+ }
+
+ private _onChange(): void {
+ const { onChange } = this.args;
+ if (onChange && typeof onChange === 'function') {
+ if (
+ this._selector === 'between' &&
+ this._betweenValueStart !== undefined &&
+ this._betweenValueEnd !== undefined
+ ) {
+ onChange(this._selector, {
+ start: this._betweenValueStart,
+ end: this._betweenValueEnd,
+ });
+ } else {
+ onChange(this._selector, this._value);
+ }
+ }
+ }
+
+ private _selectorText = (
+ selector: HdsFilterBarRangeFilterSelector
+ ): string => {
+ return this.hdsIntl.t(
+ `hds.components.filter-bar.range.selector-input.${selector}`,
+ {
+ default: RANGE_SELECTORS_INPUT_TEXT[selector],
+ }
+ );
+ };
+
+ private _resetInputValues = (): void => {
+ this._selector = undefined;
+ this._value = undefined;
+ this._betweenValueStart = undefined;
+ this._betweenValueEnd = undefined;
+ };
+}
diff --git a/packages/components/src/components/hds/filter-bar/tabs/index.hbs b/packages/components/src/components/hds/filter-bar/tabs/index.hbs
new file mode 100644
index 00000000000..4b58e4dd652
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/index.hbs
@@ -0,0 +1,34 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+
+ {{yield
+ (hash
+ Tab=(component
+ "hds/filter-bar/tabs/tab"
+ selectedTabIndex=this._selectedTabIndex
+ tabIds=this._tabIds
+ panelIds=this._panelIds
+ didInsertNode=this.didInsertTab
+ willDestroyNode=this.willDestroyTab
+ onClick=this.onClick
+ onKeyUp=this.onKeyUp
+ )
+ )
+ }}
+
+ {{yield
+ (hash
+ Panel=(component
+ "hds/filter-bar/tabs/panel"
+ selectedTabIndex=this._selectedTabIndex
+ tabIds=this._tabIds
+ panelIds=this._panelIds
+ didInsertNode=this.didInsertPanel
+ willDestroyNode=this.willDestroyPanel
+ )
+ )
+ }}
+
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/tabs/index.ts b/packages/components/src/components/hds/filter-bar/tabs/index.ts
new file mode 100644
index 00000000000..b6f37a81857
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/index.ts
@@ -0,0 +1,186 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { tracked } from '@glimmer/tracking';
+import { action } from '@ember/object';
+import { schedule } from '@ember/runloop';
+import { modifier } from 'ember-modifier';
+import type { WithBoundArgs } from '@glint/template';
+import HdsFilterBarTabsTabComponent from './tab.ts';
+import HdsFilterBarTabsPanelComponent from './panel.ts';
+
+const TAB_ELEMENT_SELECTOR = '.hds-filter-bar__tabs__tab__button';
+const PANEL_ELEMENT_SELECTOR = '.hds-filter-bar__tabs__panel';
+
+export interface HdsFilterBarTabsSignature {
+ Args: {
+ selectedTabIndex?: number;
+ ariaLabel: string;
+ onClickTab?: (event: MouseEvent, tabIndex: number) => void;
+ };
+ Blocks: {
+ default: [
+ {
+ Tab?: WithBoundArgs<
+ typeof HdsFilterBarTabsTabComponent,
+ | 'selectedTabIndex'
+ | 'tabIds'
+ | 'panelIds'
+ | 'didInsertNode'
+ | 'willDestroyNode'
+ | 'onClick'
+ | 'onKeyUp'
+ >;
+ Panel?: WithBoundArgs<
+ typeof HdsFilterBarTabsPanelComponent,
+ | 'selectedTabIndex'
+ | 'tabIds'
+ | 'panelIds'
+ | 'didInsertNode'
+ | 'willDestroyNode'
+ >;
+ },
+ ];
+ };
+ Element: HTMLDivElement;
+}
+
+export default class HdsFilterBarTabs extends Component {
+ @tracked private _tabIds: string[] = [];
+ @tracked private _tabNodes: HTMLElement[] = [];
+ @tracked private _panelNodes: HTMLElement[] = [];
+ @tracked private _panelIds: string[] = [];
+ @tracked private _selectedTabIndex: number = 0;
+ @tracked private _selectedTabId?: string;
+
+ private _element!: HTMLDivElement;
+
+ private _setUpFilterBarTabs = modifier((element: HTMLDivElement) => {
+ const { selectedTabIndex } = this.args;
+
+ if (selectedTabIndex) {
+ this._selectedTabIndex = selectedTabIndex;
+ }
+
+ this._element = element;
+
+ return () => {};
+ });
+
+ @action
+ didInsertTab(): void {
+ // eslint-disable-next-line ember/no-runloop
+ schedule('afterRender', (): void => {
+ this.updateTabs();
+ });
+ }
+
+ @action
+ willDestroyTab(element: HTMLElement): void {
+ // eslint-disable-next-line ember/no-runloop
+ schedule('afterRender', (): void => {
+ this._tabNodes = this._tabNodes.filter(
+ (node): boolean => node.id !== element.id
+ );
+ this._tabIds = this._tabIds.filter(
+ (tabId): boolean => tabId !== element.id
+ );
+ });
+ }
+
+ @action
+ didInsertPanel(): void {
+ // eslint-disable-next-line ember/no-runloop
+ schedule('afterRender', (): void => {
+ this.updatePanels();
+ });
+ }
+
+ @action
+ willDestroyPanel(element: HTMLElement): void {
+ // eslint-disable-next-line ember/no-runloop
+ schedule('afterRender', (): void => {
+ this._panelNodes = this._panelNodes.filter(
+ (node): boolean => node.id !== element.id
+ );
+ this._panelIds = this._panelIds.filter(
+ (panelId): boolean => panelId !== element.id
+ );
+ });
+ }
+
+ @action
+ onClick(event: MouseEvent, tabIndex: number): void {
+ this._selectedTabIndex = tabIndex;
+
+ // invoke the callback function if it's provided as argument
+ if (typeof this.args.onClickTab === 'function') {
+ this.args.onClickTab(event, tabIndex);
+ }
+ }
+
+ @action
+ onKeyUp(event: KeyboardEvent, tabIndex: number): void {
+ const leftArrow = 'ArrowLeft';
+ const rightArrow = 'ArrowRight';
+ const upArrow = 'ArrowUp';
+ const downArrow = 'ArrowDown';
+ const enterKey = 'Enter';
+ const spaceKey = ' ';
+
+ if (event.key === rightArrow || event.key === downArrow) {
+ const nextTabIndex = (tabIndex + 1) % this._tabIds.length;
+ this.focusTab(nextTabIndex, event);
+ } else if (event.key === leftArrow || event.key === upArrow) {
+ const prevTabIndex =
+ (tabIndex + this._tabIds.length - 1) % this._tabIds.length;
+ this.focusTab(prevTabIndex, event);
+ } else if (event.key === enterKey || event.key === spaceKey) {
+ this._selectedTabIndex = tabIndex;
+ }
+ // scroll selected tab into view (it may be out of view when activated using a keyboard with `prev/next`)
+ const parentNode = this._tabNodes[this._selectedTabIndex]?.parentNode;
+ if (parentNode instanceof HTMLElement) {
+ parentNode.scrollIntoView({
+ behavior: 'smooth',
+ block: 'nearest',
+ inline: 'nearest',
+ });
+ }
+ }
+
+ // Focus tab for keyboard & mouse navigation:
+ focusTab(tabIndex: number, event: KeyboardEvent): void {
+ event.preventDefault();
+ this._tabNodes[tabIndex]?.focus();
+ }
+
+ // Update the tab arrays based on how they are ordered in the DOM
+ private updateTabs(): void {
+ const tabs = this._element.querySelectorAll(TAB_ELEMENT_SELECTOR);
+ let newTabIds: string[] = [];
+ let newTabNodes: HTMLElement[] = [];
+ tabs.forEach((tab) => {
+ newTabIds = [...newTabIds, tab.id];
+ newTabNodes = [...newTabNodes, tab as HTMLElement];
+ });
+ this._tabIds = newTabIds;
+ this._tabNodes = newTabNodes;
+ }
+
+ // Update the panel arrays based on how they are ordered in the DOM
+ private updatePanels(): void {
+ const panels = this._element.querySelectorAll(PANEL_ELEMENT_SELECTOR);
+ let newPanelIds: string[] = [];
+ let newPanelNodes: HTMLElement[] = [];
+ panels.forEach((panel) => {
+ newPanelIds = [...newPanelIds, panel.id];
+ newPanelNodes = [...newPanelNodes, panel as HTMLElement];
+ });
+ this._panelIds = newPanelIds;
+ this._panelNodes = newPanelNodes;
+ }
+}
diff --git a/packages/components/src/components/hds/filter-bar/tabs/panel.hbs b/packages/components/src/components/hds/filter-bar/tabs/panel.hbs
new file mode 100644
index 00000000000..80f302512b8
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/panel.hbs
@@ -0,0 +1,17 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+
+ {{#if this.isVisible}}
+ {{yield}}
+ {{/if}}
+
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/tabs/panel.ts b/packages/components/src/components/hds/filter-bar/tabs/panel.ts
new file mode 100644
index 00000000000..276bd9f33dd
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/panel.ts
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+
+export interface HdsFilterBarTabsPanelSignature {
+ Args: {
+ selectedTabIndex?: number;
+ tabIds?: string[];
+ panelIds?: string[];
+ didInsertNode?: () => void;
+ willDestroyNode?: (element: HTMLElement) => void;
+ };
+ Blocks: {
+ default: [];
+ };
+ Element: HTMLElement;
+}
+
+export default class HdsFilterBarTabsPanel extends Component {
+ private _panelId = 'panel-' + guidFor(this);
+ private _elementId?: string;
+
+ private _setUpPanel = modifier(
+ (
+ element: HTMLElement,
+ [insertCallbackFunction, destroyCallbackFunction]
+ ) => {
+ if (typeof insertCallbackFunction === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ insertCallbackFunction(element);
+ }
+
+ return () => {
+ if (typeof destroyCallbackFunction === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ destroyCallbackFunction(element);
+ }
+ };
+ }
+ );
+
+ get nodeIndex(): number | undefined {
+ return this.args.panelIds?.indexOf(this._panelId);
+ }
+
+ get coupledTabId(): string | undefined {
+ return this.nodeIndex !== undefined
+ ? this.args.tabIds?.[this.nodeIndex]
+ : undefined;
+ }
+
+ get isVisible(): boolean {
+ return this.nodeIndex === this.args.selectedTabIndex;
+ }
+
+ @action
+ didInsertNode(element: HTMLElement): void {
+ const { didInsertNode } = this.args;
+
+ if (typeof didInsertNode === 'function') {
+ this._elementId = element.id;
+ didInsertNode();
+ }
+ }
+
+ @action
+ willDestroyNode(element: HTMLElement): void {
+ const { willDestroyNode } = this.args;
+
+ if (typeof willDestroyNode === 'function') {
+ willDestroyNode(element);
+ }
+ }
+}
diff --git a/packages/components/src/components/hds/filter-bar/tabs/tab.hbs b/packages/components/src/components/hds/filter-bar/tabs/tab.hbs
new file mode 100644
index 00000000000..8872757795a
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/tab.hbs
@@ -0,0 +1,30 @@
+{{!
+ Copyright (c) HashiCorp, Inc.
+ SPDX-License-Identifier: MPL-2.0
+}}
+{{! template-lint-disable require-context-role no-invalid-role }}
+{{! template-lint-disable require-presentational-children }}
+
+
+
+{{! template-lint-enable require-presentational-children }}
+{{! template-lint-enable require-context-role no-invalid-role }}
\ No newline at end of file
diff --git a/packages/components/src/components/hds/filter-bar/tabs/tab.ts b/packages/components/src/components/hds/filter-bar/tabs/tab.ts
new file mode 100644
index 00000000000..80d2234eecb
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/tabs/tab.ts
@@ -0,0 +1,116 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+import Component from '@glimmer/component';
+import { guidFor } from '@ember/object/internals';
+import { action } from '@ember/object';
+import { modifier } from 'ember-modifier';
+
+export interface HdsFilterBarTabsTabSignature {
+ Args: {
+ selectedTabIndex?: number;
+ tabIds?: string[];
+ panelIds?: string[];
+ numFilters?: number;
+ didInsertNode?: () => void;
+ willDestroyNode?: (element: HTMLButtonElement) => void;
+ onClick?: (event: MouseEvent, nodeIndex: number) => void;
+ onKeyUp?: (event: KeyboardEvent, nodeIndex: number) => void;
+ };
+ Blocks: {
+ default: [];
+ };
+ Element: HTMLElement;
+}
+
+export default class HdsFilterBarTabsTab extends Component {
+ private _tabId = 'tab-' + guidFor(this);
+ private _elementId?: string;
+
+ private _setUpTab = modifier(
+ (
+ element: HTMLElement,
+ [insertCallbackFunction, destroyCallbackFunction]
+ ) => {
+ if (typeof insertCallbackFunction === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ insertCallbackFunction(element);
+ }
+
+ return () => {
+ if (typeof destroyCallbackFunction === 'function') {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-call
+ destroyCallbackFunction(element);
+ }
+ };
+ }
+ );
+
+ get nodeIndex(): number | undefined {
+ return this.args.tabIds?.indexOf(this._tabId);
+ }
+
+ get coupledPanelId(): string | undefined {
+ return this.nodeIndex !== undefined
+ ? this.args.panelIds?.[this.nodeIndex]
+ : undefined;
+ }
+
+ get isSelected(): boolean {
+ return (
+ this.nodeIndex !== undefined &&
+ this.nodeIndex === this.args.selectedTabIndex
+ );
+ }
+
+ @action
+ didInsertNode(element: HTMLButtonElement): void {
+ const { didInsertNode } = this.args;
+
+ if (typeof didInsertNode === 'function') {
+ this._elementId = element.id;
+ didInsertNode();
+ }
+ }
+
+ @action
+ willDestroyNode(element: HTMLButtonElement): void {
+ const { willDestroyNode } = this.args;
+
+ if (typeof willDestroyNode === 'function') {
+ willDestroyNode(element);
+ }
+ }
+
+ @action
+ onClick(event: MouseEvent): false | undefined {
+ const { onClick } = this.args;
+
+ if (this.nodeIndex !== undefined && typeof onClick === 'function') {
+ onClick(event, this.nodeIndex);
+ } else {
+ return false;
+ }
+ }
+
+ @action
+ onKeyUp(event: KeyboardEvent): void {
+ const { onKeyUp } = this.args;
+
+ if (this.nodeIndex !== undefined && typeof onKeyUp === 'function') {
+ onKeyUp(event, this.nodeIndex);
+ }
+ }
+
+ get classNames(): string {
+ const classes = ['hds-filter-bar__tabs__tab'];
+
+ if (this.isSelected) {
+ classes.push(`hds-filter-bar__tabs__tab--is-selected`);
+ }
+
+ return classes.join(' ');
+ }
+}
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..8e4473400a8
--- /dev/null
+++ b/packages/components/src/components/hds/filter-bar/types.ts
@@ -0,0 +1,115 @@
+/**
+ * Copyright (c) HashiCorp, Inc.
+ * SPDX-License-Identifier: MPL-2.0
+ */
+
+export enum HdsFilterBarFilterTypeValues {
+ multiSelect = 'multi-select',
+ singleSelect = 'single-select',
+ range = 'range',
+ date = 'date',
+ time = 'time',
+ datetime = 'datetime',
+ generic = 'generic',
+ search = 'search',
+}
+
+export type HdsFilterBarFilterType = `${HdsFilterBarFilterTypeValues}`;
+
+export interface HdsFilterBarGenericFilterData {
+ value: unknown;
+}
+
+export interface HdsFilterBarRangeFilterData {
+ selector: HdsFilterBarRangeFilterSelector;
+ value: HdsFilterBarRangeFilterValue;
+}
+
+export interface HdsFilterBarDateFilterData {
+ selector: HdsFilterBarDateFilterSelector;
+ value: HdsFilterBarDateFilterValue;
+}
+
+export type HdsFilterBarData =
+ | HdsFilterBarGenericFilterData[]
+ | HdsFilterBarGenericFilterData
+ | HdsFilterBarRangeFilterData
+ | HdsFilterBarDateFilterData;
+
+export interface HdsFilterBarGenericFilter {
+ type: 'generic';
+ text?: string;
+ dismissTagText?: string;
+ data: HdsFilterBarGenericFilterData | HdsFilterBarGenericFilterData[];
+}
+export interface HdsFilterBarSingleSelectFilter {
+ type: 'single-select';
+ text?: string;
+ data: HdsFilterBarGenericFilterData;
+}
+
+export interface HdsFilterBarMultiSelectFilter {
+ type: 'multi-select';
+ text?: string;
+ data: HdsFilterBarGenericFilterData[];
+}
+
+export interface HdsFilterBarRangeFilter {
+ type: 'range';
+ text?: string;
+ data: HdsFilterBarRangeFilterData;
+}
+
+export interface HdsFilterBarDateFilter {
+ type: 'date' | 'time' | 'datetime';
+ text?: string;
+ data: HdsFilterBarDateFilterData;
+}
+
+export interface HdsFilterBarSearchFilter {
+ type: 'search';
+ text?: string;
+ data: HdsFilterBarGenericFilterData;
+}
+
+export type HdsFilterBarFilter =
+ | HdsFilterBarSingleSelectFilter
+ | HdsFilterBarMultiSelectFilter
+ | HdsFilterBarRangeFilter
+ | HdsFilterBarDateFilter
+ | HdsFilterBarSearchFilter
+ | HdsFilterBarGenericFilter;
+
+export interface HdsFilterBarFilters {
+ [name: string]: HdsFilterBarFilter;
+}
+
+export enum HdsFilterBarRangeFilterSelectorValues {
+ lessThan = 'less-than',
+ lessThanOrEqualTo = 'less-than-or-equal-to',
+ equalTo = 'equal-to',
+ greaterThanOrEqualTo = 'greater-than-or-equal-to',
+ greaterThan = 'greater-than',
+ between = 'between',
+}
+
+export type HdsFilterBarRangeFilterSelector =
+ `${HdsFilterBarRangeFilterSelectorValues}`;
+
+export enum HdsFilterBarDateFilterSelectorValues {
+ before = 'before',
+ exactly = 'exactly',
+ after = 'after',
+ between = 'between',
+}
+
+export type HdsFilterBarDateFilterSelector =
+ `${HdsFilterBarDateFilterSelectorValues}`;
+
+export type HdsFilterBarRangeFilterValue =
+ | number
+ | { start?: number; end?: number };
+
+export type HdsFilterBarDateFilterValue =
+ | string
+ | { start?: string; end?: string };
diff --git a/packages/components/src/styles/@hashicorp/design-system-components.scss b/packages/components/src/styles/@hashicorp/design-system-components.scss
index ae3fb7b1912..fdc971e96eb 100644
--- a/packages/components/src/styles/@hashicorp/design-system-components.scss
+++ b/packages/components/src/styles/@hashicorp/design-system-components.scss
@@ -36,6 +36,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/filter-bar.scss b/packages/components/src/styles/components/filter-bar.scss
new file mode 100644
index 00000000000..58226fc46c2
--- /dev/null
+++ b/packages/components/src/styles/components/filter-bar.scss
@@ -0,0 +1,180 @@
+/**
+ * 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__right {
+ margin-left: auto;
+}
+
+.hds-filter-bar__search {
+ --token-form-control-padding: 3px;
+}
+
+// FILTERS DROPDOWN
+//
+
+.hds-filter-bar__filters-dropdown__filter-group .hds-form-field--layout-flag {
+ padding: 8px 12px;
+}
+
+.hds-filter-bar__filters-dropdown .hds-dropdown__list,
+.hds-filter-bar__filters-dropdown .hds-dropdown-list-item {
+ padding: 0;
+}
+
+.hds-filter-bar__filters-dropdown .hds-dropdown__footer .hds-layout-flex {
+ margin: 8px 0;
+}
+
+.hds-filter-bar__filters-dropdown .hds-dropdown__footer .hds-button-set {
+ gap: 8px;
+}
+
+.hds-filter-bar__filters-dropdown__filter-group__search {
+ padding: 0 16px 16px 16px;
+ border-bottom: 1px solid var(--token-color-border-primary);
+}
+
+.hds-filter-bar__filters-dropdown__filter-group__list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ list-style: none;
+ overscroll-behavior: contain;
+}
+
+.hds-filter-bar__filters-dropdown__filters-count {
+ margin-left: 8px;
+}
+
+.hds-filter-bar__filters-dropdown__filter-option {
+ display: block;
+ padding: 8px 16px;
+
+ &--hidden {
+ display: none;
+ }
+}
+
+.hds-filter-bar__filters-dropdown__filter-range,
+.hds-filter-bar__filters-dropdown__filter-date {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.hds-filter-bar__filters-dropdown__fields {
+ padding: 0 16px;
+}
+
+.hds-filter-bar__filters-dropdown__fields .hds-filter-bar__filters-dropdown__field,
+.hds-filter-bar__filters-dropdown__fields .hds-filter-bar__filters-dropdown__field[type="date"],
+.hds-filter-bar__filters-dropdown__fields .hds-filter-bar__filters-dropdown__field[type="time"] {
+ width: 100%;
+}
+
+.hds-filter-bar__filters-dropdown__clear {
+ padding: 4px;
+}
+
+// TABS
+//
+
+.hds-filter-bar__tabs {
+ display: flex;
+}
+
+.hds-filter-bar__tabs__list {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ width: 50%;
+ padding: 8px;
+ list-style: none;
+ background-color: var(--token-color-surface-faint);
+ border-right: 1px solid var(--token-color-border-primary);
+ border-top-left-radius: var(--token-border-radius-medium);
+}
+
+.hds-filter-bar__tabs__panel:not([hidden]) {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: 50%;
+ padding: 16px 0;
+ background-color: var(--token-color-surface-primary);
+ border-top-right-radius: var(--token-border-radius-medium);
+}
+
+.hds-filter-bar__tabs__tab__button {
+ display: flex;
+ gap: 16px;
+ align-items: center;
+ width: 100%;
+ padding: 8px 12px;
+ color: var(--token-color-foreground-primary);
+ text-align: left;
+ background-color: transparent;
+ border: none;
+ border-radius: var(--token-border-radius-small);
+
+ &.mock-hover,
+ &:hover {
+ background-color: var(--token-color-surface-interactive-hover);
+ cursor: pointer;
+ }
+
+ &.mock-active,
+ &:active {
+ background-color: var(--token-color-surface-interactive-active);
+ }
+}
+
+.hds-filter-bar__tabs__tab__text {
+ width: 100%;
+}
+
+.hds-filter-bar__tabs__tab--is-selected .hds-filter-bar__tabs__tab__button {
+ color: var(--token-color-foreground-action);
+ background-color: var(--token-color-surface-strong);
+
+ &.mock-hover,
+ &:hover {
+ background-color: var(--token-color-palette-neutral-200);
+ }
+
+ &.mock-active,
+ &:active {
+ background-color: var(--token-color-palette-neutral-300);
+ }
+}
diff --git a/packages/components/src/template-registry.ts b/packages/components/src/template-registry.ts
index 4dee43a90a6..2b8c5eebb92 100644
--- a/packages/components/src/template-registry.ts
+++ b/packages/components/src/template-registry.ts
@@ -97,6 +97,18 @@ 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 HdsFilterBarCheckboxComponent from './components/hds/filter-bar/checkbox';
+import type HdsFilterBarDateComponent from './components/hds/filter-bar/date';
+import type HdsFilterBarGenericComponent from './components/hds/filter-bar/generic';
+import type HdsFilterBarRadioComponent from './components/hds/filter-bar/radio';
+import type HdsFilterBarFiltersDropdownComponent from './components/hds/filter-bar/filters-dropdown';
+import type HdsFilterBarFilterGroupComponent from './components/hds/filter-bar/filter-group';
+import type HdsFilterBarRangeComponent from './components/hds/filter-bar/range';
+import type HdsFilterBarTabsComponent from './components/hds/filter-bar/tabs';
+import type HdsFilterBarTabsPanelComponent from './components/hds/filter-bar/tabs/panel';
+import type HdsFilterBarTabsTabComponent from './components/hds/filter-bar/tabs/tab';
import type HdsFlyoutComponent from './components/hds/flyout';
import type HdsFormComponent from './components/hds/form';
@@ -554,6 +566,30 @@ 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::Checkbox': typeof HdsFilterBarCheckboxComponent;
+ 'hds/filter-bar/checkbox': typeof HdsFilterBarCheckboxComponent;
+ 'Hds::FilterBar::Date': typeof HdsFilterBarDateComponent;
+ 'hds/filter-bar/date': typeof HdsFilterBarDateComponent;
+ 'Hds::FilterBar::Generic': typeof HdsFilterBarGenericComponent;
+ 'hds/filter-bar/generic': typeof HdsFilterBarGenericComponent;
+ '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::FilterGroup': typeof HdsFilterBarFilterGroupComponent;
+ 'hds/filter-bar/filter-group': typeof HdsFilterBarFilterGroupComponent;
+ 'Hds::FilterBar::Range': typeof HdsFilterBarRangeComponent;
+ 'hds/filter-bar/range': typeof HdsFilterBarRangeComponent;
+ 'Hds::FilterBar::Tabs': typeof HdsFilterBarTabsComponent;
+ 'hds/filter-bar/tabs': typeof HdsFilterBarTabsComponent;
+ 'Hds::FilterBar::Tabs::Panel': typeof HdsFilterBarTabsPanelComponent;
+ 'hds/filter-bar/tabs/panel': typeof HdsFilterBarTabsPanelComponent;
+ 'Hds::FilterBar::Tabs::Tab': typeof HdsFilterBarTabsTabComponent;
+ 'hds/filter-bar/tabs/tab': typeof HdsFilterBarTabsTabComponent;
+
// Flyout
'Hds::Flyout': typeof HdsFlyoutComponent;
'hds/flyout': typeof HdsFlyoutComponent;
diff --git a/packages/components/translations/hds/components/advanced-table/en-us.yaml b/packages/components/translations/hds/components/advanced-table/en-us.yaml
index b6074f30b8b..7e5d5ac4348 100644
--- a/packages/components/translations/hds/components/advanced-table/en-us.yaml
+++ b/packages/components/translations/hds/components/advanced-table/en-us.yaml
@@ -1 +1,4 @@
reordered-message: Moved {columnLabel} column to position {newPosition}
+empty-state:
+ title: No data available
+ description: There is currently no data to display in the table.
diff --git a/packages/components/translations/hds/components/filter-bar/date/en-us.yaml b/packages/components/translations/hds/components/filter-bar/date/en-us.yaml
new file mode 100644
index 00000000000..832a7cf3e6f
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/date/en-us.yaml
@@ -0,0 +1,18 @@
+date:
+ label: Date is
+datetime:
+ label: Datetime is
+time:
+ label: Time is
+selector-input:
+ default-value: Pick a selector
+ before: Before
+ exactly: Exactly
+ after: After
+ between: Between
+value-input:
+ placeholder: Enter a date
+between-value-inputs:
+ start-placeholder: Start
+ end-placeholder: End
+clear: Clear filter
diff --git a/packages/components/translations/hds/components/filter-bar/en-us.yaml b/packages/components/translations/hds/components/filter-bar/en-us.yaml
new file mode 100644
index 00000000000..2e6e768346e
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/en-us.yaml
@@ -0,0 +1,9 @@
+search:
+ aria-label: Search filters
+ placeholder: Search
+filter-text:
+ range-filter:
+ separator: and
+ date-filter:
+ separator: and
+no-filters-applied: No filters applied
diff --git a/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml
new file mode 100644
index 00000000000..06a65bc3b91
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/filter-group/en-us.yaml
@@ -0,0 +1 @@
+clear: Clear selection
diff --git a/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml
new file mode 100644
index 00000000000..e94d2868354
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/filter-options/en-us.yaml
@@ -0,0 +1 @@
+search-input-placeholder: "Search"
diff --git a/packages/components/translations/hds/components/filter-bar/filters-dropdown/en-us.yaml b/packages/components/translations/hds/components/filter-bar/filters-dropdown/en-us.yaml
new file mode 100644
index 00000000000..1f2fcaa3a14
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/filters-dropdown/en-us.yaml
@@ -0,0 +1,7 @@
+apply: Apply filters
+clear: Clear all filters
+expand-collapse-button:
+ expand: Expand filters
+ collapse: Collapse filters
+selected-filters: Filters selected
+toggle-button: Filters
diff --git a/packages/components/translations/hds/components/filter-bar/range/en-us.yaml b/packages/components/translations/hds/components/filter-bar/range/en-us.yaml
new file mode 100644
index 00000000000..9e1eb4f1217
--- /dev/null
+++ b/packages/components/translations/hds/components/filter-bar/range/en-us.yaml
@@ -0,0 +1,15 @@
+label: Number is
+selector-input:
+ default-value: Pick a selector
+ less-than: Less than (<)
+ less-than-or-equal-to: Less than or equal to (≤)
+ equal-to: Equal to (=)
+ greater-than-or-equal-to: Greater than or equal to (≥)
+ greater-than: Greater than (>)
+ between: Between
+value-input:
+ placeholder: Enter a value
+between-value-inputs:
+ start-placeholder: Start
+ end-placeholder: End
+clear: Clear filter
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..463ce60fd32 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,43 @@
*/
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 { get, fn } from '@ember/helper';
+import { on } from '@ember/modifier';
+import style from 'ember-style-modifier/modifiers/style';
+
+import ShwPlaceholder from 'showcase/components/shw/placeholder';
// HDS components
import {
HdsAdvancedTable,
+ HdsButton,
+ HdsFilterBar,
+ HdsLayoutFlex,
HdsLinkInline,
HdsBadge,
HdsBadgeColorValues,
+ HdsFormToggleField,
+ HdsTextBody,
+ HdsTextDisplay,
type HdsAdvancedTableOnSelectionChangeSignature,
+ type HdsFilterBarRangeFilter,
+ type HdsFilterBarDateFilter,
+ type HdsFilterBarSingleSelectFilter,
+ type HdsFilterBarMultiSelectFilter,
+ type HdsFilterBarSearchFilter,
+ type HdsFilterBarFilter,
+ type HdsFilterBarGenericFilter,
} 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',
@@ -117,6 +49,7 @@ const SAMPLE_MODEL = [
'run-status': 'errored',
'run-status-color': HdsBadgeColorValues.Critical,
'current-run-applied': 'Mar 06, 2025 09:10:14 am',
+ 'creation-time': '09:13:13',
'vcs-repo': 'example/a))!hzfpKcBl0',
'module-count': 46,
modules: 'wad-bedzeaje-rogmejca',
@@ -134,6 +67,7 @@ const SAMPLE_MODEL = [
'run-status': 'applied',
'run-status-color': HdsBadgeColorValues.Success,
'current-run-applied': 'Mar 06, 2025 09:09:14 am',
+ 'creation-time': '22:22:45',
'vcs-repo': 'example/tp7Xe!mDHlI[70ZO1',
'module-count': 152,
modules: 'wad-bedzeaje-rogmejca',
@@ -151,7 +85,8 @@ 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',
+ 'creation-time': '11:05:33',
+ 'vcs-repo': 'example/a))!hzfpKcBl0',
'module-count': 31,
modules: 'wad-bedzeaje-rogmejca',
'provider-count': 42,
@@ -168,7 +103,8 @@ 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',
+ 'creation-time': '20:44:21',
+ 'vcs-repo': 'example/a))!hzfpKcBl0',
'module-count': 58,
modules: 'wad-bedzeaje-rogmejca',
'provider-count': 140,
@@ -185,7 +121,8 @@ 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',
+ 'creation-time': '07:59:59',
+ 'vcs-repo': 'example/a))!hzfpKcBl0',
'module-count': 32,
modules: 'wad-bedzeaje-rogmejca',
'provider-count': 50,
@@ -202,7 +139,8 @@ 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',
+ 'creation-time': '20:30:00',
+ 'vcs-repo': 'example/a))!hzfpKcBl0',
'module-count': 94,
modules: 'wad-bedzeaje-rogmejca',
'provider-count': 113,
@@ -219,6 +157,7 @@ const SAMPLE_MODEL = [
'run-status': 'applied',
'run-status-color': HdsBadgeColorValues.Success,
'current-run-applied': 'Mar 06, 2025 09:04:14 am',
+ 'creation-time': '10:15:30',
'vcs-repo': 'example/&j[RmmtjpQX6',
'module-count': 117,
modules: 'wad-bedzeaje-rogmejca',
@@ -236,13 +175,14 @@ 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',
+ 'creation-time': '09:45:00',
+ '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 +193,14 @@ 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',
+ 'creation-time': '10:30:00',
+ '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 +211,14 @@ 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',
+ 'creation-time': '11:00:00',
+ '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 +229,14 @@ 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',
+ 'creation-time': '10:45:00',
+ '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',
},
@@ -304,13 +247,14 @@ const SAMPLE_MODEL = [
'run-status': 'applied',
'run-status-color': HdsBadgeColorValues.Success,
'current-run-applied': 'Mar 06, 2025 09:00:14 am',
+ 'creation-time': '10:45:00',
'vcs-repo': 'example/d2s3B46I10',
'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 23 2025',
updated: 'Feb 23 2025',
},
@@ -321,13 +265,14 @@ 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',
+ 'creation-time': '09:50:00',
+ '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 +283,13 @@ 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',
+ 'creation-time': '10:10:00',
+ '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 +301,13 @@ 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',
+ 'creation-time': '10:20:00',
+ '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',
@@ -372,12 +319,13 @@ const SAMPLE_MODEL = [
'run-status': 'errored',
'run-status-color': HdsBadgeColorValues.Critical,
'current-run-applied': 'Mar 06, 2025 08:57:14 am',
+ 'creation-time': '09:30:00',
'vcs-repo': 'example/JUha^7zr14',
'module-count': 114,
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',
@@ -389,12 +337,13 @@ const SAMPLE_MODEL = [
'run-status': 'applied',
'run-status-color': HdsBadgeColorValues.Success,
'current-run-applied': 'Mar 06, 2025 08:56:14 am',
+ 'creation-time': '10:05:00',
'vcs-repo': 'example/JUha^7zr14',
'module-count': 99,
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 +355,13 @@ 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',
+ 'creation-time': '09:55:00',
+ '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',
@@ -423,12 +373,13 @@ const SAMPLE_MODEL = [
'run-status': 'planned',
'run-status-color': HdsBadgeColorValues.Warning,
'current-run-applied': 'Mar 06, 2025 08:57:14 am',
+ 'creation-time': '09:40:00',
'vcs-repo': 'example/8G3C81*u*q*O$17',
'module-count': 107,
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',
@@ -440,12 +391,13 @@ const SAMPLE_MODEL = [
'run-status': 'errored',
'run-status-color': HdsBadgeColorValues.Critical,
'current-run-applied': 'Mar 06, 2025 08:56:14 am',
+ 'creation-time': '09:15:00',
'vcs-repo': 'example/gt]5*c!N1*N%I!m)18',
'module-count': 80,
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',
@@ -457,18 +409,141 @@ const SAMPLE_MODEL = [
'run-status': 'applied',
'run-status-color': HdsBadgeColorValues.Success,
'current-run-applied': 'Mar 06, 2025 08:57:14 am',
+ 'creation-time': '10:00:00',
'vcs-repo': 'example/gt]5*c!N1*N%I!m)18',
'module-count': 158,
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',
+ },
+ {
+ 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: 'Creation time',
+ key: 'creation-time',
+ 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 CUSTOM_FILTER = {
+ type: 'generic',
+ dismissTagText: 'equals example/a))!hzfpKcBl0',
+ data: {
+ value: 'example/a))!hzfpKcBl0',
+ },
+} as HdsFilterBarGenericFilter;
+
const updateModelWithSelectAllState = (
modelData: HdsAdvancedTableSignature['Args']['model'],
selectAllState: boolean,
@@ -499,6 +574,9 @@ export default class MockAppMainGenericAdvancedTable extends Component {
+ console.log('onFilter called with filters: ', filters);
+ this.filters = filters;
+ };
+
+ get demoModelFilteredData() {
+ const filterItem = (item: Record): boolean => {
+ if (Object.keys(this.filters).length === 0) return true;
+ let match = true;
+ Object.keys(this.filters).forEach((key) => {
+ const filter = this.filters[key] as HdsFilterBarFilter;
+ if (filter) {
+ switch (filter.type) {
+ case 'date':
+ case 'datetime':
+ case 'time':
+ if (!this.isDateFilterMatch(item[key], filter)) {
+ match = false;
+ }
+ break;
+ case 'range':
+ if (!this.isRangeFilterMatch(item[key], filter)) {
+ match = false;
+ }
+ break;
+ case 'single-select':
+ if (!this.isSingleSelectFilterMatch(item[key], filter)) {
+ match = false;
+ }
+ break;
+ case 'search':
+ if (!this.isSearchFilterMatch(item, filter)) {
+ match = false;
+ }
+ break;
+ case 'generic':
+ if (!this.isGenericFilterMatch(item[key], filter)) {
+ match = false;
+ }
+ break;
+ default:
+ if (!this.isMultiSelectFilterMatch(item[key], filter)) {
+ match = false;
+ }
+ }
+ }
+ });
+ return match;
+ };
+
+ const filteredData = this.demoModel.filter(filterItem);
+ return filteredData;
+ }
+
+ get noFilterData() {
+ return this.demoModelFilteredData.length === 0;
+ }
+
+ isRangeFilterMatch(
+ itemValue: unknown,
+ filter: HdsFilterBarRangeFilter,
+ ): boolean {
+ const filterData = filter.data;
+ const selector = filterData.selector;
+ const number = Number(itemValue);
+
+ const value = filterData.value;
+ const valueNumber = Number(value);
+
+ if (isNaN(number)) {
+ return false;
+ } else if (!isNaN(valueNumber)) {
+ switch (selector) {
+ case 'less-than':
+ return number < valueNumber;
+ case 'less-than-or-equal-to':
+ return number <= valueNumber;
+ case 'equal-to':
+ return number === valueNumber;
+ case 'greater-than-or-equal-to':
+ return number >= valueNumber;
+ case 'greater-than':
+ return number > valueNumber;
+ default:
+ return false;
+ }
+ } else if (selector === 'between' && typeof value === 'object') {
+ if (!value.start || !value.end) {
+ return false;
+ }
+ return number >= value.start && number <= value.end;
+ }
+
+ return false;
+ }
+
+ isDateFilterMatch(
+ itemValue: unknown,
+ filter: HdsFilterBarDateFilter,
+ ): boolean {
+ const filterData = filter.data;
+ const selector = filterData.selector;
+ const value = filterData.value;
+
+ const date = this.dateFromFilter(String(itemValue), filter.type);
+
+ if (selector === 'between' && typeof value === 'object') {
+ if (!value.start || !value.end) {
+ return false;
+ }
+ const startDate = this.dateFromFilter(value.start, filter.type);
+ const endDate = this.dateFromFilter(value.end, filter.type);
+ if (this.dateIsValid(startDate) && this.dateIsValid(endDate)) {
+ return (
+ date.getTime() >= startDate.getTime() &&
+ date.getTime() <= endDate.getTime()
+ );
+ } else {
+ return false;
+ }
+ } else if (typeof value === 'string') {
+ const valueDate = this.dateFromFilter(value, filter.type);
+ if (this.dateIsValid(valueDate)) {
+ switch (selector) {
+ case 'before':
+ return date.getTime() < valueDate.getTime();
+ case 'exactly':
+ return date.getTime() === valueDate.getTime();
+ case 'after':
+ return date.getTime() > valueDate.getTime();
+ default:
+ return false;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ isSingleSelectFilterMatch(
+ itemValue: unknown,
+ filter: HdsFilterBarSingleSelectFilter,
+ ): boolean {
+ return itemValue === filter.data.value;
+ }
+
+ isMultiSelectFilterMatch(
+ itemValue: unknown,
+ filter: HdsFilterBarMultiSelectFilter,
+ ): boolean {
+ const filterValues = filter.data.map((d) => d.value);
+ return filterValues.includes(itemValue);
+ }
+
+ isSearchFilterMatch(
+ item: Record,
+ filter: HdsFilterBarSearchFilter,
+ ): boolean {
+ let match = false;
+ Object.keys(item).forEach((key) => {
+ const itemValue = item[key];
+ const filterValue = filter.data.value;
+ if (
+ typeof itemValue === 'string' &&
+ typeof filterValue === 'string' &&
+ itemValue.toLowerCase().includes(filterValue.toLowerCase())
+ ) {
+ match = true;
+ }
+ });
+ return match;
+ }
+
+ isGenericFilterMatch(
+ itemValue: unknown,
+ filter: HdsFilterBarGenericFilter,
+ ): boolean {
+ if (Array.isArray(filter.data)) {
+ const filterValues = filter.data.map((d) => d.value);
+ return filterValues.includes(itemValue);
+ } else {
+ return itemValue === filter.data.value;
+ }
+ }
+
+ dateFromFilter = (dateString: string, filterType: string): Date => {
+ if (filterType === 'time') {
+ return new Date(`1970-01-01T${dateString}`);
+ }
+ return new Date(dateString);
+ };
+
+ dateIsValid = (date?: Date | string): date is Date =>
+ date instanceof Date && !isNaN(+date);
+
+ clearFilters = () => {
+ this.filters = {};
+ };
+
+ onSeparatedFilterBar = (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ this.isSeparatedFilterBar = target.checked;
+ };
+
+ onLiveFilterToggle = (event: Event) => {
+ const target = event.target as HTMLInputElement;
+ this.isLiveFilter = target.checked;
+ };
+
+
+
+ Separated filter bar component
+
+
+
+
+
+ Live filtering
+
+
+
+ {{#if this.isSeparatedFilterBar}}
+
+
+
+
+
+
+ access
+ homework
+ discovery
+ memories
+
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "name") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "project-name") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "run-status") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "terraform-version") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+
+
+
+
+
+ {{/if}}
+
+ <:actions as |A|>
+ {{#unless this.isSeparatedFilterBar}}
+
+
+
+
+
+
+ access
+ homework
+ discovery
+ memories
+
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "name") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "project-name") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each (get SAMPLE_MODEL_VALUES "run-status") as |option|}}
+ {{option.label}}
+ {{/each}}
+
+
+ {{#each
+ (get SAMPLE_MODEL_VALUES "terraform-version")
+ as |option|
+ }}
+ {{option.label}}
+ {{/each}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{/unless}}
+
<:body as |B|>
{{! @glint-expect-error }}
@@ -563,6 +1066,10 @@ export default class MockAppMainGenericAdvancedTable extends Component
+
+ {{! @glint-expect-error }}
+ {{get B.data "creation-time"}}
+
{{! @glint-expect-error }}
{{get B.data "vcs-repo"}}
@@ -607,6 +1114,23 @@ export default class MockAppMainGenericAdvancedTable extends Component
+ <:emptyState>
+ {{#if this.noFilterData}}
+
+ No data to display
+
+ No results were found with the selected filters. Please clear or
+ update the filters.
+
+
+
+
+
+ {{/if}}
+
}