From 6a559927c9542dbc29d274ef75c79bb5a0d0e7f3 Mon Sep 17 00:00:00 2001 From: John Kaeser Date: Thu, 10 Feb 2022 17:29:46 -0500 Subject: [PATCH] feat(filter-panel): add view all button for filter groups (#8188) ### Related Ticket(s) Resolves #7405 ### Description Adds support for "view all" buttons in `DDSFilterGroupItem` components. - The button text defaults to "View all" and can be changed with the `view-all-text` attribute. - The button appears when the number of filters in the group exceeds the `max-filters` attribute value. This defaults to 7, as per the functional specs. - When the button appears, the number of filters specified by the `filter-cutoff` attribute are shown at first (defaults to 5 as per functional specs). The hidden filters are revealed once the button has been clicked. - The shown/hidden filters are reset once the filter group is toggled closed and then re-opened. - There is one exception to this: if one of the filters that would be hidden has been selected, all filter items are revealed when re-opening the filter group. ### Changelog **New** - "View all" buttons render in `DDSFilterGroupItem` components when a sufficient number of filters are present in the group. **Changed** - Split the `DDSFilterPanelComposite`'s modal and desktop rendering into two methods to make it more obvious what's going on. --- .../__stories__/filter-panel.stories.ts | 19 +- .../filter-panel/filter-group-item.ts | 213 ++++++++++++++++- .../filter-panel/filter-panel-composite.ts | 80 +++++-- .../filter-panel/filter-panel-input-select.ts | 5 +- .../components/filter-panel/filter-panel.scss | 17 +- .../filter-panel/filter-panel.e2e.js | 214 +++++++++++++++++- 6 files changed, 509 insertions(+), 39 deletions(-) diff --git a/packages/web-components/src/components/filter-panel/__stories__/filter-panel.stories.ts b/packages/web-components/src/components/filter-panel/__stories__/filter-panel.stories.ts index b992f89db6a..dc2929a5ca7 100644 --- a/packages/web-components/src/components/filter-panel/__stories__/filter-panel.stories.ts +++ b/packages/web-components/src/components/filter-panel/__stories__/filter-panel.stories.ts @@ -1,25 +1,30 @@ /** * @license * - * Copyright IBM Corp. 2020, 2021 + * Copyright IBM Corp. 2020, 2022 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ import { html } from 'lit-element'; -import { text, select } from '@storybook/addon-knobs'; +import { text, select, number } from '@storybook/addon-knobs'; import '../index'; import readme from './README.stories.mdx'; export const Default = ({ parameters }) => { - const { heading, gridKnobs } = parameters?.props?.FilterPanel ?? {}; + const { heading, filterCutoff, maxFilters, viewAllText, gridKnobs } = parameters?.props?.FilterPanel ?? {}; return html`
${heading} - + API Application Data Set @@ -109,6 +114,9 @@ export default { knobs: { FilterPanel: ({ groupId }) => ({ heading: text('heading', 'Filter', groupId), + filterCutoff: number('Filter cutoff', 5, {}, groupId), + maxFilters: number('Max filters', 7, {}, groupId), + viewAllText: text('View all text', 'View all', groupId), gridKnobs: select('Grid alignment', ['3 columns', '4 columns'], '4 columns', groupId), }), }, @@ -116,6 +124,9 @@ export default { default: { FilterPanel: { heading: 'Filter', + filterCutoff: 5, + maxFilters: 7, + viewAllText: 'View all', gridKnobs: '4 columns', }, }, diff --git a/packages/web-components/src/components/filter-panel/filter-group-item.ts b/packages/web-components/src/components/filter-panel/filter-group-item.ts index 8c8901a7010..2913f341320 100644 --- a/packages/web-components/src/components/filter-panel/filter-group-item.ts +++ b/packages/web-components/src/components/filter-panel/filter-group-item.ts @@ -1,19 +1,27 @@ /** * @license * - * Copyright IBM Corp. 2020, 2021 + * Copyright IBM Corp. 2020, 2022 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. */ -import { customElement } from 'lit-element'; +import { customElement, property, query, state } from 'lit-element'; +import settings from 'carbon-components/es/globals/js/settings'; import ddsSettings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js'; import BXAccordionItem from 'carbon-web-components/es/components/accordion/accordion-item'; import styles from './filter-panel.scss'; import StableSelectorMixin from '../../globals/mixins/stable-selector'; +import DDSFilterPanelComposite from './filter-panel-composite'; +import DDSFilterPanelCheckbox from './filter-panel-checkbox'; +import DDSFilterPanelInputSelectItem from './filter-panel-input-select-item'; +import DDSFilterPanelInputSelect from './filter-panel-input-select'; const { stablePrefix: ddsPrefix } = ddsSettings; +const { prefix } = settings; + +const viewAllClassName = `${ddsPrefix}-filter-group-item__view-all`; /** * DDSFilterGroupItem renders each individual accordion @@ -30,6 +38,207 @@ class DDSFilterGroupItem extends StableSelectorMixin(BXAccordionItem) { } static styles = styles; // `styles` here is a `CSSResult` generated by custom WebPack loader + + static get viewAllSelector(): string { + return `button.${viewAllClassName}`; + } + + /** + * The element containing the default slot. + */ + @query(`.${prefix}--accordion__content`) + accordionContent: any; + + /** + * The text for the button that reveals all filters in the group. + */ + @property({ type: String, attribute: 'view-all-text' }) + viewAllText: string = 'View all'; + + /** + * The number of filters that can be shown without needing to hide any. + */ + @property({ type: Number, attribute: 'max-filters' }) + maxFilters: number = 7; + + /** + * The number of filters to show when not all filters are visible. + */ + @property({ type: Number, attribute: 'filter-cutoff' }) + filterCutoff: number = 5; + + /** + * Whether or not any hidden filters have been revealed. + */ + @property({ type: Boolean }) + allRevealed = false; + + /** + * An element to set focus to on reveal. + */ + @state() + _focusedElement: HTMLElement | null = null; + + /** + * Whether or not to add view all button functionality. + */ + protected _needsViewAll(): boolean { + return this.children.length > this.maxFilters; + } + + /** + * Checks if any filters beyond the cutoff point have been selected. + */ + protected _hasHiddenActiveFilter(): boolean { + const { children, filterCutoff } = this; + let result: boolean = false; + + [...children].slice(filterCutoff, children.length).forEach(elem => { + if (elem instanceof DDSFilterPanelCheckbox) { + if (elem.checked) result = true; + } + if (elem instanceof DDSFilterPanelInputSelectItem || elem instanceof DDSFilterPanelInputSelect) { + if (elem.selected) result = true; + } + }); + + return result; + } + + /** + * Hides or reveals excess filters. + */ + protected _handleAllRevealed(revealed: boolean): void { + const { children, filterCutoff, accordionContent } = this; + const hasHiddenActiveFilter = this._hasHiddenActiveFilter(); + + [...children].slice(filterCutoff, children.length).forEach(elem => { + (elem as HTMLElement).style.display = revealed || hasHiddenActiveFilter ? '' : 'none'; + }); + + if (!revealed && !hasHiddenActiveFilter) { + accordionContent.appendChild(this._renderViewAll()); + } + + this._dispatchViewAllEvent(revealed); + } + + /** + * Generates a view all button. + */ + protected _renderViewAll(): HTMLButtonElement { + const { children, filterCutoff } = this; + + const viewAll = document.createElement('button'); + viewAll.classList.add(viewAllClassName, `${prefix}--btn--ghost`); + viewAll.type = 'button'; + viewAll.innerText = this.viewAllText; + + viewAll.addEventListener( + 'click', + (e): void => { + this.allRevealed = true; + if (e.target instanceof HTMLElement) e.target.remove(); + + const firstHidden = children[filterCutoff]; + if (firstHidden instanceof HTMLElement) { + this._focusedElement = firstHidden; + } + }, + { passive: true, once: true } + ); + + return viewAll; + } + + /** + * Dispatches a custom event that notifies listeners whether or not this + * filter group has all options revealed. + */ + protected _dispatchViewAllEvent(removed: boolean): void { + const { eventViewAll } = this.constructor as typeof DDSFilterGroupItem; + this.dispatchEvent( + new CustomEvent(eventViewAll, { + bubbles: true, + cancelable: true, + composed: true, + detail: { + id: this.titleText, + value: removed, + }, + }) + ); + } + + /** + * Retrieves view all state stored in the filter panel composite. Returns + * internal value if no cache is found. + */ + protected _getCachedViewAllValue(): boolean { + const { allRevealed, titleText } = this; + let result: boolean = allRevealed; + + const filterPanel = this.closest('dds-filter-panel'); + if (filterPanel !== null) { + // Indicates this is composite's duplicated content. + let parentHost: Element | undefined; + const parent = filterPanel.parentNode; + if (parent instanceof ShadowRoot) { + parentHost = parent.host; + } + if (parentHost instanceof DDSFilterPanelComposite) { + const match = parentHost._filterGroupsAllRevealed.find(entry => { + return entry.id === titleText; + }); + if (match !== undefined) { + result = match.value; + } + } + } + + return result; + } + + protected firstUpdated(): void { + if (this._needsViewAll()) { + this.allRevealed = this._getCachedViewAllValue(); + } + } + + protected updated(_changedProperties: Map): void { + const { allRevealed, _focusedElement } = this; + if (this._needsViewAll()) { + const prevOpen = _changedProperties.get('open'); + const hasAllRevealed = _changedProperties.has('allRevealed'); + const prevAllRevealed = _changedProperties.get('allRevealed'); + + // Reset `allRevealed` on accordion close. + if (prevOpen) { + this.allRevealed = this._hasHiddenActiveFilter() || false; + } + + // Respect `allRevealed` attribute. + if (hasAllRevealed) { + if (prevAllRevealed === undefined) { + this._handleAllRevealed(this._getCachedViewAllValue()); + } else { + this._handleAllRevealed(allRevealed); + + if (allRevealed && _focusedElement instanceof HTMLElement) { + _focusedElement.focus(); + this._focusedElement = null; + } + } + } + } + } + + /** + * The name of the event that fires when the view all button is toggled. + */ + static get eventViewAll() { + return `${ddsPrefix}-filter-group-view-all-toggle`; + } } /* @__GENERATE_REACT_CUSTOM_ELEMENT_TYPE__ */ diff --git a/packages/web-components/src/components/filter-panel/filter-panel-composite.ts b/packages/web-components/src/components/filter-panel/filter-panel-composite.ts index b2abf025aeb..7e4dae02ac3 100644 --- a/packages/web-components/src/components/filter-panel/filter-panel-composite.ts +++ b/packages/web-components/src/components/filter-panel/filter-panel-composite.ts @@ -7,7 +7,7 @@ * LICENSE file in the root directory of this source tree. */ -import { customElement, html, LitElement, property } from 'lit-element'; +import { customElement, html, LitElement, property, TemplateResult } from 'lit-element'; import ddsSettings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js'; import settings from 'carbon-components/es/globals/js/settings'; import Filter from 'carbon-web-components/es/icons/filter/16'; @@ -15,7 +15,6 @@ import HostListenerMixin from 'carbon-web-components/es/globals/mixins/host-list import './filter-group'; import './filter-panel'; import './filter-panel-modal'; -import { baseFontSize, breakpoints } from '@carbon/layout'; import { unsafeHTML } from 'lit-html/directives/unsafe-html'; import HostListener from 'carbon-web-components/es/globals/decorators/host-listener'; import StableSelectorMixin from '../../globals/mixins/stable-selector'; @@ -25,7 +24,6 @@ import DDSFilterGroupItem from './filter-group-item'; const { prefix } = settings; const { stablePrefix: ddsPrefix } = ddsSettings; -const gridBreakpoint = parseFloat(breakpoints.md.width) * baseFontSize; /** * Filter panel composite @@ -118,6 +116,19 @@ class DDSFilterPanelComposite extends HostListenerMixin(StableSelectorMixin(LitE this.renderStatus(); }; + @HostListener('document:eventFilterGroupViewAllToggle') + protected _handleFilterGroupViewAllToggle = (event: CustomEvent) => { + const match = this._filterGroupsAllRevealed.findIndex(entry => { + return entry.id === event.detail.id; + }); + + if (match !== -1) { + this._filterGroupsAllRevealed[match].value = event.detail.value; + } else { + this._filterGroupsAllRevealed.push(event.detail); + } + }; + /** * handles modal close event */ @@ -271,6 +282,12 @@ class DDSFilterPanelComposite extends HostListenerMixin(StableSelectorMixin(LitE @property() _filterButtonTitle: string = ''; + /** + * stores which filter groups have revealed filters + */ + @property({ type: Array }) + _filterGroupsAllRevealed: { id: string; value: boolean }[] = []; + /** * Handles `slotchange` event. * @@ -300,31 +317,41 @@ class DDSFilterPanelComposite extends HostListenerMixin(StableSelectorMixin(LitE this._filterButtonTitle = this._title[0].innerText; } - protected _renderButton = gridBreakpoint < document.body.clientHeight; + /** + * Renders original content into the modal and listens for changes to this + * content to then be stored in `this._content`. + */ + protected _renderModal = (): TemplateResult => html` + + + + + `; + + /** + * Renders copies of slotted elements into the desktop presentation. + */ + protected _renderDesktop = (): TemplateResult => html` + + ${this._title.map(e => { + return html` + ${unsafeHTML((e as HTMLElement).outerHTML)} + `; + })} + ${this._contents.map(e => { + return html` + ${unsafeHTML((e as HTMLElement).outerHTML)} + `; + })} + + `; render() { return html` - - - - - - - - ${this._title.map(e => { - return html` - ${unsafeHTML((e as HTMLElement).outerHTML)} - `; - })} - ${this._contents.map(e => { - return html` - ${unsafeHTML((e as HTMLElement).outerHTML)} - `; - })} - + ${this._renderModal()} ${this._renderDesktop()} `; } @@ -344,6 +371,15 @@ class DDSFilterPanelComposite extends HostListenerMixin(StableSelectorMixin(LitE return `${ddsPrefix}-filter-panel-input-select`; } + /** + * The name of the custom event captured upon activating "view all" button in + * a filter group item + */ + + static get eventFilterGroupViewAllToggle() { + return `${ddsPrefix}-filter-group-view-all-toggle`; + } + /** * The name of the custom event captured upon closing the modal */ diff --git a/packages/web-components/src/components/filter-panel/filter-panel-input-select.ts b/packages/web-components/src/components/filter-panel/filter-panel-input-select.ts index 78d74fad31c..21d73d01c06 100644 --- a/packages/web-components/src/components/filter-panel/filter-panel-input-select.ts +++ b/packages/web-components/src/components/filter-panel/filter-panel-input-select.ts @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2020, 2021 + * Copyright IBM Corp. 2020, 2022 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -11,6 +11,7 @@ import { customElement, html, property, LitElement } from 'lit-element'; import ddsSettings from '@carbon/ibmdotcom-utilities/es/utilities/settings/settings.js'; import settings from 'carbon-components/es/globals/js/settings'; import Close from 'carbon-web-components/es/icons/close/16'; +import FocusMixin from 'carbon-web-components/es/globals/mixins/focus.js'; import StableSelectorMixin from '../../globals/mixins/stable-selector'; import styles from './filter-panel.scss'; import DDSFilterPanelInputSelectItem from './filter-panel-input-select-item'; @@ -24,7 +25,7 @@ const { stablePrefix: ddsPrefix } = ddsSettings; * @element dds-filter-panel-input-select */ @customElement(`${ddsPrefix}-filter-panel-input-select`) -class DDSFilterPanelInputSelect extends StableSelectorMixin(LitElement) { +class DDSFilterPanelInputSelect extends FocusMixin(StableSelectorMixin(LitElement)) { @property() ariaLabel = ''; diff --git a/packages/web-components/src/components/filter-panel/filter-panel.scss b/packages/web-components/src/components/filter-panel/filter-panel.scss index 7d8e257fb74..e0dbb4a61c4 100644 --- a/packages/web-components/src/components/filter-panel/filter-panel.scss +++ b/packages/web-components/src/components/filter-panel/filter-panel.scss @@ -1,7 +1,7 @@ /** * @license * - * Copyright IBM Corp. 2020, 2021 + * Copyright IBM Corp. 2020, 2022 * * This source code is licensed under the Apache-2.0 license found in the * LICENSE file in the root directory of this source tree. @@ -50,6 +50,21 @@ } } +:host(#{$dds-prefix}-filter-group-item) { + .#{$dds-prefix}-filter-group-item__view-all { + width: 100%; + padding: $spacing-03 $spacing-05; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: none; + border-color: $focus; + box-shadow: inset 0 0 0 $button-outline-width $focus, inset 0 0 0 $button-border-width $ui-background; + } + } +} + :host(#{$dds-prefix}-filter-modal-footer-button) { @extend :host(#{$prefix}-modal-footer-button); diff --git a/packages/web-components/tests/e2e-storybook/cypress/integration/filter-panel/filter-panel.e2e.js b/packages/web-components/tests/e2e-storybook/cypress/integration/filter-panel/filter-panel.e2e.js index 765bd1b8fb6..5e494482ec4 100644 --- a/packages/web-components/tests/e2e-storybook/cypress/integration/filter-panel/filter-panel.e2e.js +++ b/packages/web-components/tests/e2e-storybook/cypress/integration/filter-panel/filter-panel.e2e.js @@ -103,6 +103,101 @@ describe('dds-filter-panel | (desktop)', () => { .should('have.attr', 'selected'); cy.screenshot(_screenshotOptions); }); + + it('should only add view all button when enough filters are present', () => { + let filterCount; + + cy.visit(`${_path}&knob-Filter%20cutoff_FilterPanel=1&knob-Max%20filters_FilterPanel=1`) + .get(_selector) + .shadow() + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .click() + .find('.dds-filter-group-item__view-all') + .should('have.length', 1) + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .then(checkboxes => (filterCount = checkboxes.length)); + cy.visit(`${_path}&knob-Max%20filters_FilterPanel=${filterCount}`) + .get(_selector) + .shadow() + .find('dds-filter-group-item') + .first() + .click() + .find('.dds-filter-group-item__view-all') + .should('have.length', 0); + }); + + it('should support custom view all button text', () => { + let customText = 'Foo button'; + + cy.visit(`${_path}&knob-View%20all%20text_FilterPanel=${customText}`) + .get(_selector) + .shadow() + .find('dds-filter-group-item') + .first() + .click() + .find('.dds-filter-group-item__view-all') + .should('have.text', customText); + cy.screenshot(_screenshotOptions); + }); + + it('should re-hide excess elements when filter groups are closed and reopened', () => { + cy.visit(_path) + .get(_selector) + .shadow() + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .shadow() + .find('.bx--accordion__heading') + .as('toggle') + .click() + .get('@filterGroupItem') + .find('.dds-filter-group-item__view-all') + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .last() + .as('lastCheckbox') + .get('@toggle') + .click() + .click() + .get('@lastCheckbox') + .should('not.be.visible'); + cy.screenshot(_screenshotOptions); + }); + + it('should not re-hide elements when an element that would be hidden has been selected', () => { + cy.visit(_path) + .get(_selector) + .shadow() + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .shadow() + .find('.bx--accordion__heading') + .as('toggle') + .click() + .get('@filterGroupItem') + .find('.dds-filter-group-item__view-all') + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .last() + .as('lastCheckbox') + .shadow() + .find('input[type="checkbox') + .check(_checkOptions) + .get('@toggle') + .click() + .click() + .get('@lastCheckbox') + .should('be.visible'); + cy.screenshot(_screenshotOptions); + }); }); describe('dds-filter-panel | (mobile)', () => { @@ -111,13 +206,12 @@ describe('dds-filter-panel | (mobile)', () => { }); it('checkboxes should maintain state when transitioning to desktop', () => { - // Visit on mobile and open modal + // Check box on mobile cy.visit(_path) .get(_selector) .find('.bx--filter-button') - .click(); - // Check box on mobile - cy.get(_selector) + .click() + .get(_selector) .find('dds-filter-group-item') .first() .click() @@ -143,13 +237,12 @@ describe('dds-filter-panel | (mobile)', () => { }); it('select lists should maintain state when transitioning to desktop', () => { - // Visit on mobile and open modal + // Check box on mobile cy.visit(_path) .get(_selector) .find('.bx--filter-button') - .click(); - // Check box on mobile - cy.get(_selector) + .click() + .get(_selector) .find('dds-filter-group-item') .eq(1) .click() @@ -169,4 +262,109 @@ describe('dds-filter-panel | (mobile)', () => { .should('have.attr', 'selected'); cy.screenshot(_screenshotOptions); }); + + it('should only add view all button when enough filters are present', () => { + let filterCount; + + cy.visit(`${_path}&knob-Filter%20cutoff_FilterPanel=1&knob-Max%20filters_FilterPanel=1`) + .get(_selector) + .find('.bx--filter-button') + .click() + .get(_selector) + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .click() + .find('.dds-filter-group-item__view-all') + .should('have.length', 1) + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .then(checkboxes => (filterCount = checkboxes.length)); + cy.visit(`${_path}&knob-Max%20filters_FilterPanel=${filterCount}`) + .get(_selector) + .find('.bx--filter-button') + .click() + .get(_selector) + .find('dds-filter-group-item') + .first() + .click() + .find('.dds-filter-group-item__view-all') + .should('have.length', 0); + }); + + it('should support custom view all button text', () => { + let customText = 'Foo button'; + + cy.visit(`${_path}&knob-View%20all%20text_FilterPanel=${customText}`) + .get(_selector) + .find('.bx--filter-button') + .click() + .get(_selector) + .find('dds-filter-group-item') + .first() + .click() + .find('.dds-filter-group-item__view-all') + .should('have.text', customText); + cy.screenshot(_screenshotOptions); + }); + + it('should re-hide excess elements when filter groups are closed and reopened', () => { + cy.visit(_path) + .get(_selector) + .find('.bx--filter-button') + .click() + .get(_selector) + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .shadow() + .find('.bx--accordion__heading') + .as('toggle') + .click() + .get('@filterGroupItem') + .find('.dds-filter-group-item__view-all') + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .last() + .as('lastCheckbox') + .get('@toggle') + .click() + .click() + .get('@lastCheckbox') + .should('not.be.visible'); + cy.screenshot(_screenshotOptions); + }); + + it('should not re-hide elements when an element that would be hidden has been selected', () => { + cy.visit(_path) + .get(_selector) + .find('.bx--filter-button') + .click() + .get(_selector) + .find('dds-filter-group-item') + .first() + .as('filterGroupItem') + .shadow() + .find('.bx--accordion__heading') + .as('toggle') + .click() + .get('@filterGroupItem') + .find('.dds-filter-group-item__view-all') + .click() + .get('@filterGroupItem') + .find('dds-filter-panel-checkbox') + .last() + .as('lastCheckbox') + .shadow() + .find('input[type="checkbox"]') + .check(_checkOptions) + .get('@toggle') + .click() + .click() + .get('@lastCheckbox') + .should('be.visible'); + cy.screenshot(_screenshotOptions); + }); });