diff --git a/etc/lime-elements.api.md b/etc/lime-elements.api.md index f664202f37..ee4fd6a207 100644 --- a/etc/lime-elements.api.md +++ b/etc/lime-elements.api.md @@ -639,6 +639,14 @@ export namespace Components { "ui": EditorUiType; "value": string; } + // @beta + export interface LimelRadioButtonGroup { + "badgeIcons": boolean; + "disabled": boolean; + "items": Array; + "maxLinesSecondaryText": number; + "selectedItem"?: ListItem; + } // (undocumented) export interface LimelSelect { "disabled": boolean; @@ -1186,6 +1194,10 @@ export namespace JSX { // // (undocumented) "limel-prosemirror-adapter": LimelProsemirrorAdapter; + // Warning: (ae-incompatible-release-tags) The symbol ""limel-radio-button-group"" is marked as @public, but its signature references "JSX" which is marked as @beta + // + // (undocumented) + "limel-radio-button-group": LimelRadioButtonGroup; // (undocumented) "limel-select": LimelSelect; // (undocumented) @@ -1762,6 +1774,15 @@ export namespace JSX { "ui"?: EditorUiType; "value"?: string; } + // @beta + export interface LimelRadioButtonGroup { + "badgeIcons"?: boolean; + "disabled"?: boolean; + "items"?: Array; + "maxLinesSecondaryText"?: number; + "onChange"?: (event: LimelRadioButtonGroupCustomEvent>) => void; + "selectedItem"?: ListItem; + } // (undocumented) export interface LimelSelect { "disabled"?: boolean; @@ -2288,6 +2309,16 @@ export interface LimelProsemirrorAdapterCustomEvent extends CustomEvent { target: HTMLLimelProsemirrorAdapterElement; } +// Warning: (ae-missing-release-tag) "LimelRadioButtonGroupCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface LimelRadioButtonGroupCustomEvent extends CustomEvent { + // (undocumented) + detail: T; + // (undocumented) + target: HTMLLimelRadioButtonGroupElement; +} + // Warning: (ae-missing-release-tag) "LimelSelectCustomEvent" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/components/list/list-renderer.tsx b/src/components/list/list-renderer.tsx index a25718cc82..3d4bf09e65 100644 --- a/src/components/list/list-renderer.tsx +++ b/src/components/list/list-renderer.tsx @@ -4,7 +4,7 @@ import { MenuItem } from '../menu/menu.types'; import { h } from '@stencil/core'; import { CheckboxTemplate } from '../checkbox/checkbox.template'; import { ListRendererConfig } from './list-renderer-config'; -import { RadioButtonTemplate } from './radio-button/radio-button.template'; +import { RadioButtonTemplate } from '../radio-button-group/radio-button.template'; import { getIconColor, getIconName, diff --git a/src/components/list/list.scss b/src/components/list/list.scss index cfe1331a0d..26301d540c 100644 --- a/src/components/list/list.scss +++ b/src/components/list/list.scss @@ -310,7 +310,7 @@ img { @import '../checkbox/checkbox.scss'; -@import './radio-button/radio-button.scss'; +@import '../radio-button-group/radio-button.scss'; @import './partial-styles/custom-styles.scss'; @import './partial-styles/enable-multiline-text.scss'; diff --git a/src/components/list/radio-button/radio-button.template.tsx b/src/components/list/radio-button/radio-button.template.tsx deleted file mode 100644 index da3947d131..0000000000 --- a/src/components/list/radio-button/radio-button.template.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { FunctionalComponent, h } from '@stencil/core'; - -interface RadioButtonTemplateProps { - disabled?: boolean; - id: string; - checked?: boolean; - onChange?: (event: Event) => void; - label?: string; -} - -export const RadioButtonTemplate: FunctionalComponent< - RadioButtonTemplateProps -> = (props) => { - return ( -
- -
- -
- ); -}; diff --git a/src/components/radio-button-group/examples/radio-button-group-basic.tsx b/src/components/radio-button-group/examples/radio-button-group-basic.tsx new file mode 100644 index 0000000000..6ccb73476b --- /dev/null +++ b/src/components/radio-button-group/examples/radio-button-group-basic.tsx @@ -0,0 +1,70 @@ +import { + ListItem, + LimelRadioButtonGroupCustomEvent, + ListSeparator, +} from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; + +/** + * Basic radio button group + * + * :::note + * Individual options can also be disabled while keeping others interactive. + * ::: + */ +@Component({ tag: 'limel-example-radio-button-group-basic', shadow: true }) +export class RadioButtonBasicExample { + @State() + private selectedItem: ListItem; + + @State() + private disabled = false; + + public componentWillLoad() { + this.selectedItem = this.items.find((item) => item.selected); + } + + private items: Array = [ + { text: 'First Option', value: 'option1', selected: false }, + { text: 'Second Option', value: 'option2', selected: true }, + { separator: true }, + { text: 'Third Option', value: 'option3', selected: false }, + { text: 'Disabled Option', value: 'option4', disabled: true }, + ]; + + public render() { + return [ + , + + + , + , + ]; + } + + private handleChange = ( + event: LimelRadioButtonGroupCustomEvent> + ) => { + const item = event.detail; + if (item.selected) { + this.selectedItem = item; + } + }; + + private setDisabled = (event: CustomEvent) => { + event.stopPropagation(); + this.disabled = event.detail; + }; +} diff --git a/src/components/radio-button-group/examples/radio-button-group-deselect-selected.tsx b/src/components/radio-button-group/examples/radio-button-group-deselect-selected.tsx new file mode 100644 index 0000000000..65ca23c863 --- /dev/null +++ b/src/components/radio-button-group/examples/radio-button-group-deselect-selected.tsx @@ -0,0 +1,86 @@ +import { + ListItem, + LimelRadioButtonGroupCustomEvent, +} from '@limetech/lime-elements'; +import { Component, h, Host, State } from '@stencil/core'; + +/** + * Allowing to deselect a selected radio button + * + * In native radio button groups, users cannot deselect an already selected radio button. + * This is the standard behavior across most implementations; which we also showcased in the + * "Basic example", before this one. However, this interaction design comes with a + * significant user experience drawback. Once a user selects one choice, they cannot regret their choice. + * This could be problematic in some designs such as forms or survey. + * This is because a user cannot first answer a single choice question, + * and then decide to skip that question and leave it unanswered. + * + * As a developer, for such scenarios you always have to remember to provide a N/A choice or similar, + * to enable the user or respondents to skip that question. + * However, having a radio button group in a survey that allows respondents to leave it + * unanswered means that the question cannot be marked as "Required" or "Compulsory". + * A required question works best, when user has to select an option, and cannot unselect it. + * + * See how `handleChange` is implemented in this example, and compare it to the Basic example + * which does not allow deselecting the currently selected option. + */ +@Component({ + tag: 'limel-example-radio-button-group-deselect-selected', + shadow: true, +}) +export class RadioButtonDeselectSelectedExample { + @State() + private selectedItem: ListItem; + + @State() + private disabled: boolean = false; + + private items: Array> = [ + { text: 'Very satisfied', value: 'very-satisfied' }, + { text: 'Satisfied', value: 'satisfied', selected: true }, + { text: 'Neutral', value: 'neutral' }, + { text: 'Dissatisfied', value: 'dissatisfied' }, + { text: 'Very dissatisfied', value: 'very-dissatisfied' }, + ]; + + public componentWillLoad() { + this.selectedItem = this.items.find((item) => item.selected); + } + + public render() { + return ( + +

How satisfied are you with our product?

+ + + + + +
+ ); + } + + private handleChange = ( + event: LimelRadioButtonGroupCustomEvent> + ) => { + const item = event.detail; + this.selectedItem = item.selected === false ? undefined : item; + }; + + private setDisabled = (event: CustomEvent) => { + event.stopPropagation(); + this.disabled = event.detail; + }; +} diff --git a/src/components/radio-button-group/examples/radio-button-group-icons.tsx b/src/components/radio-button-group/examples/radio-button-group-icons.tsx new file mode 100644 index 0000000000..e4ce22d163 --- /dev/null +++ b/src/components/radio-button-group/examples/radio-button-group-icons.tsx @@ -0,0 +1,67 @@ +import { + ListItem, + LimelRadioButtonGroupCustomEvent, +} from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; + +/** + * Items with icons + */ +@Component({ tag: 'limel-example-radio-button-group-icons', shadow: true }) +export class RadioButtonIconsExample { + @State() + private selectedItem: ListItem; + + private items: Array> = [ + { + text: 'Sunny', + value: 'weather_sunny', + icon: { name: 'sun', color: 'rgb(var(--color-orange-default))' }, + }, + { + text: 'Partly Cloudy', + value: 'weather_partly_cloudy', + selected: true, + icon: { + name: 'partly_cloudy_day', + color: 'rgb(var(--color-gray-default))', + }, + }, + { + text: 'Rainy', + value: 'weather_rainy', + icon: { name: 'rain', color: 'rgb(var(--color-blue-default))' }, + }, + { + text: 'Snowy', + value: 'weather_snowy', + icon: { name: 'snowflake', color: 'rgb(var(--color-cyan-light))' }, + }, + ]; + + public componentWillLoad() { + this.selectedItem = this.items.find((item) => item.selected); + } + + public render() { + return [ + , + , + ]; + } + + private handleChange = ( + event: LimelRadioButtonGroupCustomEvent> + ) => { + const item = event.detail; + this.selectedItem = item.selected === false ? undefined : item; + }; +} diff --git a/src/components/radio-button-group/examples/radio-button-group-multiple-lines.tsx b/src/components/radio-button-group/examples/radio-button-group-multiple-lines.tsx new file mode 100644 index 0000000000..2a6c25891e --- /dev/null +++ b/src/components/radio-button-group/examples/radio-button-group-multiple-lines.tsx @@ -0,0 +1,103 @@ +import { + ListItem, + LimelRadioButtonGroupCustomEvent, + LimelSelectCustomEvent, + Option, +} from '@limetech/lime-elements'; +import { Component, h, State } from '@stencil/core'; + +/** + * With secondary text for items + * + * It is possible to add more descriptive text to each radio button option + * using the `secondaryText` property. + * + * You can also use the `maxLinesSecondaryText` prop to control how many lines of + * secondary text are displayed before they get truncated. + * By default, radio buttons will display 3 lines of secondary text. + */ +@Component({ + tag: 'limel-example-radio-button-group-multiple-lines', + shadow: true, +}) +export class RadioButtonGroupMultipleLinesExample { + @State() + private selectedItem: ListItem; + + @State() + private maxLines: number = 2; + + private maxLinesOptions: Option[] = [ + { text: '1 line', value: '1' }, + { text: '2 lines', value: '2' }, + { text: '3 lines', value: '3' }, + { text: '5 lines', value: '5' }, + ]; + + private items: Array> = [ + { + text: 'Basic Plan', + secondaryText: + 'Perfect for individuals and small teams just getting started. Includes basic features, 5GB storage, and email support.', + value: 'basic', + selected: true, + }, + { + text: 'Professional Plan', + secondaryText: + 'Ideal for growing businesses that need more advanced features. Includes everything in Basic plus 100GB storage, priority support, advanced analytics, team collaboration tools, and custom integrations.', + value: 'professional', + }, + { + text: 'Enterprise Plan', + secondaryText: + 'Designed for large organizations with complex needs. Includes unlimited storage, dedicated account manager, 24/7 phone support, advanced security features, custom branding, API access, and enterprise-grade compliance tools.', + value: 'enterprise', + }, + ]; + + public componentWillLoad() { + this.selectedItem = this.items.find((item) => item.selected); + } + + public render() { + return [ + , + + + , + , + ]; + } + + private getSelectedMaxLines() { + return this.maxLinesOptions.find( + (option) => option.value === this.maxLines.toString() + ); + } + + private handlePlanChange = ( + event: LimelRadioButtonGroupCustomEvent> + ) => { + const item = event.detail; + this.selectedItem = item.selected === false ? undefined : item!; + }; + + private handleMaxLinesChange = ( + event: LimelSelectCustomEvent> + ) => { + this.maxLines = +event.detail.value; + }; +} diff --git a/src/components/radio-button-group/radio-button-group.tsx b/src/components/radio-button-group/radio-button-group.tsx new file mode 100644 index 0000000000..706365556c --- /dev/null +++ b/src/components/radio-button-group/radio-button-group.tsx @@ -0,0 +1,101 @@ +import { Component, Event, EventEmitter, h, Prop } from '@stencil/core'; +import { ListItem, ListSeparator } from '../list/list-item.types'; +import { LimelListCustomEvent } from '@limetech/lime-elements'; + +/** + * The Radio Button component provides a convenient way to create a group of radio buttons + * from an array of options. Radio buttons allow users to select a single option from + * multiple choices, making them ideal for exclusive selections. + * + * :::note + * A single radio button is never useful in a UI. Radio buttons should always come in groups + * of at least 2 options where only one can be selected at a time. + * ::: + * + * @exampleComponent limel-example-radio-button-group-basic + * @exampleComponent limel-example-radio-button-group-deselect-selected + * @exampleComponent limel-example-radio-button-group-icons + * @exampleComponent limel-example-radio-button-group-multiple-lines + * @beta + */ +@Component({ + tag: 'limel-radio-button-group', + shadow: false, +}) +export class RadioButtonGroup { + /** + * Array of radio button options to display + */ + @Prop() + public items: Array; + + /** + * The currently selected item in the radio button group. + * This is a ListItem object that contains the value and other properties of the selected item. + * If no item is selected, this will be `undefined`. + */ + @Prop() + public selectedItem?: ListItem; + + /** + * Disables all radio buttons when `true` + */ + @Prop({ reflect: true }) + public disabled = false; + + /** + * Set to `true` if the radio button group should display larger icons with a background + */ + @Prop({ reflect: true }) + public badgeIcons: boolean; + + /** + * By default, lists will display 3 lines of text, and then truncate the rest. + * Consumers can increase or decrease this number by specifying + * `maxLinesSecondaryText`. If consumer enters zero or negative + * numbers we default to 1; and if they type decimals we round up. + */ + @Prop({ reflect: true }) + public maxLinesSecondaryText: number = 3; + + /** + * Emitted when the selection changes with the full ListItem payload + */ + @Event() + public change: EventEmitter>; + + public render() { + return ( + + ); + } + + private createItems(): Array { + return this.items.map((option: ListItem) => { + if ('separator' in option) { + return option; + } + return { + ...option, + selected: option.value === this.selectedItem?.value, + disabled: this.disabled || option.disabled, + }; + }); + } + + private handleChange = (event: LimelListCustomEvent) => { + event.stopPropagation(); + + if (this.disabled) { + return; + } + + this.change.emit(event.detail); + }; +} diff --git a/src/components/list/radio-button/radio-button.scss b/src/components/radio-button-group/radio-button.scss similarity index 100% rename from src/components/list/radio-button/radio-button.scss rename to src/components/radio-button-group/radio-button.scss diff --git a/src/components/radio-button-group/radio-button.template.tsx b/src/components/radio-button-group/radio-button.template.tsx new file mode 100644 index 0000000000..70e1131fcf --- /dev/null +++ b/src/components/radio-button-group/radio-button.template.tsx @@ -0,0 +1,64 @@ +import { FunctionalComponent, h } from '@stencil/core'; + +/** + * Radio Button Template + * + * This is a low-level template component that renders individual radio button elements + * using Material Design Components (MDC) styling and structure. It's used internally + * by the list component to render radio buttons when `type="radio"` is specified. + * + * ## Usage in the Library + * + * This template is primarily used by: + * - `limel-list` component when `type="radio"` + * - `limel-radio-button-group` component (which wraps `limel-list`) + * + * ## Why This Exists + * + * While we have `limel-radio-button-group` for most use cases, this template provides + * the actual radio button HTML structure with proper MDC classes and accessibility + * attributes. It ensures consistent styling and behavior across all radio button + * implementations in the library. + * + * ## Design Philosophy + * + * This follows the principle that individual radio buttons should not be standalone + * components, as a single radio button is never useful in a UI. Instead, this template + * is used to build groups of radio buttons through higher-level components. + * + * @internal + */ +interface RadioButtonTemplateProps { + disabled?: boolean; + id: string; + checked?: boolean; + onChange?: (event: Event) => void; + label?: string; +} + +export const RadioButtonTemplate: FunctionalComponent< + RadioButtonTemplateProps +> = (props) => { + return ( +
+ +
+ +
+ ); +};