From f0252caa3ccfb13044991a7141be0c6eb64d7f1e Mon Sep 17 00:00:00 2001 From: Natalie Tolentino Date: Mon, 9 Sep 2024 20:03:12 -0300 Subject: [PATCH] feat(select): add badge in select options (#549) --- packages/core/src/components.d.ts | 3 + .../core/src/components/select/select.spec.ts | 98 ++++++++++++++++++- .../core/src/components/select/select.tsx | 75 ++++++++++++++ .../select/stories/select.core.stories.tsx | 56 +++++++---- .../select/stories/select.react.stories.tsx | 44 +++++++-- .../select/stories/select.vue.stories.tsx | 44 +++++++-- packages/core/src/components/tag/tag.spec.ts | 2 +- packages/core/src/components/tag/tag.tsx | 1 + 8 files changed, 283 insertions(+), 40 deletions(-) diff --git a/packages/core/src/components.d.ts b/packages/core/src/components.d.ts index 58483dc31..457a33895 100644 --- a/packages/core/src/components.d.ts +++ b/packages/core/src/components.d.ts @@ -184,9 +184,11 @@ export namespace Components { label?: string selected?: boolean disabled?: boolean + tag?: { color: string; label: string } }>; "placeholder": string; "readonly"?: boolean; + "setTagInSelectOptions": () => Promise; "value"?: IonTypes.IonSelect['value']; } interface AtomTag { @@ -728,6 +730,7 @@ declare namespace LocalJSX { label?: string selected?: boolean disabled?: boolean + tag?: { color: string; label: string } }>; "placeholder"?: string; "readonly"?: boolean; diff --git a/packages/core/src/components/select/select.spec.ts b/packages/core/src/components/select/select.spec.ts index ec4677312..0289139d3 100644 --- a/packages/core/src/components/select/select.spec.ts +++ b/packages/core/src/components/select/select.spec.ts @@ -7,10 +7,11 @@ const optionsMock: { selected?: boolean disabled?: boolean label?: string + tag?: { color: string; label: string } }[] = [ { value: 'apple', selected: true }, { value: 'banana', disabled: true }, - { value: 'orange' }, + { value: 'orange', tag: { color: 'success', label: 'New' } }, ] describe('AtomSelect', () => { @@ -226,9 +227,13 @@ describe('AtomSelect', () => { html: '', }) + page.rootInstance.options = optionsMock + await page.waitForChanges() - const selectEl = page.root?.shadowRoot?.querySelector('ion-select') + const selectEl = page.root?.shadowRoot?.querySelector( + 'ion-select' + ) as HTMLElement const spy = jest.fn() page.root?.addEventListener('ionFocus', spy) @@ -248,20 +253,23 @@ describe('AtomSelect', () => { html: '', }) + page.rootInstance.options = optionsMock + await page.waitForChanges() const selectEl = page.root?.shadowRoot?.querySelector('ion-select') - const spy = jest.fn() + const spyIonBlur = jest.fn() - page.root?.addEventListener('ionBlur', spy) + page.root?.addEventListener('ionBlur', spyIonBlur) if (selectEl) { selectEl.dispatchEvent(new Event('ionBlur')) } + await page.waitForChanges() page.root?.dispatchEvent(new CustomEvent('ionBlur')) - expect(spy).toHaveBeenCalled() + expect(spyIonBlur).toHaveBeenCalled() }) it('emits atomCancel event on select cancel', async () => { @@ -330,4 +338,84 @@ describe('AtomSelect', () => { expect(handleDismiss).not.toHaveBeenCalled() }) + + it('should filter options with tag', async () => { + const page = await newSpecPage({ + components: [AtomSelect], + html: '', + }) + + await page.waitForChanges() + const mockFiltered = optionsMock.filter((option) => option?.tag?.label) + const instanceObjetct = page.rootInstance.filterOptionsWithTag(optionsMock) + + expect(Object.keys(instanceObjetct).length).toEqual(mockFiltered.length) + }) + it('should filter options and attach tag element', async () => { + const page = await newSpecPage({ + components: [AtomSelect], + html: '', + }) + + page.rootInstance.options = optionsMock + await page.waitForChanges() + + const generateItems = (texts: Array) => { + return texts.map((text) => { + const ionItem = document.createElement('ion-item') + const ionRadio = document.createElement('ion-radio') + const radioShadow = ionRadio.attachShadow({ mode: 'open' }) + + radioShadow.innerHTML = `

${text}

` + ionItem.textContent = text + ionItem.appendChild(ionRadio) + + return ionItem + }) + } + + const items = generateItems(['apple', 'banana', 'orange']) + + page.rootInstance.optionsWithTag = + page.rootInstance.filterOptionsWithTag(optionsMock) + + jest + .spyOn(document, 'querySelectorAll') + .mockReturnValue(items as unknown as NodeListOf) + + await page.waitForChanges() + + page.rootInstance.setTagInSelectOptions() + + await page.waitForChanges() + + expect(items[0]).toEqualHtml(` + + apple + + +
+

apple

+
+
+
+
+ `) + + expect(items[2]).toEqualHtml(` + + orange + + +
+

orange

+ + New + +
+
+
+
+ `) + }) }) diff --git a/packages/core/src/components/select/select.tsx b/packages/core/src/components/select/select.tsx index bbdbb7368..e00363654 100644 --- a/packages/core/src/components/select/select.tsx +++ b/packages/core/src/components/select/select.tsx @@ -8,6 +8,7 @@ import { Host, Prop, h, + Method, } from '@stencil/core' import { IconProps } from '../../icons' @@ -39,6 +40,7 @@ export class AtomSelect { label?: string selected?: boolean disabled?: boolean + tag?: { color: string; label: string } }> = [] @Event() atomBlur!: EventEmitter @@ -47,6 +49,77 @@ export class AtomSelect { @Event() atomDismiss!: EventEmitter @Event() atomFocus!: EventEmitter + @Method() + setTagInSelectOptions() { + /** + * This method was necessary because the `ion-selection-option` loop does not allow customizations or custom components. + * So, to be able to add custom elements such as a tag or a badge inside an option of the `select` field, when the select + * is opened, the `onBlur` event triggers this method that performs a search for all `ion-item` elements (which is the + * final element rendered to list options) and filters the ones that need to be changed. + */ + + const ionItemElements = document.querySelectorAll('ion-item') + + ionItemElements?.forEach((itemElement) => { + const optionText = itemElement.textContent?.trim() + const optionWithTag = this.optionsWithTag[optionText] + + if (!optionWithTag) return + + const { color, label } = optionWithTag.tag + + const optionElement = + this.getElementByTag(itemElement, 'ion-radio') || + this.getElementByTag(itemElement, 'ion-checkbox') + const optionShadowRoot = optionElement.shadowRoot + .firstElementChild as HTMLElement + const firstElementInOption = + optionShadowRoot.firstElementChild as HTMLElement + + const tagElement = document.createElement('atom-tag') + + tagElement.setAttribute('color', color) + tagElement.style.marginLeft = 'var(--spacing-xsmall)' + tagElement.textContent = label + tagElement.classList.add('atom-tag') + + optionShadowRoot.style.justifyContent = 'start' + + firstElementInOption.style.marginRight = '0' + firstElementInOption.insertAdjacentElement('afterend', tagElement) + }) + } + + getElementByTag(element, name) { + return element.getElementsByTagName(name)[0] as HTMLElement + } + + filterOptionsWithTag = ( + options: Array<{ + label?: string + value?: string + tag?: { label: string; color: string } + }> + ) => { + return options?.reduce((optionsWithTag, option) => { + if (option?.tag?.label) { + const label = option.label || option.value + + if (label) { + optionsWithTag[label] = option + } + } + + return optionsWithTag + }, {}) + } + + optionsWithTag = {} + + componentWillLoad() { + this.optionsWithTag = this.filterOptionsWithTag(this.options) + } + componentDidLoad() { this.selectEl.addEventListener('ionDismiss', this.handleDismiss) } @@ -66,6 +139,8 @@ export class AtomSelect { } private handleBlur = () => { + if (Object.values(this.optionsWithTag).length) this.setTagInSelectOptions() + this.selectEl.removeEventListener('ionBlur', this.handleBlur) this.atomBlur.emit() } diff --git a/packages/core/src/components/select/stories/select.core.stories.tsx b/packages/core/src/components/select/stories/select.core.stories.tsx index 4d6f9d88f..12b940f31 100644 --- a/packages/core/src/components/select/stories/select.core.stories.tsx +++ b/packages/core/src/components/select/stories/select.core.stories.tsx @@ -8,7 +8,24 @@ export default { ...SelectStoryArgs, } as Meta -const createSelect = (args) => { +const optionsDefault = [ + { id: '1', value: 'Red', disabled: false }, + { + id: '2', + value: 'Green', + disabled: false, + }, + { id: '3', value: 'Blue', disabled: false }, + { + id: '4', + value: 'nice_blue', + disabled: false, + label: 'Nice Blue', + }, + { id: '5', value: 'Disabled example', disabled: true }, +] + +const createSelect = (args, options = optionsDefault) => { return html` { @@ -97,3 +102,20 @@ export const Multiple: StoryObj = { multiple: true, }, } + +const optionWithTag = [ + ...optionsDefault, + { + id: '3', + value: 'Nice Green', + disabled: false, + tag: { color: 'success', label: 'New ' }, + }, +] + +export const WithTag: StoryObj = { + render: (args) => createSelect(args, optionWithTag), + args: { + ...SelectComponentArgs, + }, +} diff --git a/packages/core/src/components/select/stories/select.react.stories.tsx b/packages/core/src/components/select/stories/select.react.stories.tsx index bf76c88e3..d21a0d1d1 100644 --- a/packages/core/src/components/select/stories/select.react.stories.tsx +++ b/packages/core/src/components/select/stories/select.react.stories.tsx @@ -10,7 +10,24 @@ export default { ...SelectStoryArgs, } as Meta -const createSelect = (args) => ( +const optionsDefault = [ + { id: '1', value: 'Red', disabled: false }, + { + id: '2', + value: 'Green', + disabled: false, + }, + { id: '3', value: 'Blue', disabled: false }, + { + id: '4', + value: 'nice_blue', + disabled: false, + label: 'Nice Blue', + }, + { id: '5', value: 'Disabled example', disabled: true }, +] + +const createSelect = (args, options = optionsDefault) => ( ( icon={args.icon} mode={args.mode} value={args.value} - options={[ - { id: '1', value: 'Red', disabled: false }, - { id: '2', value: 'Green', disabled: false }, - { id: '3', value: 'Blue', disabled: false }, - { id: '4', value: 'nice_blue', disabled: false, label: 'Nice Blue' }, - { id: '5', value: 'Disabled example', disabled: true }, - ]} + options={options} /> ) @@ -79,3 +90,20 @@ export const Multiple: StoryObj = { multiple: true, }, } + +const optionWithTag = [ + ...optionsDefault, + { + id: '3', + value: 'Nice Green', + disabled: false, + tag: { color: 'success', label: 'New ' }, + }, +] + +export const WithTag: StoryObj = { + render: (args) => createSelect(args, optionWithTag), + args: { + ...SelectComponentArgs, + }, +} diff --git a/packages/core/src/components/select/stories/select.vue.stories.tsx b/packages/core/src/components/select/stories/select.vue.stories.tsx index a59245ebd..e821b96c7 100644 --- a/packages/core/src/components/select/stories/select.vue.stories.tsx +++ b/packages/core/src/components/select/stories/select.vue.stories.tsx @@ -8,10 +8,26 @@ export default { ...SelectStoryArgs, } as Meta -const createSelect = (args) => ({ +const optionsDefault = [ + { id: '1', value: 'Red', disabled: false }, + { + id: '2', + value: 'Green', + disabled: false, + }, + { id: '3', value: 'Blue', disabled: false }, + { + id: '4', + value: 'nice_blue', + disabled: false, + label: 'Nice Blue', + }, + { id: '5', value: 'Disabled example', disabled: true }, +] +const createSelect = (args, options = optionsDefault) => ({ components: { AtomSelect }, setup() { - return { args } + return { args, options } }, template: ` ({ ${args.icon ? `icon="${args.icon}"` : ''} mode="${args.mode}" value="${args.value}" - :options="[ - { id: '1', value: 'Red', disabled: false }, - { id: '2', value: 'Green', disabled: false }, - { id: '3', value: 'Blue', disabled: false }, - { id: '4', value: 'nice_blue', disabled: false, label: 'Nice Blue' }, - { id: '5', value: 'Disabled example', disabled: true }, - ]" + :options="options" /> `, }) @@ -83,3 +93,19 @@ export const Multiple: StoryObj = { multiple: true, }, } +const optionWithTag = [ + ...optionsDefault, + { + id: '3', + value: 'Nice Green', + disabled: false, + tag: { color: 'success', label: 'New ' }, + }, +] + +export const WithTag: StoryObj = { + render: (args) => createSelect(args, optionWithTag), + args: { + ...SelectComponentArgs, + }, +} diff --git a/packages/core/src/components/tag/tag.spec.ts b/packages/core/src/components/tag/tag.spec.ts index 3e9735b94..b9b1b16fd 100644 --- a/packages/core/src/components/tag/tag.spec.ts +++ b/packages/core/src/components/tag/tag.spec.ts @@ -53,7 +53,7 @@ describe('atom-tag', () => { const pageRoot = await setup('success', mockedIcon) expect(pageRoot?.root?.shadowRoot) - .toEqualHtml(` + .toEqualHtml(` `) diff --git a/packages/core/src/components/tag/tag.tsx b/packages/core/src/components/tag/tag.tsx index 10d97844e..22094ecfa 100644 --- a/packages/core/src/components/tag/tag.tsx +++ b/packages/core/src/components/tag/tag.tsx @@ -34,6 +34,7 @@ export class AtomTag { }} part='tag' class='atom-tag' + mode='md' > {this.icon && (