Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(select): add badge in select options #549

Merged
merged 20 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,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 @@ -730,6 +732,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 @@ -227,9 +228,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 @@ -249,20 +254,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 @@ -331,4 +339,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 @@ -40,6 +41,7 @@ export class AtomSelect {
label?: string
selected?: boolean
disabled?: boolean
tag?: { color: string; label: string }
}> = []

@Event() atomBlur!: EventEmitter<void>
Expand All @@ -48,6 +50,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 @@ -67,6 +140,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 @@ -28,24 +45,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 @@ -98,3 +103,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
Loading