Skip to content

Commit

Permalink
feat(select): add badge in select options (#549)
Browse files Browse the repository at this point in the history
  • Loading branch information
Natalie9 authored Sep 9, 2024
1 parent 226aa78 commit f0252ca
Show file tree
Hide file tree
Showing 8 changed files with 283 additions and 40 deletions.
3 changes: 3 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,9 +184,11 @@ export namespace Components {
label?: string
selected?: boolean
disabled?: boolean
tag?: { color: string; label: string }
}>;
"placeholder": string;
"readonly"?: boolean;
"setTagInSelectOptions": () => Promise<void>;
"value"?: IonTypes.IonSelect['value'];
}
interface AtomTag {
Expand Down Expand Up @@ -728,6 +730,7 @@ declare namespace LocalJSX {
label?: string
selected?: boolean
disabled?: boolean
tag?: { color: string; label: string }
}>;
"placeholder"?: string;
"readonly"?: boolean;
Expand Down
98 changes: 93 additions & 5 deletions packages/core/src/components/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -226,9 +227,13 @@ describe('AtomSelect', () => {
html: '<atom-select />',
})

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)
Expand All @@ -248,20 +253,23 @@ describe('AtomSelect', () => {
html: '<atom-select />',
})

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 () => {
Expand Down Expand Up @@ -330,4 +338,84 @@ describe('AtomSelect', () => {

expect(handleDismiss).not.toHaveBeenCalled()
})

it('should filter options with tag', async () => {
const page = await newSpecPage({
components: [AtomSelect],
html: '<atom-select />',
})

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: '<atom-select />',
})

page.rootInstance.options = optionsMock
await page.waitForChanges()

const generateItems = (texts: Array<string>) => {
return texts.map((text) => {
const ionItem = document.createElement('ion-item')
const ionRadio = document.createElement('ion-radio')
const radioShadow = ionRadio.attachShadow({ mode: 'open' })

radioShadow.innerHTML = `<div><p>${text}</p></div>`
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<HTMLElement>)

await page.waitForChanges()

page.rootInstance.setTagInSelectOptions()

await page.waitForChanges()

expect(items[0]).toEqualHtml(`
<ion-item>
apple
<ion-radio>
<mock:shadow-root>
<div>
<p>apple</p>
</div>
</mock:shadow-root>
</ion-radio>
</ion-item>
`)

expect(items[2]).toEqualHtml(`
<ion-item>
orange
<ion-radio>
<mock:shadow-root>
<div style="justify-content: start;">
<p style="margin-right: 0;">orange</p>
<atom-tag class="atom-tag" color="success" style="margin-left: var(--spacing-xsmall);">
New
</atom-tag>
</div>
</mock:shadow-root>
</ion-radio>
</ion-item>
`)
})
})
75 changes: 75 additions & 0 deletions packages/core/src/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Host,
Prop,
h,
Method,
} from '@stencil/core'

import { IconProps } from '../../icons'
Expand Down Expand Up @@ -39,6 +40,7 @@ export class AtomSelect {
label?: string
selected?: boolean
disabled?: boolean
tag?: { color: string; label: string }
}> = []

@Event() atomBlur!: EventEmitter<void>
Expand All @@ -47,6 +49,77 @@ export class AtomSelect {
@Event() atomDismiss!: EventEmitter<void>
@Event() atomFocus!: EventEmitter<void>

@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)
}
Expand All @@ -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()
}
Expand Down
56 changes: 39 additions & 17 deletions packages/core/src/components/select/stories/select.core.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<atom-select
placeholder=${args.placeholder}
Expand All @@ -27,24 +44,12 @@ const createSelect = (args) => {
<script>
;(function () {
const atomSelectElements = document.querySelectorAll('atom-select')
const lastElement = atomSelectElements[atomSelectElements.length - 1]
atomSelectElements.forEach((atomSelect) => {
atomSelect.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 },
]
lastElement.options = ${JSON.stringify(options)}
atomSelect.addEventListener('atomChange', (event) => {
console.log('atomChange', event)
})
lastElement.addEventListener('atomChange', (event) => {
console.log('atomChange', event)
})
})()
</script>
Expand Down Expand Up @@ -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,
},
}
Loading

0 comments on commit f0252ca

Please sign in to comment.