Skip to content

Commit

Permalink
feat(searchable-select): support tag icons (VIV-2044) (#1917)
Browse files Browse the repository at this point in the history
* Support tag icons

* Refactor code

* Update screenshots
  • Loading branch information
RichardHelm authored Sep 30, 2024
1 parent d3353b3 commit f8d5f25
Show file tree
Hide file tree
Showing 13 changed files with 138 additions and 15 deletions.
12 changes: 12 additions & 0 deletions libs/components/src/lib/option/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,15 @@ If set, the `icon` attribute is ignored.
</vwc-option>
</div>
```

### Tag Icon

If the option is represented as a tag in a [`searchable-select`](/components/searchable-select/) component, you can use `tag-icon` slot to show an icon in the tag.

```html preview 230px
<vwc-searchable-select multiple>
<vwc-option value="afghanistan" text="Afghanistan" selected>
<vwc-icon slot="tag-icon" name="flag-afghanistan"></vwc-icon>
</vwc-option>
</vwc-searchable-select>
```
21 changes: 21 additions & 0 deletions libs/components/src/lib/searchable-select/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -719,6 +719,27 @@ Holds the available options as [Option](/components/option/) elements.
</vwc-searchable-select>
```

You can use the [Option's `tag-icon` slot](/components/option/#tag-icon) to display an icon next to the selected option's tag.

```html preview 320px
<vwc-searchable-select label="Country" clearable multiple>
<vwc-option
icon="flag-afghanistan"
value="afghanistan"
text="Afghanistan"
selected
>
<vwc-icon slot="tag-icon" name="flag-afghanistan"></vwc-icon>
</vwc-option>
<vwc-option icon="flag-albania" value="albania" text="Albania">
<vwc-icon slot="tag-icon" name="flag-albania"></vwc-icon>
</vwc-option>
<vwc-option icon="flag-algeria" value="algeria" text="Algeria">
<vwc-icon slot="tag-icon" name="flag-algeria"></vwc-icon>
</vwc-option>
</vwc-searchable-select>
```

### Icon

Set the `icon` slot to show an icon at the start of the input.
Expand Down
2 changes: 2 additions & 0 deletions libs/components/src/lib/searchable-select/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { FoundationElementDefinition } from '@microsoft/fast-foundation';
import { registerFactory } from '../../shared/design-system';
import { buttonRegistries } from '../button/definition';
import { popupRegistries } from '../popup/definition';
import { iconRegistries } from '../icon/definition';
import styles from './searchable-select.scss?inline';
import optionTagStyles from './option-tag.scss?inline';

Expand Down Expand Up @@ -36,6 +37,7 @@ export const searchableSelectDefinition =
export const searchableSelectRegistries = [
...buttonRegistries,
...popupRegistries,
...iconRegistries,
optionTagDefinition(),
searchableSelectDefinition(),
];
Expand Down
4 changes: 4 additions & 0 deletions libs/components/src/lib/searchable-select/option-tag.scss
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ slot[name='icon'] {
line-height: 1;
}

.icon-placeholder {
inline-size: calc($block-size / 1.5);
}

.remove-button {
display: flex;
align-items: center;
Expand Down
12 changes: 6 additions & 6 deletions libs/components/src/lib/searchable-select/option-tag.template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import type {
FoundationElementDefinition,
} from '@microsoft/fast-foundation';
import { classNames } from '@microsoft/fast-web-utilities';
import {
affixIconTemplateFactory,
IconWrapper,
} from '../../shared/patterns/affix';
import { Icon } from '../icon/icon';
import type { OptionTag } from './option-tag';

Expand Down Expand Up @@ -38,11 +34,15 @@ export const optionTagTemplate: (
context: ElementDefinitionContext,
definition: FoundationElementDefinition
) => ViewTemplate<OptionTag> = (context: ElementDefinitionContext) => {
const affixIconTemplate = affixIconTemplateFactory(context);
const iconTag = context.tagFor(Icon);

return html`<span class="${getClasses}" aria-disabled="${(x) => x.disabled}">
${(x) => affixIconTemplate(x.icon, IconWrapper.Slot)}
<slot name="icon" aria-hidden="true">
${when(
(x) => x.hasIconPlaceholder,
html`<div class="icon-placeholder"></div>`
)}
</slot>
${when(
(x) => x.label,
(x) => html<OptionTag>`<span class="label">${x.label!}</span>`
Expand Down
8 changes: 4 additions & 4 deletions libs/components/src/lib/searchable-select/option-tag.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { applyMixins, FoundationElement } from '@microsoft/fast-foundation';
import { attr } from '@microsoft/fast-element';
import { attr, observable } from '@microsoft/fast-element';
import { Shape } from '../enums';
import { AffixIcon } from '../../shared/patterns/affix';
import { Localized } from '../../shared/patterns';

export type OptionTagShape = Extract<Shape, Shape.Rounded | Shape.Pill>;
Expand All @@ -11,6 +10,7 @@ export class OptionTag extends FoundationElement {
@attr label?: string;
@attr({ mode: 'boolean' }) removable = false;
@attr({ mode: 'boolean' }) disabled = false;
@observable hasIconPlaceholder = false;

_onClickRemove() {
this.$emit('remove', undefined, {
Expand All @@ -19,5 +19,5 @@ export class OptionTag extends FoundationElement {
}
}

export interface OptionTag extends AffixIcon, Localized {}
applyMixins(OptionTag, AffixIcon, Localized);
export interface OptionTag extends Localized {}
applyMixins(OptionTag, Localized);
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import '../option';
import { Popup } from '../popup/popup';
import { ListboxOption } from '../option/option';
import { Button } from '../button/button';
import { Icon } from '../icon/icon.ts';
import { OptionTag } from './option-tag';
import { SearchableSelect } from './searchable-select';
import { searchableSelectDefinition } from './definition';

const COMPONENT_TAG = 'vwc-searchable-select';
const OPTION_TAG = 'vwc-option';
const ICON_TAG = 'vwc-icon';

describe('vwc-searchable-select', () => {
let element: SearchableSelect;
Expand Down Expand Up @@ -1492,6 +1494,41 @@ describe('vwc-searchable-select', () => {
});
});

describe('option tag icon', () => {
let icon: Icon;
beforeEach(async () => {
await setUpFixture(`
<${COMPONENT_TAG} multiple>
<${OPTION_TAG} value="apple" text="Apple" selected>
<${ICON_TAG} slot="tag-icon" name="apple-mono"></${ICON_TAG}>
</${OPTION_TAG}>
</${COMPONENT_TAG}>
`);
const tagIconSlot = getTag('Apple').shadowRoot!.querySelector(
'slot[name="icon"]'
) as HTMLSlotElement;
const iconForwarderSlot =
tagIconSlot.assignedElements()[0] as HTMLSlotElement;
icon = iconForwarderSlot.assignedElements()[0] as Icon;
});

it('should display an icon placed into the tag-icon slot of a selected option in the tag', async () => {
expect(icon.tagName).toBe(ICON_TAG.toUpperCase());
expect(icon.name).toBe('apple-mono');
});

it('should clone the icon into the light DOM', async () => {
expect(icon).not.toBe(element.querySelector('[slot="tag-icon"]'));
expect(icon.getRootNode()).toBe(document);
});

it('should cleanup the cloned when the option is unselected', async () => {
element.values = [];

expect(element.querySelectorAll(ICON_TAG).length).toBe(1);
});
});

xdescribe('a11y', () => {
it('should pass html a11y test', async () => {
element.label = 'Label';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ const tagTemplateFactory = (
@remove="${(x, c) => getComponent(c)._onTagRemoved(x)}"
@keydown="${(_, c) => getComponent(c)._onTagKeydown(c.event as KeyboardEvent)}"
@mousedown="${() => false}">
<slot slot="icon" name="${(x, c) =>
getComponent(c)._tagIconSlotName(x)}"></slot>
</${optionTagTag}>
</div>
`;
Expand Down
45 changes: 41 additions & 4 deletions libs/components/src/lib/searchable-select/searchable-select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,7 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
#updateSelectedOnSlottedOptions() {
for (const option of this._slottedOptions) {
option.selected = this.values.includes(option.value);
this.#updateClonedTagIconOfOption(option);
}
}

Expand Down Expand Up @@ -451,6 +452,39 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
this.#updateValuesThroughUserInteraction(newValues);
}

// --- Option tag icons ---

#clonedTagIcons = new Map<ListboxOption, HTMLElement>();

#tagIconOfOption(option: ListboxOption) {
return option.querySelector('[slot="tag-icon"]');
}

/**
* @internal
*/
_tagIconSlotName(value: string) {
return `_tag-icon-${this.values.indexOf(value)}`;
}

#updateClonedTagIconOfOption(option: ListboxOption) {
if (option.selected && this.#tagIconOfOption(option)) {
let clone = this.#clonedTagIcons.get(option);
if (!clone) {
clone = this.#tagIconOfOption(option)!.cloneNode(true) as HTMLElement;
this.#clonedTagIcons.set(option, clone);
}
clone.slot = this._tagIconSlotName(option.value);
this.appendChild(clone);
} else {
const clone = this.#clonedTagIcons.get(option);
if (clone) {
clone.remove();
this.#clonedTagIcons.delete(option);
}
}
}

