diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index 12e9700404..827c8417fb 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -4269,6 +4269,7 @@ interface Option_2 { icon?: IconName | Icon; // @deprecated iconColor?: Color; + primaryComponent?: ListComponent; secondaryText?: string; text: string; value: T; diff --git a/src/components/list-item/list-item.tsx b/src/components/list-item/list-item.tsx index 0a23d67979..8d65582eb8 100644 --- a/src/components/list-item/list-item.tsx +++ b/src/components/list-item/list-item.tsx @@ -2,6 +2,7 @@ import { Component, Host, Prop, h } from '@stencil/core'; import { getIconName } from '../icon/get-icon-props'; import type { IconSize } from '../icon/icon.types'; import { createRandomString } from '../../util/random-string'; +import { renderListComponent } from '../../util/render-list-component'; import { ListItem } from './list-item.types'; import { MenuItem } from '../menu/menu.types'; import { ListSeparator } from '../../global/shared-types/separator.types'; @@ -244,15 +245,7 @@ export class ListItemComponent implements ListItem { }; private renderPrimaryComponent = () => { - const primary = this.primaryComponent; - if (!primary?.name) { - return; - } - - const PrimaryComponent: any = primary.name; - const props = primary.props || {}; - - return ; + return renderListComponent(this.primaryComponent); }; private renderImage = () => { diff --git a/src/components/select/examples/select-with-primary-component.tsx b/src/components/select/examples/select-with-primary-component.tsx new file mode 100644 index 0000000000..0ff4b3bf2c --- /dev/null +++ b/src/components/select/examples/select-with-primary-component.tsx @@ -0,0 +1,121 @@ +import { Option } from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; + +/** + * Select with a primary component + * + * Just like list items, options in `limel-select` can render a custom + * primary component using the `primaryComponent` prop. The primary + * component is rendered both in the dropdown list and in the trigger + * area, next to the selected value. + * + * :::note + * 1. The primary component does not become automatically disabled, + * once an option is disabled. Clicks on, or interactions with the + * component will still be registered on disabled options. + * You should handle the disabled state of the components accordingly. + * 2. Dropdowns with primary component in their items won't render as platform-native + * dropdown on mobile devices. + * ::: + * :::tip + * Primary components can carry their own host-level styles, or use + * `style` properties to fit the design of the select, or position + * themselves within it. In the example, the badge uses custom CSS + * properties to adjust its colors, and an `order` style on its host + * to appear on the right side of the selected option instead of the + * left, next to the dropdown arrow. + * ::: + */ +@Component({ + shadow: true, + tag: 'limel-example-select-with-primary-component', +}) +export class SelectExample { + @State() + public value: Option; + + private readonly options: Option[] = [ + { + text: 'King of Tokyo', + secondaryText: '2 players', + value: 'tokyo', + primaryComponent: { + name: 'limel-circular-progress', + props: { + value: 5, + maxValue: 10, + suffix: '%', + displayPercentageColors: true, + size: 'small', + }, + }, + }, + { + text: 'Smash Up!', + secondaryText: '2-5 players', + value: 'smash', + primaryComponent: { + name: 'limel-circular-progress', + props: { + value: 1, + maxValue: 10, + suffix: '%', + displayPercentageColors: true, + size: 'small', + }, + }, + }, + { + text: 'Pandemic', + secondaryText: '2-4 players', + value: 'pandemic', + primaryComponent: { + name: 'limel-circular-progress', + props: { + value: 8, + maxValue: 10, + suffix: '%', + displayPercentageColors: true, + size: 'small', + }, + }, + }, + { + text: 'Ticket to Ride', + secondaryText: '1-3 players', + value: 'ticket', + primaryComponent: { + name: 'limel-badge', + props: { + label: 'Completed', + style: { + order: 3, + '--badge-max-width': 'auto', + '--badge-text-color': 'rgb(var(--color-white))', + '--badge-background-color': + 'rgb(var(--color-green-default))', + }, + }, + }, + }, + ]; + + public render() { + return ( +
+ + +
+ ); + } + + private readonly handleChange = (event) => { + this.value = event.detail; + }; +} diff --git a/src/components/select/option.types.ts b/src/components/select/option.types.ts index 45bdc41772..315b8a2724 100644 --- a/src/components/select/option.types.ts +++ b/src/components/select/option.types.ts @@ -1,5 +1,6 @@ import { Color } from '../../global/shared-types/color.types'; import { Icon, IconName } from '../../global/shared-types/icon.types'; +import { ListComponent } from '../list-item/list-item.types'; /** * Describes an option for limel-select. * @public @@ -53,4 +54,14 @@ export interface Option { * ``` */ iconColor?: Color; + + /** + * Component used to render along with the icon and the option's text, + * both in the dropdown list and in the trigger area when the option is selected. + * + * Note: the primary component does not become automatically disabled + * when the option is disabled — clicks and interactions still register. + * The consumer should handle the disabled state of the component accordingly. + */ + primaryComponent?: ListComponent; } diff --git a/src/components/select/select.e2e.tsx b/src/components/select/select.e2e.tsx index c650f41e19..2391df15a8 100644 --- a/src/components/select/select.e2e.tsx +++ b/src/components/select/select.e2e.tsx @@ -421,4 +421,83 @@ describe('limel-select (menu)', () => { ); }); }); + + describe('with a primary component', () => { + const optionsWithPrimary: Option[] = [ + { + text: 'Option A', + value: 'a', + primaryComponent: { + name: 'limel-spinner', + props: { size: 'mini' }, + }, + }, + { text: 'Option B', value: 'b' }, + ]; + + it('renders the primary component in the trigger for the selected option', async () => { + const { root, waitForChanges } = await render( + + ); + await waitForChanges(); + + const primary = root.shadowRoot.querySelector( + '.limel-select__selected-option__primary-component' + ); + expect(primary).not.toBeNull(); + expect(primary.tagName.toLowerCase()).toBe('limel-spinner'); + }); + + it('does not render a primary component when the selected option has none', async () => { + const { root, waitForChanges } = await render( + + ); + await waitForChanges(); + + const wrapper = root.shadowRoot.querySelector( + '.limel-select__selected-option__primary-component' + ); + expect(wrapper).toBeNull(); + }); + + it('falls back to the menu dropdown on mobile when an option has a primary component', async () => { + const { root, waitForChanges } = await render( + + ); + await waitForChanges(); + + const nativeSelect = root.shadowRoot.querySelector('select'); + expect(nativeSelect).toBeNull(); + }); + + it('uses the native dropdown on mobile when no option has a primary component', async () => { + const optionsWithoutPrimary: Option[] = [ + { text: 'Option A', value: 'a' }, + { text: 'Option B', value: 'b' }, + ]; + const { root, waitForChanges } = await render( + + ); + await waitForChanges(); + + const nativeSelect = root.shadowRoot.querySelector('select'); + expect(nativeSelect).not.toBeNull(); + }); + }); }); diff --git a/src/components/select/select.scss b/src/components/select/select.scss index 5181adbd6b..9907fc5a4f 100644 --- a/src/components/select/select.scss +++ b/src/components/select/select.scss @@ -66,6 +66,13 @@ limel-notched-outline:not([invalid]:not([invalid='false'])) { flex-shrink: 0; } +.limel-select__selected-option__primary-component { + display: inline-flex; + align-items: center; + margin-right: 0.25rem; + flex-shrink: 0; +} + .multiple-selected-options { display: inline-flex; align-items: center; diff --git a/src/components/select/select.template.tsx b/src/components/select/select.template.tsx index d627607517..c821e84bef 100644 --- a/src/components/select/select.template.tsx +++ b/src/components/select/select.template.tsx @@ -2,6 +2,7 @@ import { ListItem, ListSeparator } from '../list-item/list-item.types'; import { Option } from '../select/option.types'; import { FunctionalComponent, h, VNode } from '@stencil/core'; import { isMultiple } from '../../util/multiple'; +import { renderListComponent } from '../../util/render-list-component'; import { getIconColor, getIconName } from '../icon/get-icon-props'; interface SelectTemplateProps { disabled?: boolean; @@ -126,6 +127,7 @@ const SelectValue: FunctionalComponent< > {getSelectedIcon(props.value)} + {getSelectedPrimaryComponent(props.value)} , + primary, option.text, ]; } @@ -406,6 +413,22 @@ function getSelectedIcon(value: Option | Option[]) { ); } +function getSelectedPrimaryComponent(value: Option | Option[]) { + // For multiple selections, primary components are rendered inline with text + if (isMultiple(value)) { + return; + } + + return renderPrimaryComponent(value); +} + +function renderPrimaryComponent(option: Option | undefined) { + return renderListComponent( + option?.primaryComponent, + 'limel-select__selected-option__primary-component' + ); +} + /** * * @param options diff --git a/src/components/select/select.tsx b/src/components/select/select.tsx index 17ab2f02f0..9f78c0c3eb 100644 --- a/src/components/select/select.tsx +++ b/src/components/select/select.tsx @@ -25,6 +25,7 @@ import { SelectTemplate, triggerIconColorWarning } from './select.template'; * @exampleComponent limel-example-select-with-secondary-text * @exampleComponent limel-example-select-multiple * @exampleComponent limel-example-select-multiple-icons + * @exampleComponent limel-example-select-with-primary-component * @exampleComponent limel-example-select-with-empty-option * @exampleComponent limel-example-select-preselected * @exampleComponent limel-example-select-change-options @@ -113,12 +114,19 @@ export class Select { private hasChanged: boolean = false; + private hasPrimaryComponentMemo: boolean = false; + @Watch('value') @Watch('options') protected resetHasChanged() { this.hasChanged = false; } + @Watch('options') + protected updateHasPrimaryComponent() { + this.hasPrimaryComponentMemo = this.computeHasPrimaryComponent(); + } + private checkValid: boolean = false; private mdcSelectHelperText: MDCSelectHelperText; private mdcFloatingLabel: MDCFloatingLabel; @@ -150,6 +158,8 @@ export class Select { if (Object.hasOwn(this.host.dataset, 'native')) { this.isMobileDevice = true; } + + this.hasPrimaryComponentMemo = this.computeHasPrimaryComponent(); } public componentDidLoad() { @@ -215,7 +225,7 @@ export class Select { open={this.openMenu} close={this.closeMenu} checkValid={this.checkValid} - native={this.isMobileDevice && !this.multiple} + native={this.shouldRenderNative()} dropdownZIndex={dropdownZIndex} anchor={this.getAnchorElement()} /> @@ -452,4 +462,18 @@ export class Select { (option): option is Option => !('separator' in option) ); } + + private shouldRenderNative(): boolean { + return ( + this.isMobileDevice && + !this.multiple && + !this.hasPrimaryComponentMemo + ); + } + + private computeHasPrimaryComponent(): boolean { + return this.getOptionsExcludingSeparators().some( + (option) => !!option.primaryComponent?.name + ); + } } diff --git a/src/util/render-list-component.tsx b/src/util/render-list-component.tsx new file mode 100644 index 0000000000..84ec5a9b53 --- /dev/null +++ b/src/util/render-list-component.tsx @@ -0,0 +1,40 @@ +import { h, VNode } from '@stencil/core'; +import { ListComponent } from '../components/list-item/list-item.types'; + +/** + * Renders a `ListComponent` as a JSX element. + * + * Returns `undefined` when no component is provided, when its `name` is + * empty, or when its `name` is not a valid custom-element tag (i.e. does + * not contain a hyphen). The hyphen requirement matches the HTML custom + * element spec and prevents the slot from being used to render built-in + * tags like `iframe`, `script`, or `a` with arbitrary attributes. + * + * If `extraClass` is provided, it is merged with any consumer-supplied + * `class` from `component.props` and applied directly to the rendered + * element. This lets host components style or position the rendered + * element without wrapping it (which would otherwise block consumer + * styles like `order` from reaching the surrounding flex layout). + * @param component the `ListComponent` to render, or `undefined` + * @param extraClass an optional class to apply to the rendered element, + * merged with any consumer-supplied `class` in `props` + */ +export function renderListComponent( + component: ListComponent | undefined, + extraClass?: string +): VNode | undefined { + if (!component?.name) { + return; + } + + if (!component.name.includes('-')) { + return; + } + + const Tag: any = component.name; + const props = component.props || {}; + const mergedClass = + [extraClass, props.class].filter(Boolean).join(' ') || undefined; + + return ; +}