Skip to content

Commit

Permalink
rework
Browse files Browse the repository at this point in the history
  • Loading branch information
faselbaum committed Jun 28, 2024
1 parent b51739d commit 3144455
Show file tree
Hide file tree
Showing 11 changed files with 181 additions and 95 deletions.
2 changes: 1 addition & 1 deletion packages/js-example-app/src/index.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion packages/js-example-app/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ export function* renderIndex() {
radio-id="radioId"
name="Radio Group"
>
<blr-radio label="male" value="male"></blr-radio>
<blr-radio label="male" value="male" checked></blr-radio>
<blr-radio label="female" value="female"></blr-radio>
<blr-radio label="other" value="other"></blr-radio>
</blr-radio-group>
Expand Down
12 changes: 10 additions & 2 deletions packages/ui-library/.babelrc
Original file line number Diff line number Diff line change
@@ -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" }]
]
}
1 change: 1 addition & 0 deletions packages/ui-library/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
79 changes: 37 additions & 42 deletions packages/ui-library/src/components/radio-group/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -15,9 +15,10 @@ import {
BlrBlurEvent,
BlrFocusEvent,
BlrRadioGroupValueChangeEvent,
createBlrRadioGroupValueChangeEvent,
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;
Expand Down Expand Up @@ -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 = (<CustomEvent>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<Signal['subscribe']>[] = [];

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: (<BlrRadio>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 = <HTMLInputElement>item.shadowRoot?.querySelector('input');
const radioElementWrapper = <HTMLInputElement>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))
);
});
};

Expand Down
86 changes: 49 additions & 37 deletions packages/ui-library/src/components/radio/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,18 @@ 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
* @fires blrBlur Radio lost focus
* @fires blrSelectedValueChangeEvent Radio selected value changed
*/

export class BlrRadio extends LitElementCustom {
export class BlrRadio extends LitElementCustom implements PublicReactiveProperties {
public declare signals: SignalHub<PublicReactiveProperties>;

static styles = [staticFormStyles, staticRadioStyles];

@query('input')
Expand All @@ -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) => {
Expand All @@ -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() {
Expand Down Expand Up @@ -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}
/>
Expand All @@ -179,4 +164,31 @@ if (!customElements.get(TAG_NAME)) {
customElements.define(TAG_NAME, BlrRadio);
}

export type BlrRadioType = ElementInterface<BlrRadio & BlrRadioEventHandlers>;
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;
};
4 changes: 2 additions & 2 deletions packages/ui-library/src/globals/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,15 +68,15 @@ export function createBlrCheckedChangeEvent(detail: BlrCheckedChangeEventDetail)
}

export type BlrSelectedValueChangeEventDetail = {
originalEvent: Event;
originalEvent?: Event;
selectedValue: string;
};
export type BlrSelectedValueChangeEvent = CustomEvent<BlrSelectedValueChangeEventDetail>;
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 BlrRadioGroupValueChangeEventDetail = {
Expand Down
52 changes: 44 additions & 8 deletions packages/ui-library/src/utils/lit/decorators.ts
Original file line number Diff line number Diff line change
@@ -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 <C extends Interface<ReactiveElement>, V>(
export function property(options?: PropertyDecoratorOptions) {
return <C extends Interface<LitElementCustom>, V>(
target: ClassAccessorDecoratorTarget<C, V> | ((this: C, value: V) => void),
context: ClassSetterDecoratorContext<C, V> | ClassAccessorDecoratorContext<C, V>
// 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<any>(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;
};
}

Expand All @@ -35,6 +67,10 @@ export const camelCaseToKebabCase = (str: string) =>
(isNotFirstCharacter ? '-' : '') + upperCaseGroup.toLowerCase()
);

export type PropertyDecoratorOptions = PropertyDeclaration & {
signal?: boolean;
};

type Interface<T> = {
[K in keyof T]: T[K];
};
7 changes: 5 additions & 2 deletions packages/ui-library/src/utils/lit/element.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> = {};
}

export type ElementInterface<TElement extends LitElementCustom> = Record<string | number | symbol, unknown> &
Omit<UndefinedToOptional<TElement>, keyof LitElementCustom>;
Expand Down
Loading

0 comments on commit 3144455

Please sign in to comment.