From 1d580e1eb660214b45749773a3c1de14d04e80e1 Mon Sep 17 00:00:00 2001 From: Barkley Date: Thu, 11 Apr 2024 10:04:07 +0200 Subject: [PATCH] feat(ui-library): radio group - use slots instead of options array --- packages/js-example-app/src/index.client.ts | 6 + packages/js-example-app/src/index.server.ts | 19 ++- packages/ui-library/.babelrc | 12 +- packages/ui-library/package.json | 1 + .../src/components/radio-group/index.css.ts | 11 +- .../components/radio-group/index.stories.ts | 80 ++++------- .../src/components/radio-group/index.test.ts | 47 +++--- .../src/components/radio-group/index.ts | 134 ++++++++++-------- .../components/radio-group/renderFunction.ts | 5 +- .../src/components/radio/index.stories.ts | 6 +- .../ui-library/src/components/radio/index.ts | 90 +++++++++--- packages/ui-library/src/globals/events.ts | 4 +- packages/ui-library/src/globals/types.ts | 8 +- .../ui-library/src/utils/lit/decorators.ts | 52 +++++-- packages/ui-library/src/utils/lit/element.ts | 7 +- packages/ui-library/src/utils/lit/signals.ts | 13 ++ yarn.lock | 18 +++ 17 files changed, 338 insertions(+), 175 deletions(-) create mode 100644 packages/ui-library/src/utils/lit/signals.ts diff --git a/packages/js-example-app/src/index.client.ts b/packages/js-example-app/src/index.client.ts index d5ed2b0a4..8487e3dfb 100644 --- a/packages/js-example-app/src/index.client.ts +++ b/packages/js-example-app/src/index.client.ts @@ -9,6 +9,7 @@ import { BlrTabBar, BlrTextarea, BlrToggleSwitch, + BlrRadioGroup, } from '@boiler/ui-library'; async function hydrate() { @@ -44,6 +45,7 @@ function init() { const blrInputFieldNumber = document.getElementsByTagName('blr-input-field-number')[0] as BlrInputFieldNumber; const blrTextArea = document.getElementsByTagName('blr-textarea')[0] as BlrTextarea; const blrRadio = document.getElementsByTagName('blr-radio')[0] as BlrRadio; + const blrRadioGroup = document.getElementsByTagName('blr-radio-group')[0] as BlrRadioGroup; const blrToggleSwitch = document.getElementsByTagName('blr-label-toggleswitch')[0] as BlrToggleSwitch; const blrTabBar = document.getElementsByTagName('blr-tab-bar')[0] as BlrTabBar; @@ -185,6 +187,10 @@ function init() { addLog('blr-radio changed: ' + e.detail.selectedValue); }); + blrRadioGroup.addEventListener('blrSelectedValueChange', (e) => { + addLog('blr-radio value changed blrRadioGroupValueChange: ' + e.detail.selectedValue); + }); + blrToggleSwitch.addEventListener('blrFocus', () => { addLog('blr-toggleswitch focused'); }); diff --git a/packages/js-example-app/src/index.server.ts b/packages/js-example-app/src/index.server.ts index d995c915e..ba62affee 100644 --- a/packages/js-example-app/src/index.server.ts +++ b/packages/js-example-app/src/index.server.ts @@ -177,7 +177,7 @@ export function* renderIndex() { +
+

Radio Group

+ + + + + +
+

Tab Bar

slot { + display: flex; + flex-direction: row; + column-gap: 1rem; + height: 40px; } - .horizontal { - flex-direction: row; + .vertical > slot { + flex-direction: column; } `; diff --git a/packages/ui-library/src/components/radio-group/index.stories.ts b/packages/ui-library/src/components/radio-group/index.stories.ts index 6cb596270..a4f6dddf8 100644 --- a/packages/ui-library/src/components/radio-group/index.stories.ts +++ b/packages/ui-library/src/components/radio-group/index.stories.ts @@ -223,7 +223,12 @@ export default { }, }; -export const BlrRadioGroup = (params: BlrRadioGroupType) => BlrRadioGroupRenderFunction(params); +const radioButtonsAsChildren = html` + e.detail.selectedValue}> + e.detail.selectedValue}> + e.detail.selectedValue}> +`; +export const BlrRadioGroup = (params: BlrRadioGroupType) => BlrRadioGroupRenderFunction(params, radioButtonsAsChildren); BlrRadioGroup.storyName = 'Radio Group'; @@ -233,42 +238,12 @@ const defaultParams: BlrRadioGroupType & { } = { theme: 'Light', sizeVariant: 'md', - direction: 'horizontal', + direction: 'vertical', hasLegend: true, legend: 'Legend-text', hasHint: false, groupHintMessage: 'This is a small hint', groupHintMessageIcon: 'blrInfo', - options: [ - { - value: '0', - label: 'Option 1', - checked: false, - errorMessage: 'OMG! An error!', - hintMessage: 'This is a small hint', - }, - { - value: '1', - label: 'Option 2', - checked: false, - errorMessage: 'OMG! An error!', - hintMessage: 'This is a small hint', - }, - { - value: '2', - label: 'Option 3', - checked: true, - errorMessage: 'OMG! An error!', - hintMessage: 'This is a small hint', - }, - { - value: '4', - label: 'Option 4', - checked: false, - errorMessage: 'OMG! An error!', - hintMessage: 'This is a small hint', - }, - ], disabled: false, readonly: false, required: false, @@ -278,7 +253,6 @@ const defaultParams: BlrRadioGroupType & { ariaLabel: 'Radio Group', radioGroupId: 'Radio Group', name: 'Radio Group ', - blrChange: () => action('blrChange'), blrFocus: () => action('blrFocus'), blrBlur: () => action('blrBlur'), }; @@ -322,25 +296,25 @@ SizeVariant.story = { name: ' ' }; // /** // * The Radio Group component can have a horizontal or a vertical direction. // * */ -// export const Direction = () => { -// return html` -// ${sharedStyles} -//
-// ${BlrRadioGroup({ -// ...defaultParams, -// direction: 'vertical', -// legend: 'Vertical', -// })} -//
-//
-// ${BlrRadioGroup({ -// ...defaultParams, -// direction: 'horizontal', -// legend: 'Horizontal', -// })} -//
-// `; -// }; +export const Direction = () => { + return html` + ${sharedStyles} +
+ ${BlrRadioGroup({ + ...defaultParams, + direction: 'vertical', + legend: 'Vertical', + })} +
+
+ ${BlrRadioGroup({ + ...defaultParams, + direction: 'horizontal', + legend: 'Horizontal', + })} +
+ `; +}; /** * ## Content / Settings @@ -456,7 +430,7 @@ export const FormCaptionGroup = () => { groupErrorMessage: "OMG it's an error", hasHint: true, hasError: true, - groupErrorIcon: 'blrErrorFilled', + groupHintMessageIcon: 'blrErrorFilled', })}
`; diff --git a/packages/ui-library/src/components/radio-group/index.test.ts b/packages/ui-library/src/components/radio-group/index.test.ts index 0667dbb5e..5f1cf1677 100644 --- a/packages/ui-library/src/components/radio-group/index.test.ts +++ b/packages/ui-library/src/components/radio-group/index.test.ts @@ -2,9 +2,10 @@ import '@boiler/ui-library'; import { BlrRadioGroupRenderFunction } from './renderFunction.js'; -import { fixture, expect } from '@open-wc/testing'; +import { fixture, expect, aTimeout } from '@open-wc/testing'; import { querySelectorDeep } from 'query-selector-shadow-dom'; import { BlrRadioGroupType } from './index.js'; +import { html } from 'lit-html'; const sampleParams: BlrRadioGroupType = { theme: 'Light', @@ -13,11 +14,6 @@ const sampleParams: BlrRadioGroupType = { name: 'Default Name', required: false, readonly: false, - options: [ - { label: 'Multi-line option 1', value: 'option1', hintMessage: 'Hint 1', errorMessage: 'Error Message 1' }, - { label: 'Option 2', value: 'option2', hintMessage: 'Hint 2', errorMessage: 'Error Message 2' }, - { label: 'Option 3', value: 'option3', hintMessage: 'Hint 3', errorMessage: 'Error Message 3' }, - ], hasHint: true, groupHintMessage: 'This is a sample hint message', groupHintMessageIcon: 'blrInfo', @@ -27,9 +23,16 @@ const sampleParams: BlrRadioGroupType = { direction: 'horizontal', }; +const radioButtonsAsChildren = html` + + + + +`; + describe('blr-radio-group', () => { it('is having a radioGroup containing the right className', async () => { - const element = await fixture(BlrRadioGroupRenderFunction(sampleParams)); + const element = await fixture(BlrRadioGroupRenderFunction(sampleParams, radioButtonsAsChildren)); const radioGroup = querySelectorDeep('input[type="radio"]', element.getRootNode() as HTMLElement); const className = radioGroup?.className; @@ -38,7 +41,7 @@ describe('blr-radio-group', () => { }); it('has a size md by default', async () => { - const element = await fixture(BlrRadioGroupRenderFunction(sampleParams)); + const element = await fixture(BlrRadioGroupRenderFunction(sampleParams, radioButtonsAsChildren)); const radioGroup = querySelectorDeep('.blr-radio-group', element.getRootNode() as HTMLElement); const className = radioGroup?.className; @@ -47,7 +50,9 @@ describe('blr-radio-group', () => { }); it('has a size sm when "size" is set to "sm" ', async () => { - const element = await fixture(BlrRadioGroupRenderFunction({ ...sampleParams, sizeVariant: 'sm' })); + const element = await fixture( + BlrRadioGroupRenderFunction({ ...sampleParams, sizeVariant: 'sm' }, radioButtonsAsChildren) + ); const radioGroup = querySelectorDeep('.blr-radio-group', element.getRootNode() as HTMLElement); const className = radioGroup?.className; @@ -57,24 +62,30 @@ describe('blr-radio-group', () => { it('has a error state if hasError is true', async () => { const element = await fixture( - BlrRadioGroupRenderFunction({ - ...sampleParams, - hasError: true, - }) + BlrRadioGroupRenderFunction( + { + ...sampleParams, + hasError: true, + }, + radioButtonsAsChildren + ) ); const radioGroup = querySelectorDeep('input[type="radio"]', element.getRootNode() as HTMLElement); + await aTimeout(100); const className = radioGroup?.className; - expect(className).to.contain('error'); }); it('does not have a error state if hasError is false', async () => { const element = await fixture( - BlrRadioGroupRenderFunction({ - ...sampleParams, - hasError: false, - }) + BlrRadioGroupRenderFunction( + { + ...sampleParams, + hasError: false, + }, + radioButtonsAsChildren + ) ); const radioGroup = querySelectorDeep('input[type="radio"]', element.getRootNode() as HTMLElement); diff --git a/packages/ui-library/src/components/radio-group/index.ts b/packages/ui-library/src/components/radio-group/index.ts index 15ed1e765..57f006a80 100644 --- a/packages/ui-library/src/components/radio-group/index.ts +++ b/packages/ui-library/src/components/radio-group/index.ts @@ -1,4 +1,4 @@ -import { html, nothing } from 'lit'; +import { PropertyValues, html, nothing } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; import { property } from '../../utils/lit/decorators.js'; import { staticStyles as componentSpecificStaticStyles } from './index.css.js'; @@ -6,29 +6,43 @@ import { SizelessIconType } from '@boiler/icons'; import { ThemeType } from '../../foundation/_tokens-generated/index.themes.js'; import { staticStyles as staticRadioStyles } from '../../foundation/component-tokens/radio.css.js'; import { staticStyles as staticFormStyles } from '../../foundation/semantic-tokens/form.css.js'; -import { InputSizesType, RadioGroupDirection, RadioOption } from '../../globals/types.js'; +import { InputSizesType, RadioGroupDirection } from '../../globals/types.js'; import { BlrFormCaptionGroupRenderFunction } from '../form-caption-group/renderFunction.js'; import { BlrFormCaptionRenderFunction } from '../form-caption/renderFunction.js'; -import { BlrFormLabelInlineRenderFunction } from '../form-label/form-label-inline/renderFunction.js'; import { TAG_NAME } from './renderFunction.js'; import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; +import { + BlrBlurEvent, + BlrFocusEvent, + BlrSelectedValueChangeEvent, + createBlrSelectedValueChangeEvent, +} from '../../globals/events.js'; +import { BlrRadio } from '../radio/index.js'; +import { batch, Signal } from '@lit-labs/preact-signals'; + +export type BlrRadioGroupEventHandlers = { + blrFocus?: (event: BlrFocusEvent) => void; + blrBlur?: (event: BlrBlurEvent) => void; + blrSelectedValueChange?: (event: BlrSelectedValueChangeEvent) => void; +}; + +/** + * @fires blrFocus Radio received focus + * @fires blrBlur Radio lost focus + * @fires blrSelectedValueChange Radio selected value changed + */ export class BlrRadioGroup extends LitElementCustom { static styles = [staticFormStyles, staticRadioStyles, componentSpecificStaticStyles]; @property() accessor disabled: boolean | undefined; @property() accessor readonly: boolean | undefined; - @property() accessor checked: boolean | undefined; @property() accessor name: string | undefined; @property() accessor sizeVariant: InputSizesType = 'md'; @property() accessor hasLegend: boolean | undefined; @property() accessor required: boolean | undefined; - @property() accessor blrChange: HTMLElement['oninput'] | undefined; - @property() accessor blrBlur: HTMLElement['blur'] | undefined; - @property() accessor blrFocus: HTMLElement['focus'] | undefined; @property() accessor hasError: boolean | undefined; @property() accessor errorIcon: SizelessIconType | undefined; - @property() accessor options!: RadioOption[]; @property() accessor hasHint = true; @property() accessor groupHintMessageIcon: SizelessIconType | undefined; @property() accessor groupErrorMessage: string | undefined; @@ -36,9 +50,59 @@ export class BlrRadioGroup extends LitElementCustom { @property() accessor groupErrorMessageIcon: SizelessIconType | undefined; @property() accessor legend: string | undefined; @property() accessor direction: RadioGroupDirection = 'horizontal'; - @property() accessor theme: ThemeType = 'Light'; + protected _radioElements: BlrRadio[] = []; + private _selectedRadio?: BlrRadio; + private _radioCheckedSignalSubscriptionDisposers: ReturnType[] = []; + + protected handleRadioCheckedSignal = (target: BlrRadio, value?: boolean) => { + const selectedRadio: BlrRadio | undefined = value + ? target + : target === this._selectedRadio && !value + ? undefined + : this._selectedRadio; + + batch(() => { + this._radioElements?.forEach((radio) => { + if (radio !== selectedRadio) { + radio.checked = false; + } + }); + }); + + if (this._selectedRadio !== selectedRadio) { + this.dispatchEvent(createBlrSelectedValueChangeEvent({ selectedValue: (selectedRadio)?.value ?? '' })); + this._selectedRadio = selectedRadio; + } + }; + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected firstUpdated(_changedProperties: PropertyValues): void { + this.handleSlotChange(); + } + + protected handleSlotChange = () => { + // Cleanup signal listeners from previously slotted elements + this._radioCheckedSignalSubscriptionDisposers.forEach((cancelSubscription) => cancelSubscription()); + + const slot = this.renderRoot?.querySelector('slot'); + this._radioElements = slot?.assignedElements({ flatten: false }) as BlrRadio[]; + + // Add signal listeners to newly slotted elements + this._radioElements.forEach((item) => { + if (item instanceof BlrRadio === false) { + throw new Error('child component of blr-radio-group must be blr-radio'); + } + + item.hasError = this.hasError; + + this._radioCheckedSignalSubscriptionDisposers.push( + item.signals.checked.subscribe((value) => this.handleRadioCheckedSignal(item, value)) + ); + }); + }; + protected render() { if (!this.sizeVariant) { return null; @@ -56,25 +120,15 @@ export class BlrRadioGroup extends LitElementCustom { [this.sizeVariant]: this.sizeVariant, }); - const radioClasses = classMap({ - [this.theme]: this.theme, - [this.sizeVariant]: this.sizeVariant, - error: this.hasError || false, - }); - const classes = classMap({ [this.theme]: this.theme, [this.sizeVariant]: this.sizeVariant, disabled: this.disabled || false, readonly: this.readonly || false, - checked: this.checked || false, error: this.hasError || false, [this.direction]: this.direction, }); - const calculateOptionId = (label: string) => { - return label.replace(/ /g, '_').toLowerCase(); - }; const captionContent = html` ${this.hasHint && (this.groupHintMessage || this.groupHintMessageIcon) ? BlrFormCaptionRenderFunction({ @@ -98,44 +152,12 @@ export class BlrRadioGroup extends LitElementCustom { return html` ${this.hasLegend - ? html`
${this.legend}
` + ? html`
+ ${this.legend} +
` : nothing} -
- ${this.options && - this.options.map((option: RadioOption) => { - const id = calculateOptionId(option.label); - return html` -
- -
- ${option.label - ? html`${BlrFormLabelInlineRenderFunction({ - labelText: option.label, - forValue: id, - labelSize: this.sizeVariant || 'md', - theme: this.theme, - })}` - : nothing} -
-
- `; - })} +
${this.hasHint || this.hasError @@ -151,4 +173,4 @@ if (!customElements.get(TAG_NAME)) { customElements.define(TAG_NAME, BlrRadioGroup); } -export type BlrRadioGroupType = ElementInterface; +export type BlrRadioGroupType = ElementInterface; diff --git a/packages/ui-library/src/components/radio-group/renderFunction.ts b/packages/ui-library/src/components/radio-group/renderFunction.ts index e4ec97ec9..9c97c5fdd 100644 --- a/packages/ui-library/src/components/radio-group/renderFunction.ts +++ b/packages/ui-library/src/components/radio-group/renderFunction.ts @@ -1,7 +1,8 @@ +import { TemplateResult } from 'lit-html'; import { BlrRadioGroupType } from './index.js'; import { genericBlrComponentRenderer } from '../../utils/typesafe-generic-component-renderer.js'; export const TAG_NAME = 'blr-radio-group'; -export const BlrRadioGroupRenderFunction = (params: BlrRadioGroupType) => - genericBlrComponentRenderer(TAG_NAME, { ...params }); +export const BlrRadioGroupRenderFunction = (params: BlrRadioGroupType, children?: TemplateResult<1>) => + genericBlrComponentRenderer(TAG_NAME, { ...params }, children); diff --git a/packages/ui-library/src/components/radio/index.stories.ts b/packages/ui-library/src/components/radio/index.stories.ts index 5b21b0e66..b4b32b431 100644 --- a/packages/ui-library/src/components/radio/index.stories.ts +++ b/packages/ui-library/src/components/radio/index.stories.ts @@ -228,7 +228,7 @@ const args: BlrRadioType & { } = { theme: 'Light', sizeVariant: 'md', - value: '', + value: 'radioValue', checked: false, label: 'Label', hasHint: false, @@ -243,8 +243,8 @@ const args: BlrRadioType & { errorMessageIcon: undefined, radioId: 'radioId', name: 'Radio Button', - blrChange: () => action('blrChange'), - blrFocus: () => action('blrFocus'), + blrSelectedValueChange: () => action('blrSelectedValueChangeEvent'), + blrFocus: () => action('focused'), blrBlur: () => action('blrBlr'), }; diff --git a/packages/ui-library/src/components/radio/index.ts b/packages/ui-library/src/components/radio/index.ts index b7ad7bc00..496327c65 100644 --- a/packages/ui-library/src/components/radio/index.ts +++ b/packages/ui-library/src/components/radio/index.ts @@ -11,10 +11,26 @@ import { ThemeType } from '../../foundation/_tokens-generated/index.themes.js'; import { BlrFormCaptionGroupRenderFunction } from '../form-caption-group/renderFunction.js'; import { BlrFormCaptionRenderFunction } from '../form-caption/renderFunction.js'; import { BlrFormLabelInlineRenderFunction } from '../form-label/form-label-inline/renderFunction.js'; -import { createBlrBlurEvent, createBlrFocusEvent, createBlrSelectedValueChangeEvent } from '../../globals/events.js'; -import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; +import { + createBlrBlurEvent, + createBlrFocusEvent, + createBlrSelectedValueChangeEvent, + BlrBlurEvent, + BlrFocusEvent, + BlrCheckedChangeEvent, +} from '../../globals/events.js'; +import { LitElementCustom } from '../../utils/lit/element.js'; +import { SignalHub } from '../../utils/lit/signals.js'; + +/** + * @fires blrFocus Radio received focus + * @fires blrBlur Radio lost focus + * @fires blrSelectedValueChangeEvent Radio selected value changed + */ + +export class BlrRadio extends LitElementCustom implements PublicReactiveProperties { + public declare signals: SignalHub; -export class BlrRadio extends LitElementCustom { static styles = [staticFormStyles, staticRadioStyles]; @query('input') @@ -24,20 +40,17 @@ export class BlrRadio extends LitElementCustom { @property() accessor label!: string; @property() accessor disabled: boolean | undefined; @property() accessor readonly: boolean | undefined; - @property() accessor checked: boolean | undefined; + @property({ type: Boolean }) accessor checked: boolean | undefined; @property() accessor name: string | undefined; @property() accessor sizeVariant: InputSizesType | undefined = 'md'; @property() accessor required: boolean | undefined; - @property() accessor blrChange: HTMLElement['oninput'] | undefined; - @property() accessor blrBlur: HTMLElement['blur'] | undefined; - @property() accessor blrFocus: HTMLElement['focus'] | undefined; @property() accessor hasError: boolean | undefined; @property() accessor errorMessage: string | undefined; @property() accessor errorMessageIcon: SizelessIconType | undefined; @property() accessor hasHint: boolean | undefined; @property() accessor hintMessage: string | undefined; @property() accessor hintMessageIcon: SizelessIconType | undefined; - + @property() accessor value: string | undefined; @property() accessor theme: ThemeType = 'Light'; protected handleFocus = (event: FocusEvent) => { @@ -52,10 +65,21 @@ export class BlrRadio extends LitElementCustom { } }; - protected handleChange(event: Event) { - this.dispatchEvent( - createBlrSelectedValueChangeEvent({ originalEvent: event, selectedValue: this._radioNode.value }) - ); + protected handleClick(event: Event) { + event.preventDefault(); + + if (!this.disabled) { + const changeEvent = createBlrSelectedValueChangeEvent({ + originalEvent: event, + selectedValue: this._radioNode.value, + }); + + this.dispatchEvent(changeEvent); + + if (!changeEvent.defaultPrevented) { + this.checked = true; + } + } } protected render() { @@ -69,6 +93,10 @@ export class BlrRadio extends LitElementCustom { error: this.hasError || false, }); + const calculateOptionId = (label: string) => { + return label.replace(/ /g, '_').toLowerCase(); + }; + const captionContent = html` ${this.hasHint && (this.hintMessage || this.hintMessageIcon) ? html` @@ -97,11 +125,11 @@ export class BlrRadio extends LitElementCustom { ` : nothing} `; - + const id = calculateOptionId(this.label); return html`
${BlrFormLabelInlineRenderFunction({ labelText: this.label, - forValue: this.optionId, + forValue: id, labelSize: this.sizeVariant, theme: this.theme, })} @@ -135,4 +164,31 @@ if (!customElements.get(TAG_NAME)) { customElements.define(TAG_NAME, BlrRadio); } -export type BlrRadioType = ElementInterface; +export type BlrRadioType = PublicReactiveProperties & PublicMethods & BlrRadioEventHandlers; + +export type PublicReactiveProperties = { + optionId: string; + label: string; + disabled?: boolean; + readonly?: boolean; + name?: string; + sizeVariant?: InputSizesType; + required?: boolean; + hasError?: boolean; + errorMessage?: string; + errorMessageIcon?: SizelessIconType; + hasHint?: boolean; + hintMessage?: string; + hintMessageIcon?: SizelessIconType; + value?: string; + theme: ThemeType; + checked?: boolean; +}; + +export type PublicMethods = unknown; + +export type BlrRadioEventHandlers = { + blrFocus?: (event: BlrFocusEvent) => void; + blrBlur?: (event: BlrBlurEvent) => void; + blrSelectedValueChangeEvent?: (event: BlrCheckedChangeEvent) => void; +}; diff --git a/packages/ui-library/src/globals/events.ts b/packages/ui-library/src/globals/events.ts index e793fe58e..e8abd7c81 100644 --- a/packages/ui-library/src/globals/events.ts +++ b/packages/ui-library/src/globals/events.ts @@ -68,7 +68,7 @@ export function createBlrCheckedChangeEvent(detail: BlrCheckedChangeEventDetail) } export type BlrSelectedValueChangeEventDetail = { - originalEvent: Event; + originalEvent?: Event; selectedValue: string; }; export type BlrSelectedValueChangeEvent = CustomEvent; @@ -76,7 +76,7 @@ export const BlrSelectedValueChangeEventName = 'blrSelectedValueChange'; export function createBlrSelectedValueChangeEvent( detail: BlrSelectedValueChangeEventDetail ): BlrSelectedValueChangeEvent { - return new CustomEvent(BlrSelectedValueChangeEventName, { bubbles: true, composed: true, detail }); + return new CustomEvent(BlrSelectedValueChangeEventName, { bubbles: false, composed: true, detail, cancelable: true }); } export type BlrTextValueChangeEventDetail = { diff --git a/packages/ui-library/src/globals/types.ts b/packages/ui-library/src/globals/types.ts index 275632172..ecab43857 100644 --- a/packages/ui-library/src/globals/types.ts +++ b/packages/ui-library/src/globals/types.ts @@ -41,13 +41,7 @@ export type ButtonGroupSizesType = (typeof ButtonGroupSizes)[number]; export type ResizeType = (typeof Resizes)[number]; export type InputSizesType = (typeof InputSizes)[number]; -export type RadioOption = { - label: string; - value: string; - hintMessage?: string; - errorMessage?: string; - checked?: boolean; -}; + export type IconPositionVariant = 'leading' | 'trailing'; export type WarningLimits = 'warningLimitInt' | 'warningLimitPer'; export type DividerVariationTypes = (typeof DividerVariations)[number]; diff --git a/packages/ui-library/src/utils/lit/decorators.ts b/packages/ui-library/src/utils/lit/decorators.ts index a858b78dd..053be8a02 100644 --- a/packages/ui-library/src/utils/lit/decorators.ts +++ b/packages/ui-library/src/utils/lit/decorators.ts @@ -1,21 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { standardProperty } from 'lit/decorators.js'; -import type { PropertyDeclaration, ReactiveElement } from 'lit'; +import type { PropertyDeclaration } from 'lit'; +import { LitElementCustom } from './element.js'; +import { Signal } from '@lit-labs/preact-signals'; +import { registerSignal } from './signals.js'; /** * Extends lit's property decorator with the following custom functionality: * - Automatic kebab-casing of correlated HTML attribute name if {@link PropertyDeclaration.attribute|options.attribute} is undefined. */ -export function property(options?: PropertyDeclaration) { - return , V>( +export function property(options?: PropertyDecoratorOptions) { + return , V>( target: ClassAccessorDecoratorTarget | ((this: C, value: V) => void), context: ClassSetterDecoratorContext | ClassAccessorDecoratorContext // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any => { - return standardProperty( - { ...options, attribute: options?.attribute ?? camelCaseToKebabCase(context.name.toString()) }, - target, - context - ); + const { kind: decoratorKind, name: propertyName } = context; + + const defaultOptions: PropertyDecoratorOptions = { + ...options, + attribute: options?.attribute ?? camelCaseToKebabCase(context.name.toString()), + signal: options?.signal ?? true, + }; + + const litDecoratedProperty = standardProperty(defaultOptions, target, context); + + if (defaultOptions.signal && decoratorKind === 'accessor' && typeof litDecoratedProperty !== 'function') { + const { set: baseSet, init: baseInit } = litDecoratedProperty; + + litDecoratedProperty.init = function (value) { + const initialValue = baseInit?.call(this, value) ?? value; + const signal = new Signal(initialValue); + + registerSignal(this.signals, propertyName, signal); + + return initialValue as V; + }; + + litDecoratedProperty.get = function () { + return (this.signals as any)[propertyName].value as V; + }; + + litDecoratedProperty.set = function (value) { + (this.signals as any)[propertyName].value = value; + baseSet?.call(this, value); + }; + } + + return litDecoratedProperty; }; } @@ -35,6 +67,10 @@ export const camelCaseToKebabCase = (str: string) => (isNotFirstCharacter ? '-' : '') + upperCaseGroup.toLowerCase() ); +export type PropertyDecoratorOptions = PropertyDeclaration & { + signal?: boolean; +}; + type Interface = { [K in keyof T]: T[K]; }; diff --git a/packages/ui-library/src/utils/lit/element.ts b/packages/ui-library/src/utils/lit/element.ts index f42aaacc3..ba87096ef 100644 --- a/packages/ui-library/src/utils/lit/element.ts +++ b/packages/ui-library/src/utils/lit/element.ts @@ -1,7 +1,10 @@ // eslint-disable-next-line no-restricted-imports -import { LitElement as LitElementCustom } from 'lit'; +import { LitElement } from 'lit'; +import { SignalHub } from './signals.js'; -export { LitElementCustom }; +export class LitElementCustom extends LitElement { + public readonly signals: SignalHub = {}; +} export type ElementInterface = Record & Omit, keyof LitElementCustom>; diff --git a/packages/ui-library/src/utils/lit/signals.ts b/packages/ui-library/src/utils/lit/signals.ts new file mode 100644 index 000000000..00ac0b17e --- /dev/null +++ b/packages/ui-library/src/utils/lit/signals.ts @@ -0,0 +1,13 @@ +import { Signal, ReadonlySignal } from '@lit-labs/preact-signals'; +import { LitElementCustom } from './element.js'; + +export function registerSignal(hub: SignalHub, key: keyof TProps, signal: Signal) { + Object.defineProperty(hub, key, { value: signal }); +} + +export type SignalHub = Omit< + { + readonly [P in keyof TProps]-?: ReadonlySignal; + }, + keyof LitElementCustom +>; diff --git a/yarn.lock b/yarn.lock index 4c45c006a..c89d3a60e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1657,6 +1657,7 @@ __metadata: "@boiler/figma-design-tokens": "npm:0.0.1" "@boiler/icons": "npm:0.0.1" "@floating-ui/dom": "npm:^1.6.3" + "@lit-labs/preact-signals": "npm:1.0.2" "@lit-labs/react": "npm:^1.1.1" "@storybook/addon-designs": "npm:^7.0.7" lit: "npm:^3.1.2" @@ -2613,6 +2614,16 @@ __metadata: languageName: node linkType: hard +"@lit-labs/preact-signals@npm:1.0.2": + version: 1.0.2 + resolution: "@lit-labs/preact-signals@npm:1.0.2" + dependencies: + "@preact/signals-core": "npm:^1.3.0" + lit: "npm:^3.1.2" + checksum: 10c0/bb27f24388d363c751ec5e50f09a58ed5d274c59635e679f425ba8c2cbe1e30342a2f19d4f29fae4c065a854f0f28ce53dd16e48df1f560161b6a9215207bb86 + languageName: node + linkType: hard + "@lit-labs/react@npm:^1.0.2, @lit-labs/react@npm:^1.1.1": version: 1.2.1 resolution: "@lit-labs/react@npm:1.2.1" @@ -2841,6 +2852,13 @@ __metadata: languageName: node linkType: hard +"@preact/signals-core@npm:^1.3.0": + version: 1.6.1 + resolution: "@preact/signals-core@npm:1.6.1" + checksum: 10c0/877f4b1915aa660e6a46c9dee028904284a565066fc782d1814419687ab92f8ffea34aa89a936ab4b7fc1d0105dcb4916e12d8bf529febe7104b25c691303f8a + languageName: node + linkType: hard + "@puppeteer/browsers@npm:0.5.0": version: 0.5.0 resolution: "@puppeteer/browsers@npm:0.5.0"