// --- Option filtering ---

/**
Expand Down Expand Up @@ -608,11 +642,12 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
/**
* @internal
*/
#measureTagWidth(label: string, removable: boolean) {
#measureTagWidth(label: string, removable: boolean, hasIcon: boolean) {
const tag = document.createElement(this._optionTagTagName) as OptionTag;
tag.label = label;
tag.removable = removable;
tag.style.cssText = 'position: absolute; visibility: hidden;';
tag.hasIconPlaceholder = hasIcon;
this.shadowRoot!.appendChild(tag);
const width = tag.getBoundingClientRect().width;
tag.remove();
Expand Down Expand Up @@ -671,7 +706,8 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {

const tagWidth = this.#measureTagWidth(
this._tagLabelForValue(this.values[i])!,
true
true,
this.#tagIconOfOption(this.selectedOptions[i]) !== null
);
const entry: TagLayoutEntry = {
value: this.values[i],
Expand All @@ -684,7 +720,8 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
const numElidedTags = i;
if (numElidedTags) {
elidedTagCounterWidth =
TagGapPx + this.#measureTagWidth(numElidedTags.toString(), false);
TagGapPx +
this.#measureTagWidth(numElidedTags.toString(), false, false);
}
}

Expand Down Expand Up @@ -730,7 +767,7 @@ export class SearchableSelect extends FormAssociatedSearchableSelect {
if (i === 0 && this._numEllidedTags) {
lineWidth +=
TagGapPx +
this.#measureTagWidth(this._numEllidedTags.toString(), false);
this.#measureTagWidth(this._numEllidedTags.toString(), false, false);
}

// Pull up tags from the next line as long as they fit
Expand Down
10 changes: 9 additions & 1 deletion libs/components/src/lib/searchable-select/ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
loadTemplate,
} from '../../visual-tests/visual-tests-utils.js';

const components = ['searchable-select', 'option'];
const components = ['searchable-select', 'option', 'icon'];

const genOptions = (count: number) => {
const options = [];
Expand Down Expand Up @@ -70,6 +70,14 @@ test('should show the component', async ({ page }: { page: Page }) => {
<vwc-searchable-select max-lines="3" multiple>
${genOptions(30)}
</vwc-searchable-select>
<vwc-searchable-select multiple>
<vwc-option value="afghanistan" text="Afghanistan" selected>
<vwc-icon slot="tag-icon" name="flag-afghanistan"></vwc-icon>
</vwc-option>
<vwc-option value="albania" text="Albania" selected>
<vwc-icon slot="tag-icon" name="flag-albania"></vwc-icon>
</vwc-option>
</vwc-searchable-select>
</div>
`,
});
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit f8d5f25

Please sign in to comment.