Skip to content

Commit

Permalink
bound: added decorator to automatically bound methods to the instance
Browse files Browse the repository at this point in the history
  • Loading branch information
Andrea Zanenghi committed Sep 30, 2024
1 parent 2d5bb6a commit 4551f81
Show file tree
Hide file tree
Showing 12 changed files with 155 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/rotten-kiwis-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ecopages/radiant": patch
---

Added @bound decorator to simplify the binding of methods that runs in untracked events
1 change: 1 addition & 0 deletions apps/docs/src/data/docs-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const docsConfig: DocsConfig = {
name: 'Decorators',
subdirectory: 'decorators',
pages: [
{ title: '@bound', slug: 'bound' },
{ title: '@customElement', slug: 'custom-element' },
{ title: '@debounce', slug: 'debounce' },
{ title: '@event', slug: 'event' },
Expand Down
21 changes: 21 additions & 0 deletions apps/docs/src/pages/docs/decorators/bound.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { CodeBlock } from '@/components/code-block/code-block.kita';
import { DocsLayout } from '@/layouts/docs-layout';

export const layout = DocsLayout;

export const getMetadata = () => ({
title: 'Docs | @bound',
description: 'The place to learn about @ecopages/radiant',
})

# @bound
---

The bound decorator binds a method to the instance of the class it is defined in. This is useful when passing a method as a callback to another function, such as in response to an event listener.

<CodeBlock >
```typescript
@bound doSomething() {...}
```
</CodeBlock>
```
2 changes: 2 additions & 0 deletions apps/docs/src/pages/docs/decorators/on-event.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const getMetadata = () => ({
A decorator to subscribe to an event on the target element.
The event listener will be automatically unsubscribed when the element is disconnected.

Please note the method binding is not necessary, as the method is bound to the instance of the class by default.

It accepts an object with one of the following properties:

- `selector`: A CSS selector to match the target element.
Expand Down
4 changes: 4 additions & 0 deletions packages/radiant/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@
"types": "./dist/core/radiant-element.d.ts",
"import": "./dist/core/radiant-element.js"
},
"./decorators/bound": {
"types": "./dist/decorators/bound.d.ts",
"import": "./dist/decorators/bound.js"
},
"./decorators/custom-element": {
"types": "./dist/decorators/custom-element.d.ts",
"import": "./dist/decorators/custom-element.js"
Expand Down
35 changes: 35 additions & 0 deletions packages/radiant/src/decorators/bound.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { RadiantElement } from '@/core/radiant-element';

/**
* A decorator to bind a method to the instance.
* @param target {@link RadiantElement}
* @param propertyKey string
* @param descriptor {@link PropertyDescriptor}
* @returns
*/
export function bound(target: RadiantElement, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor {
const originalMethod = descriptor.value;

return {
configurable: true,
get() {
/**
* Check if the method is already bound to the instance.
*/
if (this === (target as any).prototype || Object.hasOwn(this, propertyKey)) {
return originalMethod;
}

/**
* Bind the method to the instance.
*/
const boundMethod = originalMethod.bind(this);
Object.defineProperty(this, propertyKey, {
value: boundMethod,
configurable: true,
writable: true,
});
return boundMethod;
},
};
}
4 changes: 3 additions & 1 deletion packages/radiant/src/decorators/debounce.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { RadiantElement } from '@/core/radiant-element';

export function debounce(
timeout: number,
): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor {
let timeoutRef: ReturnType<typeof setTimeout> | null = null;

return (_target: any, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
return (_target: RadiantElement, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => {
const originalMethod = descriptor.value;

descriptor.value = function debounce(...args: any[]) {
Expand Down
9 changes: 3 additions & 6 deletions playground/vite/src/components/dropdown/dropdown.kita.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,16 @@
import type { WithChildren } from '@/types';
import type { RadiantDropdownProps } from './dropdown.script';
import './dropdown.script';
import './dropdown.css';

export const RadiantDropdown = ({ defaultOpen, placement, arrow }: RadiantDropdownProps) => {
export const RadiantDropdown = ({ defaultOpen, placement, arrow, children }: WithChildren<RadiantDropdownProps>) => {
return (
<radiant-dropdown defaultOpen={defaultOpen} placement={placement}>
<button type="button" data-ref="trigger" class="rui-button rui-button--primary rui-button--md">
Open
</button>
<div data-ref="content">
<ul class="flex flex-col gap-2">
<li>Option 1</li>
<li>Option 2</li>
<li>Option 3</li>
</ul>
{children as 'safe'}
{arrow ? <div data-ref="arrow"></div> : null}
</div>
</radiant-dropdown>
Expand Down
53 changes: 46 additions & 7 deletions playground/vite/src/components/dropdown/dropdown.script.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import type { FocusableElement } from '@/types';
import { onUpdated } from '@ecopages/radiant';
import { RadiantElement } from '@ecopages/radiant/core';
import { bound } from '@ecopages/radiant/decorators/bound';
import { customElement } from '@ecopages/radiant/decorators/custom-element';
import { onEvent } from '@ecopages/radiant/decorators/on-event';
import { query } from '@ecopages/radiant/decorators/query';
import { reactiveProp } from '@ecopages/radiant/decorators/reactive-prop';
import { type Coords, type Placement, arrow, autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import {
type Coords,
type Placement,
type ShiftOptions,
arrow,
autoUpdate,
computePosition,
flip,
offset,
} from '@floating-ui/dom';

export type RadiantDropdownProps = {
defaultOpen?: boolean;
Expand All @@ -13,31 +24,44 @@ export type RadiantDropdownProps = {
arrow?: boolean;
};

/**
* @element radiant-dropdown
* @description A dropdown component that can be toggled by a trigger element
*
* @ref trigger - The trigger element
* @ref content - The content container
* @ref arrow - The arrow element
* @prop {boolean} defaultOpen - Whether the dropdown should be open by default
* @prop {Placement} placement - The placement of the dropdown
* @prop {number} offset - The offset of the dropdown
* @prop {ShiftOptions} shiftOptions - The shift options of the dropdown {@link ShiftOptions}
*/
@customElement('radiant-dropdown')
export class RadiantDropdown extends RadiantElement {
@query({ ref: 'trigger' }) triggerTarget!: HTMLButtonElement;
@query({ ref: 'content' }) contentTarget!: HTMLElement;
@query({ ref: 'arrow' }) arrowTarget!: HTMLElement;

@reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare defaultOpen: boolean;
@reactiveProp({ type: String, reflect: true, defaultValue: 'left' }) declare placement: Placement;
@reactiveProp({ type: Number, reflect: true, defaultValue: 6 }) declare offset: number;
@reactiveProp({ type: String, defaultValue: 'left' }) declare placement: Placement;
@reactiveProp({ type: Number, defaultValue: 6 }) declare offset: number;
@reactiveProp({ type: Boolean, defaultValue: true }) declare focusOnOpen: boolean;

cleanup: ReturnType<typeof autoUpdate> | null = null;

connectedCallback(): void {
super.connectedCallback();
this.updateFloatingUI = this.updateFloatingUI.bind(this);
this.updateFloatingUI();
if (this.defaultOpen) this.toggleContent();
}

@bound
@onUpdated(['offset', 'placement'])
updateFloatingUI(): void {
if (!this.triggerTarget || !this.contentTarget) return;
computePosition(this.triggerTarget, this.contentTarget, {
placement: this.placement,
middleware: [offset(this.offset), flip(), shift({ padding: 8 }), arrow({ element: this.arrowTarget })],
middleware: [offset(this.offset), flip(), arrow({ element: this.arrowTarget })],
}).then(({ x, y, placement, middlewareData }) => {
Object.assign(this.contentTarget.style, {
left: `${x}px`,
Expand Down Expand Up @@ -68,24 +92,39 @@ export class RadiantDropdown extends RadiantElement {
}

@onEvent({ ref: 'trigger', type: 'click' })
toggleContent() {
toggleContent(): void {
if (typeof this.triggerTarget.ariaExpanded === 'undefined') this.triggerTarget.ariaExpanded = 'false';
this.triggerTarget.setAttribute('aria-expanded', String(this.triggerTarget.ariaExpanded !== 'true'));
const isOpen = this.triggerTarget.ariaExpanded === 'true';
this.contentTarget.style.display = isOpen ? 'block' : 'none';

if (isOpen) {
this.cleanup = autoUpdate(this.triggerTarget, this.contentTarget, this.updateFloatingUI);
document.addEventListener('click', this.closeContent);
this.focusOnOpenChanged();
} else {
this.cleanup?.();
}
}

@onEvent({ document: true, type: 'click' })
@bound
focusOnOpenChanged(): void {
if (this.triggerTarget.ariaExpanded === 'true' && this.focusOnOpen) {
const firstFocusableElement = this.contentTarget.querySelector(
'a, button, input, [tabindex]:not([tabindex="-1"])',
);
if (firstFocusableElement) {
(firstFocusableElement as FocusableElement).focus();
}
}
}

@bound
closeContent(event: MouseEvent) {
if (!this.triggerTarget.contains(event.target as Node) && !this.contentTarget.contains(event.target as Node)) {
this.triggerTarget.setAttribute('aria-expanded', 'false');
this.contentTarget.style.display = 'none';
document.removeEventListener('click', this.closeContent);
this.cleanup?.();
}
}
Expand Down
14 changes: 13 additions & 1 deletion playground/vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,19 @@ const App = async () => {
]}
/>
<div class="flex items-center gap-4">
<RadiantDropdown placement="left-start" arrow />
<RadiantDropdown placement="left-end" arrow>
<ul class="flex flex-col gap-2">
<li>
<a href="/">Option 1</a>
</li>
<li>
<a href="/">Option 2</a>
</li>
<li>
<a href="/">Option 3</a>
</li>
</ul>
</RadiantDropdown>
<select id="placement">
<option value="left-start">Left Start</option>
<option value="left">Left</option>
Expand Down
11 changes: 11 additions & 0 deletions playground/vite/src/styles/tailwind.css
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,14 @@ body {
main {
@apply flex flex-col gap-4;
}

a,
button,
input,
select,
textarea {
&:focus,
&:focus-visible {
@apply outline outline-2 outline-focus-ring rounded-sm;
}
}
11 changes: 11 additions & 0 deletions playground/vite/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type WithChildren<T = unknown> = T & { children?: JSX.Element | JSX.Element[] };

export type WithChildrenAndClassName<T = unknown> = WithChildren<T> & { className?: string };

export type FocusableElement =
| HTMLAnchorElement
| HTMLButtonElement
| HTMLInputElement
| HTMLTextAreaElement
| HTMLSelectElement
| (HTMLElement & { tabindex: number });

0 comments on commit 4551f81

Please sign in to comment.