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"