diff --git a/.changeset/rotten-kiwis-switch.md b/.changeset/rotten-kiwis-switch.md new file mode 100644 index 0000000..ef715ee --- /dev/null +++ b/.changeset/rotten-kiwis-switch.md @@ -0,0 +1,5 @@ +--- +"@ecopages/radiant": patch +--- + +Added @bound decorator to simplify the binding of methods that runs in untracked events diff --git a/apps/docs/src/data/docs-config.ts b/apps/docs/src/data/docs-config.ts index 06d10f7..69baaa6 100644 --- a/apps/docs/src/data/docs-config.ts +++ b/apps/docs/src/data/docs-config.ts @@ -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' }, diff --git a/apps/docs/src/pages/docs/decorators/bound.mdx b/apps/docs/src/pages/docs/decorators/bound.mdx new file mode 100644 index 0000000..7aae1f2 --- /dev/null +++ b/apps/docs/src/pages/docs/decorators/bound.mdx @@ -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. + + +```typescript +@bound doSomething() {...} +``` + +``` \ No newline at end of file diff --git a/apps/docs/src/pages/docs/decorators/on-event.mdx b/apps/docs/src/pages/docs/decorators/on-event.mdx index 31434d9..93f1369 100644 --- a/apps/docs/src/pages/docs/decorators/on-event.mdx +++ b/apps/docs/src/pages/docs/decorators/on-event.mdx @@ -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. diff --git a/packages/radiant/package.json b/packages/radiant/package.json index 40f25b4..d96e2c5 100644 --- a/packages/radiant/package.json +++ b/packages/radiant/package.json @@ -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" diff --git a/packages/radiant/src/decorators/bound.ts b/packages/radiant/src/decorators/bound.ts new file mode 100644 index 0000000..9a459bf --- /dev/null +++ b/packages/radiant/src/decorators/bound.ts @@ -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; + }, + }; +} diff --git a/packages/radiant/src/decorators/debounce.ts b/packages/radiant/src/decorators/debounce.ts index 2449e32..54a3bba 100644 --- a/packages/radiant/src/decorators/debounce.ts +++ b/packages/radiant/src/decorators/debounce.ts @@ -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 | 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[]) { diff --git a/playground/vite/src/components/dropdown/dropdown.kita.tsx b/playground/vite/src/components/dropdown/dropdown.kita.tsx index 9f09119..af23b70 100644 --- a/playground/vite/src/components/dropdown/dropdown.kita.tsx +++ b/playground/vite/src/components/dropdown/dropdown.kita.tsx @@ -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) => { return (
-
    -
  • Option 1
  • -
  • Option 2
  • -
  • Option 3
  • -
+ {children as 'safe'} {arrow ?
: null}
diff --git a/playground/vite/src/components/dropdown/dropdown.script.ts b/playground/vite/src/components/dropdown/dropdown.script.ts index 33dd1ac..8448f1d 100644 --- a/playground/vite/src/components/dropdown/dropdown.script.ts +++ b/playground/vite/src/components/dropdown/dropdown.script.ts @@ -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; @@ -13,6 +24,18 @@ 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; @@ -20,24 +43,25 @@ export class RadiantDropdown extends RadiantElement { @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 | 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`, @@ -68,7 +92,7 @@ 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'; @@ -76,16 +100,31 @@ export class RadiantDropdown extends RadiantElement { 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?.(); } } diff --git a/playground/vite/src/main.tsx b/playground/vite/src/main.tsx index 3cbf876..4524152 100644 --- a/playground/vite/src/main.tsx +++ b/playground/vite/src/main.tsx @@ -34,7 +34,19 @@ const App = async () => { ]} />
- + + +