Skip to content
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
1 change: 1 addition & 0 deletions etc/lime-elements.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4269,6 +4269,7 @@ interface Option_2<T extends string = string> {
icon?: IconName | Icon;
// @deprecated
iconColor?: Color;
primaryComponent?: ListComponent;
secondaryText?: string;
text: string;
value: T;
Expand Down
11 changes: 2 additions & 9 deletions src/components/list-item/list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 <PrimaryComponent {...props} />;
return renderListComponent(this.primaryComponent);
};

private renderImage = () => {
Expand Down
121 changes: 121 additions & 0 deletions src/components/select/examples/select-with-primary-component.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<limel-select
label="Game night pick"
helperText="The number indicates how often we have played the game"
value={this.value}
options={this.options}
onChange={this.handleChange}
/>
<limel-example-value value={this.value} />
</section>
);
}

private readonly handleChange = (event) => {
this.value = event.detail;
};
}
11 changes: 11 additions & 0 deletions src/components/select/option.types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -53,4 +54,14 @@ export interface Option<T extends string = string> {
* ```
*/
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;
}
79 changes: 79 additions & 0 deletions src/components/select/select.e2e.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<limel-select
label="Test"
options={optionsWithPrimary}
value={optionsWithPrimary[0]}
></limel-select>
);
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(
<limel-select
label="Test"
options={optionsWithPrimary}
value={optionsWithPrimary[1]}
></limel-select>
);
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(
<limel-select
data-native
label="Test"
options={optionsWithPrimary}
></limel-select>
);
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(
<limel-select
data-native
label="Test"
options={optionsWithoutPrimary}
></limel-select>
);
await waitForChanges();

const nativeSelect = root.shadowRoot.querySelector('select');
expect(nativeSelect).not.toBeNull();
});
});
});
7 changes: 7 additions & 0 deletions src/components/select/select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 25 additions & 2 deletions src/components/select/select.template.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -126,6 +127,7 @@ const SelectValue: FunctionalComponent<
>
<span class="mdc-select__selected-text-container limel-select__selected-option">
{getSelectedIcon(props.value)}
{getSelectedPrimaryComponent(props.value)}
<span
id="s-selected-text"
class="mdc-select__selected-text limel-select__selected-option__text"
Expand Down Expand Up @@ -287,7 +289,7 @@ function createMenuItems(
}

const selected = isSelected(option, value);
const { text, secondaryText, disabled } = option;
const { text, secondaryText, disabled, primaryComponent } = option;
const name = getIconName(option.icon);

const color = getIconColor(option.icon, option.iconColor);
Expand All @@ -299,6 +301,7 @@ function createMenuItems(
selected: selected,
disabled: disabled,
value: option,
primaryComponent: primaryComponent,
};
}

Expand All @@ -312,6 +315,7 @@ function createMenuItems(
name: name,
color: color,
},
primaryComponent: primaryComponent,
};
});
}
Expand Down Expand Up @@ -358,8 +362,10 @@ function getSelectedText(value: Option | Option[]): string | VNode[] {

function renderOptionWithIcon(option: Option) {
const name = getIconName(option.icon);
const primary = renderPrimaryComponent(option);

if (!name) {
return option.text;
return [primary, option.text];
}

const color = getIconColor(option.icon, option.iconColor);
Expand All @@ -375,6 +381,7 @@ function renderOptionWithIcon(option: Option) {
size="small"
style={style}
/>,
primary,
option.text,
];
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading