diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index a36df2b77a..f29c2c26b2 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -83,6 +83,7 @@ export interface Chip { iconTitle?: string; id: number | string; image?: Image_2; + invalid?: boolean; loading?: boolean; menuItems?: Array; removable?: boolean; @@ -4179,6 +4180,7 @@ export interface ListItem { // @deprecated iconColor?: Color; image?: Image_2; + invalid?: boolean; primaryComponent?: ListComponent; secondaryText?: string; selected?: boolean; diff --git a/src/components/chip-set/chip-set.tsx b/src/components/chip-set/chip-set.tsx index 45738c0b98..d461a7816b 100644 --- a/src/components/chip-set/chip-set.tsx +++ b/src/components/chip-set/chip-set.tsx @@ -56,6 +56,7 @@ import { createRandomString } from '../../util/random-string'; * @exampleComponent limel-example-chip-set-filter * @exampleComponent limel-example-chip-set-filter-badge * @exampleComponent limel-example-chip-set-input + * @exampleComponent limel-example-chip-set-invalid-chips * @exampleComponent limel-example-chip-set-input-type-with-menu-items * @exampleComponent limel-example-chip-set-input-type-text * @exampleComponent limel-example-chip-set-input-type-search @@ -618,6 +619,7 @@ export class ChipSet { selected: chip.selected, disabled: this.disabled, loading: chip.loading, + invalid: chip.invalid, readonly: readonly, type: chipType, removable: removable, diff --git a/src/components/chip-set/chip.types.ts b/src/components/chip-set/chip.types.ts index 6e6c301c31..3497aa3e1b 100644 --- a/src/components/chip-set/chip.types.ts +++ b/src/components/chip-set/chip.types.ts @@ -108,6 +108,11 @@ export interface Chip { * indeterminate progress indicator inside the chip. */ loading?: boolean; + + /** + * Set to `true` to visualize the chip in an "invalid" or "error" state. + */ + invalid?: boolean; } /** diff --git a/src/components/chip-set/examples/chip-set-invalid-chips.tsx b/src/components/chip-set/examples/chip-set-invalid-chips.tsx new file mode 100644 index 0000000000..ad0aa5f486 --- /dev/null +++ b/src/components/chip-set/examples/chip-set-invalid-chips.tsx @@ -0,0 +1,98 @@ +import { Chip, LimelChipSetCustomEvent } from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; +import { ENTER } from '../../../util/keycodes'; + +/** + * Per-chip invalid state + * + * Set `invalid: true` on any chip in the `value` array to mark that + * specific chip as invalid. This is independent of the chip-set-level + * `invalid` prop, which is intended for signalling that the whole field + * is invalid. Per-chip `invalid` lets the consumer flag individual + * entries, for example an address that fails validation in a list of + * recipients. + * + * In this example, each entry is checked with a simple email regex when + * added. Invalid entries are rendered with `invalid: true` and an error + * icon. + */ +@Component({ + tag: 'limel-example-chip-set-invalid-chips', + shadow: true, +}) +export class ChipSetInvalidChipsExample { + @State() + private value: Chip[]; + + @State() + private textValue = ''; + + constructor() { + this.value = [ + this.createChip('alice@example.com'), + this.createChip('not-an-email'), + this.createChip('bob@example.com'), + ]; + } + + public render() { + return [ + , + , + ]; + } + + private handleInput = ( + event: LimelChipSetCustomEvent | InputEvent + ) => { + if (event instanceof CustomEvent) { + this.textValue = event.detail; + } + }; + + private onKeyUp = (event: KeyboardEvent) => { + if (event.key === ENTER && this.textValue.trim()) { + this.value = [ + ...this.value, + this.createChip(this.textValue.trim()), + ]; + this.textValue = ''; + } + }; + + private handleChange = (event: LimelChipSetCustomEvent) => { + this.value = event.detail; + }; + + private createChip = (text: string): Chip => { + const isValid = this.looksLikeEmail(text); + + return { + id: text, + text: text, + removable: true, + invalid: !isValid, + ...(!isValid && { icon: 'error' }), + }; + }; + + private looksLikeEmail = (text: string): boolean => { + const atIndex = text.indexOf('@'); + if (atIndex <= 0 || atIndex === text.length - 1) { + return false; + } + + const domain = text.slice(atIndex + 1); + + return domain.includes('.') && !/\s/.test(text); + }; +} diff --git a/src/components/list-item/list-item.types.ts b/src/components/list-item/list-item.types.ts index ff5d738a8c..e715048e58 100644 --- a/src/components/list-item/list-item.types.ts +++ b/src/components/list-item/list-item.types.ts @@ -49,6 +49,18 @@ export interface ListItem { */ selected?: boolean; + /** + * Set to `true` to visualize the item in an "invalid" or "error" state. + * + * :::note + * This flag is currently only honoured when the item is rendered as a + * chip by `limel-picker`. It has no visual effect in plain lists or + * menus today; see the `limel-list` / `limel-menu` roadmap for when + * that support is added. + * ::: + */ + invalid?: boolean; + /** * Value of the list item. */ diff --git a/src/components/picker/examples/picker-invalid-items.tsx b/src/components/picker/examples/picker-invalid-items.tsx new file mode 100644 index 0000000000..d392bec904 --- /dev/null +++ b/src/components/picker/examples/picker-invalid-items.tsx @@ -0,0 +1,64 @@ +import { LimelPickerCustomEvent, ListItem } from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; + +/** + * Per-item invalid state + * + * Set `invalid: true` on any `ListItem` passed to the picker's `value` to + * render that specific selection as an invalid chip. This is useful when + * a previously-valid selection is no longer valid (for example, a + * deactivated user or an archived tag) while other selections remain + * valid. + * + * This is independent of the picker-level `invalid` prop, which marks + * the whole field as invalid. + */ +@Component({ + tag: 'limel-example-picker-invalid-items', + shadow: true, +}) +export class PickerInvalidItemsExample { + private allItems: Array> = [ + { text: 'Admiral Swiggins', value: 1 }, + { text: 'Ayla', value: 2 }, + { text: 'Clunk', value: 3, invalid: true }, + { text: 'Coco', value: 4 }, + { text: 'Derpl', value: 5, invalid: true }, + { text: 'Froggy G', value: 6 }, + ]; + + @State() + private selectedItems: Array> = [ + this.allItems[0], + this.allItems[2], + this.allItems[3], + ]; + + public render() { + return [ + , + , + ]; + } + + private availableItems = (): Array> => { + return this.allItems.filter((item) => { + return !this.selectedItems.some((selected) => { + return selected.value === item.value; + }); + }); + }; + + private onChange = ( + event: LimelPickerCustomEvent>> + ) => { + this.selectedItems = [...event.detail]; + }; +} diff --git a/src/components/picker/picker.tsx b/src/components/picker/picker.tsx index 1efed84817..09d02e74a8 100644 --- a/src/components/picker/picker.tsx +++ b/src/components/picker/picker.tsx @@ -31,6 +31,7 @@ const DEFAULT_SEARCHER_MAX_RESULTS = 20; /** * @exampleComponent limel-example-picker-basic * @exampleComponent limel-example-picker-multiple + * @exampleComponent limel-example-picker-invalid-items * @exampleComponent limel-example-picker-icons * @exampleComponent limel-example-picker-pictures * @exampleComponent limel-example-picker-value-as-object @@ -356,6 +357,7 @@ export class Picker { image: listItem.image, value: listItem, menuItems: listItem.actions, + invalid: listItem.invalid, }; };