diff --git a/packages/js-example-app/src/index.client.ts b/packages/js-example-app/src/index.client.ts index 4a1cf82ef..8487e3dfb 100644 --- a/packages/js-example-app/src/index.client.ts +++ b/packages/js-example-app/src/index.client.ts @@ -187,7 +187,7 @@ function init() { addLog('blr-radio changed: ' + e.detail.selectedValue); }); - blrRadioGroup.addEventListener('blrRadioGroupValueChange', (e) => { + blrRadioGroup.addEventListener('blrSelectedValueChange', (e) => { addLog('blr-radio value changed blrRadioGroupValueChange: ' + e.detail.selectedValue); }); diff --git a/packages/js-example-app/src/index.server.ts b/packages/js-example-app/src/index.server.ts index a0a5cdaef..99b39fa3e 100644 --- a/packages/js-example-app/src/index.server.ts +++ b/packages/js-example-app/src/index.server.ts @@ -196,7 +196,7 @@ export function* renderIndex() { radio-id="radioId" name="Radio Group" > - + diff --git a/packages/ui-library/.babelrc b/packages/ui-library/.babelrc index c58d74138..fc22a331b 100644 --- a/packages/ui-library/.babelrc +++ b/packages/ui-library/.babelrc @@ -1,6 +1,14 @@ // Used by Storybook { "sourceType": "unambiguous", - "presets": ["@babel/preset-env", "@babel/preset-typescript"], - "plugins": [["@babel/plugin-proposal-decorators", { "version": "2023-05" }]] + "presets": ["@babel/preset-env"], + "plugins": [ + [ + "@babel/plugin-transform-typescript", + { + "allowDeclareFields": true + } + ], + ["@babel/plugin-proposal-decorators", { "version": "2023-05" }] + ] } diff --git a/packages/ui-library/package.json b/packages/ui-library/package.json index bcedb3725..ac553d9f0 100644 --- a/packages/ui-library/package.json +++ b/packages/ui-library/package.json @@ -36,6 +36,7 @@ "dependencies": { "@boiler/icons": "0.0.1", "@floating-ui/dom": "^1.6.3", + "@lit-labs/preact-signals": "1.0.2", "@lit-labs/react": "^1.1.1", "lit": "^3.1.2", "nested-css-to-flat": "^1.0.5" diff --git a/packages/ui-library/src/components/radio-group/index.ts b/packages/ui-library/src/components/radio-group/index.ts index 03065a904..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'; @@ -14,21 +14,22 @@ import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; import { BlrBlurEvent, BlrFocusEvent, - BlrRadioGroupValueChangeEvent, - createBlrRadioGroupValueChangeEvent, + 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; - blrRadioGroupValueChange?: (event: BlrRadioGroupValueChangeEvent) => void; + blrSelectedValueChange?: (event: BlrSelectedValueChangeEvent) => void; }; /** * @fires blrFocus Radio received focus * @fires blrBlur Radio lost focus - * @fires blrRadioGroupValueChange Radio selected value changed + * @fires blrSelectedValueChange Radio selected value changed */ export class BlrRadioGroup extends LitElementCustom { @@ -50,61 +51,55 @@ export class BlrRadioGroup extends LitElementCustom { @property() accessor legend: string | undefined; @property() accessor direction: RadioGroupDirection = 'horizontal'; @property() accessor theme: ThemeType = 'Light'; - protected _radioElements: BlrRadio[] | undefined; - protected handleRadioCheckedEvent = (event: Event) => { - let currentlyCheckedRadio = (event?.target as HTMLInputElement).id; - - if (event.type === 'checkedChangeEvent') { - currentlyCheckedRadio = (event).detail.currentlyCheckedRadio.getAttribute('label'); - } - - this._radioElements?.forEach((item: BlrRadio) => { - const label = item.getAttribute('label'); - if (label === currentlyCheckedRadio) { - item.checked = true; - } else { - item.checked = false; - } + 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; + } + }); }); - this.dispatchEvent( - createBlrRadioGroupValueChangeEvent({ - originalEvent: event, - selectedValue: (event?.target as HTMLInputElement).value, - }) - ); - - event.preventDefault(); + if (this._selectedRadio !== selectedRadio) { + this.dispatchEvent(createBlrSelectedValueChangeEvent({ selectedValue: (selectedRadio)?.value ?? '' })); + this._selectedRadio = selectedRadio; + } }; - firstUpdated() { - this.addEventListener('checkedChangeEvent', this.handleRadioCheckedEvent); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected firstUpdated(_changedProperties: PropertyValues): void { + this.handleSlotChange(); } - disconnectedCallback() { - this.removeEventListener('checkedChangeEvent', this.handleRadioCheckedEvent); - } + protected handleSlotChange = () => { + // Cleanup signal listeners from previously slotted elements + this._radioCheckedSignalSubscriptionDisposers.forEach((cancelSubscription) => cancelSubscription()); - handleSlotChange = () => { const slot = this.renderRoot?.querySelector('slot'); - this._radioElements = slot?.assignedElements({ flatten: false }) as HTMLElement[] & BlrRadio[]; + this._radioElements = slot?.assignedElements({ flatten: false }) as BlrRadio[]; - this._radioElements?.forEach((item: 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'); } - const radioElement = item.shadowRoot?.querySelector('input'); - const radioElementWrapper = item.shadowRoot?.querySelector('.blr-radio'); - - if (this.hasError) { - [radioElement, radioElementWrapper].forEach((element) => element.classList.add('error')); - } - - if (this.sizeVariant !== item.sizeVariant) item.sizeVariant = this.sizeVariant; + item.hasError = this.hasError; - radioElement.addEventListener('click', this.handleRadioCheckedEvent); + this._radioCheckedSignalSubscriptionDisposers.push( + item.signals.checked.subscribe((value) => this.handleRadioCheckedSignal(item, value)) + ); }); }; diff --git a/packages/ui-library/src/components/radio/index.ts b/packages/ui-library/src/components/radio/index.ts index 2b180235d..937e133f2 100644 --- a/packages/ui-library/src/components/radio/index.ts +++ b/packages/ui-library/src/components/radio/index.ts @@ -19,13 +19,8 @@ import { BlrFocusEvent, BlrCheckedChangeEvent, } from '../../globals/events.js'; -import { LitElementCustom, ElementInterface } from '../../utils/lit/element.js'; - -export type BlrRadioEventHandlers = { - blrFocus?: (event: BlrFocusEvent) => void; - blrBlur?: (event: BlrBlurEvent) => void; - blrSelectedValueChangeEvent?: (event: BlrCheckedChangeEvent) => void; -}; +import { LitElementCustom } from '../../utils/lit/element.js'; +import { SignalHub } from '../../utils/lit/signals.js'; /** * @fires blrFocus Radio received focus @@ -33,7 +28,9 @@ export type BlrRadioEventHandlers = { * @fires blrSelectedValueChangeEvent Radio selected value changed */ -export class BlrRadio extends LitElementCustom { +export class BlrRadio extends LitElementCustom implements PublicReactiveProperties { + public declare signals: SignalHub; + static styles = [staticFormStyles, staticRadioStyles]; @query('input') @@ -43,7 +40,7 @@ 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; @@ -53,6 +50,7 @@ export class BlrRadio extends LitElementCustom { @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) => { @@ -67,34 +65,21 @@ export class BlrRadio extends LitElementCustom { } }; - protected handleChange(event: Event) { + protected handleClick(event: Event) { + event.preventDefault(); + if (!this.disabled) { - this.dispatchEvent( - createBlrSelectedValueChangeEvent({ originalEvent: event, selectedValue: this._radioNode.value }) - ); - } - } + const changeEvent = createBlrSelectedValueChangeEvent({ + originalEvent: event, + selectedValue: this._radioNode.value, + }); - firstUpdated() { - /* eslint-disable @typescript-eslint/no-this-alias */ - const _self = this; - Object.defineProperty(this._radioNode, 'checked', { - set: function (value) { - if (value === false) { - return; - } - _self.checked = value; - _self.dispatchEvent( - new CustomEvent('checkedChangeEvent', { - bubbles: true, - detail: { currentlyCheckedRadio: _self }, - }) - ); - }, - get: function () { - return this.hasAttribute('checked'); - }, - }); + this.dispatchEvent(changeEvent); + + if (!changeEvent.defaultPrevented) { + this.checked = true; + } + } } protected render() { @@ -148,13 +133,13 @@ export class BlrRadio extends LitElementCustom { class="${classes} input-control" type="radio" name=${this.name} - .value=${this.value} ?disabled=${this.disabled} ?readonly=${this.readonly} ?invalid=${this.hasError} ?checked=${this.checked} + .checked=${this.checked || false} ?required=${this.required} - @input=${this.handleChange} + @click=${this.handleClick} @focus=${this.handleFocus} @blur=${this.handleBlur} /> @@ -179,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 27d5a56dd..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,19 +76,7 @@ export const BlrSelectedValueChangeEventName = 'blrSelectedValueChange'; export function createBlrSelectedValueChangeEvent( detail: BlrSelectedValueChangeEventDetail ): BlrSelectedValueChangeEvent { - return new CustomEvent(BlrSelectedValueChangeEventName, { bubbles: true, composed: true, detail }); -} - -export type BlrRadioGroupValueChangeEventDetail = { - originalEvent: Event; - selectedValue: string; -}; -export type BlrRadioGroupValueChangeEvent = CustomEvent; -export const BlrRadioGroupValueChangeEventName = 'blrRadioGroupValueChange'; -export function createBlrRadioGroupValueChangeEvent( - detail: BlrRadioGroupValueChangeEventDetail -): BlrRadioGroupValueChangeEvent { - return new CustomEvent(BlrRadioGroupValueChangeEventName, { 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/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 1005b22cb..48d67e886 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" @@ -2587,6 +2588,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" @@ -2815,6 +2826,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"