From bf7d9045b5c0ab06e8c111ff2a97e4ab6a278ab7 Mon Sep 17 00:00:00 2001 From: azanenghi Date: Mon, 14 Oct 2024 23:07:26 +0200 Subject: [PATCH] Feature/#36 stage 3 (#37) * feature(#36): This update introduce the possibility to use stage 3 decorators. This refactor changed the RadiantElement class to be more concise and organised. --------- Co-authored-by: Andrea Zanenghi --- .changeset/loud-starfishes-return.md | 5 + apps/docs/package.json | 2 +- .../src/components/burger/burger.script.ts | 2 +- .../radiant-counter/radiant-counter.script.ts | 2 +- .../layouts/docs-layout/docs-layout.script.ts | 2 +- .../src/pages/docs/core/radiant-element.mdx | 202 ++++++++++- .../docs/getting-started/introduction.mdx | 24 +- apps/docs/src/styles/tailwind.css | 340 +++++++++--------- biome.json | 5 +- bun.lockb | Bin 223064 -> 254768 bytes package.json | 6 +- packages/radiant/build.ts | 4 +- packages/radiant/package.json | 54 ++- .../radiant/src/context/context-provider.ts | 5 +- .../src/context/decorators/consume-context.ts | 35 +- .../context/decorators/context-selector.ts | 59 ++- .../decorators/legacy/consume-context.ts | 19 + .../decorators/legacy/context-selector.ts | 31 ++ .../decorators/legacy/provide-context.ts | 16 + .../src/context/decorators/provide-context.ts | 41 ++- .../decorators/standard/consume-context.ts | 18 + .../decorators/standard/context-selector.ts | 18 + .../decorators/standard/provide-context.ts | 14 + packages/radiant/src/context/events.ts | 5 +- packages/radiant/src/context/index.ts | 7 - packages/radiant/src/core/index.ts | 1 - packages/radiant/src/core/radiant-element.ts | 269 ++++++++++++-- packages/radiant/src/decorators.ts | 7 - packages/radiant/src/decorators/bound.ts | 53 ++- .../radiant/src/decorators/custom-element.ts | 25 +- packages/radiant/src/decorators/debounce.ts | 48 +-- packages/radiant/src/decorators/event.ts | 48 ++- .../radiant/src/decorators/legacy/bound.ts | 29 ++ .../src/decorators/legacy/custom-element.ts | 12 + .../radiant/src/decorators/legacy/debounce.ts | 23 ++ .../radiant/src/decorators/legacy/event.ts | 28 ++ .../radiant/src/decorators/legacy/on-event.ts | 83 +++++ .../src/decorators/legacy/on-updated.ts | 23 ++ .../radiant/src/decorators/legacy/query.ts | 63 ++++ .../src/decorators/legacy/reactive-field.ts | 18 + .../src/decorators/legacy/reactive-prop.ts | 40 +++ packages/radiant/src/decorators/on-event.ts | 81 ++--- packages/radiant/src/decorators/on-updated.ts | 47 ++- packages/radiant/src/decorators/query.ts | 98 ++--- .../radiant/src/decorators/reactive-field.ts | 49 ++- .../radiant/src/decorators/reactive-prop.ts | 139 ++----- .../radiant/src/decorators/standard/bound.ts | 11 + .../src/decorators/standard/custom-element.ts | 9 + .../src/decorators/standard/debounce.ts | 19 + .../radiant/src/decorators/standard/event.ts | 24 ++ .../src/decorators/standard/on-event.ts | 66 ++++ .../src/decorators/standard/on-updated.ts | 17 + .../radiant/src/decorators/standard/query.ts | 40 +++ .../src/decorators/standard/reactive-field.ts | 29 ++ .../src/decorators/standard/reactive-prop.ts | 12 + packages/radiant/src/index.ts | 18 +- packages/radiant/src/mixins/with-kita.ts | 2 +- packages/radiant/src/tools/event-emitter.ts | 2 +- packages/radiant/src/types.ts | 104 ++++++ packages/radiant/src/utils/attribute-utils.ts | 19 + .../radiant/test/context/context.test.tsx | 86 +++-- .../radiant/test/core/radiant-element.test.ts | 104 +++++- .../radiant/test/decorators/bound.test.ts | 32 ++ .../test/decorators/custom-element.test.ts | 9 +- .../radiant/test/decorators/debounce.test.ts | 4 +- .../radiant/test/decorators/event.test.ts | 47 ++- .../radiant/test/decorators/on-event.test.ts | 46 +++ .../test/decorators/on-updated.test.ts | 150 +++++--- .../radiant/test/decorators/query.test.ts | 97 ++++- .../test/decorators/reactive-field.test.ts | 15 +- .../test/decorators/reactive-prop.test.ts | 67 ++-- .../radiant/test/mixins/with-kita.test.tsx | 10 +- .../test/utils/attribute-utils.test.ts | 62 +++- .../test/utils/stringify-typed.test.ts | 37 ++ packages/radiant/tsconfig.json | 21 +- packages/radiant/tsconfig.legacy.json | 34 ++ packages/radiant/vite.config.ts | 26 ++ playground/vite/package.json | 1 + .../components/accordion/accordion.script.ts | 8 +- .../components/dropdown/dropdown.script.ts | 10 +- .../radiant-counter/radiant-counter.script.ts | 4 +- .../radiant-event/radiant-event.script.ts | 2 +- .../radiant-todo-app.script.tsx | 2 +- .../value-tester/value-tester.script.tsx | 15 +- playground/vite/src/main.tsx | 12 +- playground/vite/tsconfig.json | 8 +- playground/vite/tsconfig.legacy.json | 37 ++ playground/vite/vite.config.ts | 14 + 88 files changed, 2499 insertions(+), 933 deletions(-) create mode 100644 .changeset/loud-starfishes-return.md create mode 100644 packages/radiant/src/context/decorators/legacy/consume-context.ts create mode 100644 packages/radiant/src/context/decorators/legacy/context-selector.ts create mode 100644 packages/radiant/src/context/decorators/legacy/provide-context.ts create mode 100644 packages/radiant/src/context/decorators/standard/consume-context.ts create mode 100644 packages/radiant/src/context/decorators/standard/context-selector.ts create mode 100644 packages/radiant/src/context/decorators/standard/provide-context.ts delete mode 100644 packages/radiant/src/context/index.ts delete mode 100644 packages/radiant/src/core/index.ts delete mode 100644 packages/radiant/src/decorators.ts create mode 100644 packages/radiant/src/decorators/legacy/bound.ts create mode 100644 packages/radiant/src/decorators/legacy/custom-element.ts create mode 100644 packages/radiant/src/decorators/legacy/debounce.ts create mode 100644 packages/radiant/src/decorators/legacy/event.ts create mode 100644 packages/radiant/src/decorators/legacy/on-event.ts create mode 100644 packages/radiant/src/decorators/legacy/on-updated.ts create mode 100644 packages/radiant/src/decorators/legacy/query.ts create mode 100644 packages/radiant/src/decorators/legacy/reactive-field.ts create mode 100644 packages/radiant/src/decorators/legacy/reactive-prop.ts create mode 100644 packages/radiant/src/decorators/standard/bound.ts create mode 100644 packages/radiant/src/decorators/standard/custom-element.ts create mode 100644 packages/radiant/src/decorators/standard/debounce.ts create mode 100644 packages/radiant/src/decorators/standard/event.ts create mode 100644 packages/radiant/src/decorators/standard/on-event.ts create mode 100644 packages/radiant/src/decorators/standard/on-updated.ts create mode 100644 packages/radiant/src/decorators/standard/query.ts create mode 100644 packages/radiant/src/decorators/standard/reactive-field.ts create mode 100644 packages/radiant/src/decorators/standard/reactive-prop.ts create mode 100644 packages/radiant/src/types.ts create mode 100644 packages/radiant/test/decorators/bound.test.ts create mode 100644 packages/radiant/test/decorators/on-event.test.ts create mode 100644 packages/radiant/test/utils/stringify-typed.test.ts create mode 100644 packages/radiant/tsconfig.legacy.json create mode 100644 packages/radiant/vite.config.ts create mode 100644 playground/vite/tsconfig.legacy.json create mode 100644 playground/vite/vite.config.ts diff --git a/.changeset/loud-starfishes-return.md b/.changeset/loud-starfishes-return.md new file mode 100644 index 0000000..33495de --- /dev/null +++ b/.changeset/loud-starfishes-return.md @@ -0,0 +1,5 @@ +--- +"@ecopages/radiant": minor +--- + +This update introduce the possibility to use stage 3 decorators. This refactor changed the RadiantElement class to be more concise and organised. diff --git a/apps/docs/package.json b/apps/docs/package.json index b67db7f..17b6a6e 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -9,7 +9,7 @@ }, "devDependencies": { "@ecopages/core": "npm:@jsr/ecopages__core@latest", - "@ecopages/radiant": "^0.1.8", + "@ecopages/radiant": "*", "@ecopages/bun-mdx-kitajs-loader": "npm:@jsr/ecopages__bun-mdx-kitajs-loader@latest", "@ecopages/bun-postcss-loader": "npm:@jsr/ecopages__bun-postcss-loader@latest", "@ecopages/kitajs": "npm:@jsr/ecopages__kitajs@latest", diff --git a/apps/docs/src/components/burger/burger.script.ts b/apps/docs/src/components/burger/burger.script.ts index 93b8cb4..e9cb182 100644 --- a/apps/docs/src/components/burger/burger.script.ts +++ b/apps/docs/src/components/burger/burger.script.ts @@ -1,5 +1,5 @@ import { BurgerEvents } from '@/components/burger/burger.events'; -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { debounce } from '@ecopages/radiant/decorators/debounce'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; diff --git a/apps/docs/src/components/radiant-counter/radiant-counter.script.ts b/apps/docs/src/components/radiant-counter/radiant-counter.script.ts index a5d93f8..0edabd5 100644 --- a/apps/docs/src/components/radiant-counter/radiant-counter.script.ts +++ b/apps/docs/src/components/radiant-counter/radiant-counter.script.ts @@ -1,4 +1,4 @@ -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; import { onUpdated } from '@ecopages/radiant/decorators/on-updated'; diff --git a/apps/docs/src/layouts/docs-layout/docs-layout.script.ts b/apps/docs/src/layouts/docs-layout/docs-layout.script.ts index b98ac42..bf3d094 100644 --- a/apps/docs/src/layouts/docs-layout/docs-layout.script.ts +++ b/apps/docs/src/layouts/docs-layout/docs-layout.script.ts @@ -1,6 +1,6 @@ import { BurgerEvents } from '@/components/burger/burger.events'; import { onEvent } from '@ecopages/radiant'; -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; @customElement('radiant-navigation') diff --git a/apps/docs/src/pages/docs/core/radiant-element.mdx b/apps/docs/src/pages/docs/core/radiant-element.mdx index 4985135..b9268e2 100644 --- a/apps/docs/src/pages/docs/core/radiant-element.mdx +++ b/apps/docs/src/pages/docs/core/radiant-element.mdx @@ -11,17 +11,203 @@ export const getMetadata = () => ({ # Radiant Element --- -This is the base class for all Radiant. It provides a set of decorators that can be used to define the behavior of the element. +The `RadiantElement` class serves as a base for creating custom web components with reactive properties, event handling, and template rendering capabilities. It extends the native `HTMLElement` and implements the `IRadiantElement` interface, providing a robust framework for building interactive and dynamic elements. -Please note that this class is not meant to be used directly. Instead, you should extend it to create your own custom elements. +The suggested way to create a custom element is to extend the `RadiantElement` class and use the provided decorators and methods to define reactive properties, event listeners, and template rendering logic. This approach simplifies the process of creating and managing custom elements, making it easier to build complex and interactive components. -You can use the `@customElement` decorator to define a custom element. +It is still possible to use the `RadiantElement` class without decorators by manually calling the corresponding methods. However, using decorators is the recommended way to take advantage of the built-in features and simplify the code. -Go to the [Decorator Section](/docs/decorators/custom-element) to see all the available decorators. +Please have a look to the [Decorator Section](/docs/decorators/custom-element) to see the available decorators. - +## Key Features + +- **Reactive Properties**: Automatically update the UI when properties change. +- **Event Management**: Subscribe to and manage events easily. +- **Template Rendering**: Render HTML templates dynamically within the component. + + +### Example + +Following is an example of a custom element created using the `RadiantElement` class with decorators: + + +```typescript +@customElement('my-custom-element') +class MyCustomElement extends RadiantElement { + @reactiveProp({ type: String, defaultValue: 'Foo' }) foo: string; + @query({ ref: 'paragraph' }) paragraph!: HTMLParagraphElement; + + @onUpdated('foo') + updateParagraph() { + this.paragraph.textContent = `Hello ${this.foo}`; + } + + @onEvent({ selector: 'button', type: 'click' }) + handleClick() { + this.foo = 'World'; + } +} +``` + + +The above example demonstrates how to create a custom element using the `RadiantElement` class with decorators. The element has a reactive property `foo`, a reference to a paragraph element, and an event listener for a button click. When the button is clicked, the `foo` property is updated, triggering the `updateParagraph` method to update the paragraph text. + +## Example without Decorators + + ```typescript -@customElement('lc-demo') -export class MyElement extends RadiantElement {} +class MyCustomElement extends RadiantElement { + declare foo: string; + paragraph = this.getRef('paragraph'); + + constructor() { + super(); + this.createReactiveProp('foo', { type: String, defaultValue: 'Foo' }); + this.registerUpdateCallback('foo', () => { + this.paragraph.textContent = `Hello ${this.foo}`; + }); + this.subscribeEvent({ + selector: 'button', + type: 'click', + listener: () => { + this.foo = 'World'; + }, + }); + } +} +customElements.define('my-custom-element-plain', MyCustomElementPlain); ``` - \ No newline at end of file + + +The above example demonstrates how to create a custom element using the `RadiantElement` class without decorators. The element has a reactive property `foo`, a reference to a paragraph element, and an event listener for a button click. When the button is clicked, the `foo` property is updated, triggering the update callback to update the paragraph text. + +## Usage + + +```html + +

+ +
+``` +
+ +## Methods + +### createReactiveProp + +Creates a reactive property for the element. + +- **Parameters**: + - `name`: The name of the property. + - `options`: The property configuration options. + +This method defines a reactive property on the element, allowing it to automatically update the UI when the property changes. + +--- +### createReactiveField + +Creates a reactive field for the element. + +- **Parameters**: + - `name`: The name of the field. + +This method defines a reactive field on the element, allowing it to automatically update the UI when the field changes. + +--- +### registerUpdateCallback + +Registers a callback to be executed when a property is updated. + +- **Parameters**: + - `property`: The name of the property to watch for updates. + - `callback`: The callback function to execute when the property is updated. + +This method allows you to define custom logic that runs when a specific property is updated. + + +--- +### notifyUpdate + +Called when a property of the element is updated. + +- **Parameters**: + - `changedProperty`: The name of the changed property. + - `oldValue`: The old value of the property. + - `newValue`: The new value of the property. + +This method triggers any registered update callbacks for the specified property. + +--- +### subscribeEvent + +Subscribes to a specific event on the Radiant element. + +- **Parameters**: + - `event`: The event listener configuration to subscribe to. +- **Returns**: An unsubscription callback for the event. + +This method allows you to listen for events and execute a callback when the event occurs. + +--- +### subscribeEvents + +Subscribes to multiple events at once. + +- **Parameters**: + - `events`: An array of event listener configurations to subscribe to. +- **Returns**: An array of unsubscription callbacks for each event. + +This method is useful for batch subscribing to multiple events for the element. + +--- +### removeAllSubscribedEvents + +Removes all event listeners that have been subscribed to the Radiant element. + +This method is important for cleaning up event listeners to avoid memory leaks. + +--- +### registerCleanupCallback + +Registers a callback to be executed when the element is disconnected from the DOM. + +- **Parameters**: + - `callback`: The cleanup function to be executed. + +This method allows you to define cleanup logic that runs when the element is removed from the DOM. + +--- +### renderTemplate + +Renders a specified template into a target element. + +- **Parameters**: + - `options`: The rendering options. + - `target`: The target element to render the template into. + - `template`: The HTML template string to render. + - `insert`: The position to insert the rendered template (optional). + +This method allows you to dynamically insert HTML content into the element. + +--- +### connectedContextCallback + +Called when the Radiant element is connected to a specific context. + +- **Parameters**: + - `context`: The context to which the element is connected. + +This method can be overridden to perform actions when the element is connected to a context. + +--- +### getRef + +Retrieves a child element by its `data-ref` attribute. + +- **Parameters**: + - `ref`: The value of the `data-ref` attribute to search for. + - `all`: Whether to return all matching elements (true) or just the first one (false). +- **Returns**: The matching element(s) or null if none are found. + +This method simplifies accessing child elements based on their `data-ref` attributes. \ No newline at end of file diff --git a/apps/docs/src/pages/docs/getting-started/introduction.mdx b/apps/docs/src/pages/docs/getting-started/introduction.mdx index 4195f34..7043ab7 100644 --- a/apps/docs/src/pages/docs/getting-started/introduction.mdx +++ b/apps/docs/src/pages/docs/getting-started/introduction.mdx @@ -7,8 +7,28 @@ export const getMetadata = () => ({ description: 'The place to learn about @ecopages/radiant', }) + + # Introduction ---- + +
+## radiant +/ˈreɪdɪənt/ + +**adjective** +

1. sending out light; shining or glowing brightly.

+
    +
  • "a bird with radiant green and red plumage"
  • +
+

2. (of electromagnetic energy, especially heat) transmitted by radiation, rather than conduction or convection.

+
    +
  • "plants convert the radiant energy of the sun into chemical energy"
  • +
+**noun** +
Radiant is a minimalist web component library designed for simplicity and flexibility. @@ -16,4 +36,4 @@ It leverages the light DOM, allowing components to be styled and manipulated wit This approach deviates from conventional web component best practices, offering a trade-off for a more streamlined development experience. -Ideal for small to medium-sized projects, Radiant provides a lightweight alternative to full web components implementations, reducing unnecessary overhead for projects that don't demand the full capabilities of the standard. \ No newline at end of file +Ideal for small to medium-sized projects, Radiant provides a lightweight alternative to full web components implementations, reducing unnecessary overhead for projects that don't demand the full encapsulation of the standard. \ No newline at end of file diff --git a/apps/docs/src/styles/tailwind.css b/apps/docs/src/styles/tailwind.css index cd05bc2..6652f14 100644 --- a/apps/docs/src/styles/tailwind.css +++ b/apps/docs/src/styles/tailwind.css @@ -3,186 +3,186 @@ @tailwind utilities; :root { - --color-tropical-rain-forest-50-hsla: 100 14% 96%; - --color-tropical-rain-forest-100-hsla: 104 11% 81%; - --color-tropical-rain-forest-200-hsla: 107 11% 66%; - --color-tropical-rain-forest-300-hsla: 109 11% 52%; - --color-tropical-rain-forest-400-hsla: 108 19% 37%; - --color-tropical-rain-forest-500-hsla: 109 37% 23%; - --color-tropical-rain-forest-600-hsla: 108 38% 18%; - --color-tropical-rain-forest-700-hsla: 109 37% 14%; - --color-tropical-rain-forest-800-hsla: 107 37% 10%; - --color-tropical-rain-forest-900-hsla: 105 40% 6%; - --color-tropical-rain-forest-950-hsla: 100 33% 2%; - - --color-scarlet-100-hsla: 353 100% 89%; - --color-scarlet-200-hsla: 351 100% 81%; - --color-scarlet-300-hsla: 350 100% 72%; - --color-scarlet-400-hsla: 350 100% 63%; - --color-scarlet-500-hsla: 350 100% 54%; - --color-scarlet-600-hsla: 350 84% 44%; - --color-scarlet-700-hsla: 351 83% 35%; - --color-scarlet-800-hsla: 351 81% 25%; - --color-scarlet-900-hsla: 353 76% 15%; - --color-scarlet-950-hsla: 8 60% 5%; - - --color-silver-50-hsla: 0 0% 99%; - --color-silver-100-hsla: 0 0% 95%; - --color-silver-200-hsla: 0 0% 91%; - --color-silver-300-hsla: 0 1% 87%; - --color-silver-400-hsla: 0 1% 77%; - --color-silver-500-hsla: 0 1% 69%; - --color-silver-600-hsla: 0 0% 54%; - --color-silver-700-hsla: 0 0% 30%; - --color-silver-800-hsla: 0 0% 20%; - --color-silver-900-hsla: 0 0% 10%; - --color-silver-950-hsla: 0 0% 5%; - - --color-laser-lemon-50-hsla: 60 100% 98%; - --color-laser-lemon-100-hsla: 60 100% 88%; - --color-laser-lemon-200-hsla: 60 100% 79%; - --color-laser-lemon-300-hsla: 60 100% 69%; - --color-laser-lemon-400-hsla: 60 100% 60%; - --color-laser-lemon-500-hsla: 60 100% 50%; - --color-laser-lemon-600-hsla: 60 99% 41%; - --color-laser-lemon-700-hsla: 60 98% 32%; - --color-laser-lemon-800-hsla: 59 93% 23%; - --color-laser-lemon-900-hsla: 58 86% 14%; - --color-laser-lemon-950-hsla: 55 50% 5%; - - --color-navy-blue-50-hsla: 223 64% 98%; - --color-navy-blue-100-hsla: 214 65% 88%; - --color-navy-blue-200-hsla: 212 67% 78%; - --color-navy-blue-300-hsla: 212 66% 68%; - --color-navy-blue-400-hsla: 212 67% 58%; - --color-navy-blue-500-hsla: 211 74% 47%; - --color-navy-blue-600-hsla: 212 73% 39%; - --color-navy-blue-700-hsla: 212 71% 30%; - --color-navy-blue-800-hsla: 212 69% 22%; - --color-navy-blue-900-hsla: 214 64% 13%; - --color-navy-blue-950-hsla: 225 36% 4%; - - --background: var(--color-silver-50-hsla); - --on-background: var(--color-silver-900-hsla); - --background-accent: var(--color-silver-100-hsla); - --on-background-accent: var(--color-silver-950-hsla); - --primary-container: var(--color-tropical-rain-forest-100-hsla); - --on-primary-container: var(--color-rain-forest-900-hsla); - --primary: var(--color-tropical-rain-forest-500-hsla); - --on-primary: var(--color-silver-50-hsla); - --secondary: var(--color-scarlet-500-hsla); - --on-secondary: var(--color-silver-50-hsla); - - --border: var(--color-silver-200-hsla); - --link: var(--color-navy-blue-700-hsla); - - --background-code: var(--color-silver-100-hsla); - --on-background-code: var(--color-laser-lemon-800-hsla); + --color-tropical-rain-forest-50-hsla: 100 14% 96%; + --color-tropical-rain-forest-100-hsla: 104 11% 81%; + --color-tropical-rain-forest-200-hsla: 107 11% 66%; + --color-tropical-rain-forest-300-hsla: 109 11% 52%; + --color-tropical-rain-forest-400-hsla: 108 19% 37%; + --color-tropical-rain-forest-500-hsla: 109 37% 23%; + --color-tropical-rain-forest-600-hsla: 108 38% 18%; + --color-tropical-rain-forest-700-hsla: 109 37% 14%; + --color-tropical-rain-forest-800-hsla: 107 37% 10%; + --color-tropical-rain-forest-900-hsla: 105 40% 6%; + --color-tropical-rain-forest-950-hsla: 100 33% 2%; + + --color-scarlet-100-hsla: 353 100% 89%; + --color-scarlet-200-hsla: 351 100% 81%; + --color-scarlet-300-hsla: 350 100% 72%; + --color-scarlet-400-hsla: 350 100% 63%; + --color-scarlet-500-hsla: 350 100% 54%; + --color-scarlet-600-hsla: 350 84% 44%; + --color-scarlet-700-hsla: 351 83% 35%; + --color-scarlet-800-hsla: 351 81% 25%; + --color-scarlet-900-hsla: 353 76% 15%; + --color-scarlet-950-hsla: 8 60% 5%; + + --color-silver-50-hsla: 0 0% 99%; + --color-silver-100-hsla: 0 0% 95%; + --color-silver-200-hsla: 0 0% 91%; + --color-silver-300-hsla: 0 1% 87%; + --color-silver-400-hsla: 0 1% 77%; + --color-silver-500-hsla: 0 1% 69%; + --color-silver-600-hsla: 0 0% 54%; + --color-silver-700-hsla: 0 0% 30%; + --color-silver-800-hsla: 0 0% 20%; + --color-silver-900-hsla: 0 0% 10%; + --color-silver-950-hsla: 0 0% 5%; + + --color-laser-lemon-50-hsla: 60 100% 98%; + --color-laser-lemon-100-hsla: 60 100% 88%; + --color-laser-lemon-200-hsla: 60 100% 79%; + --color-laser-lemon-300-hsla: 60 100% 69%; + --color-laser-lemon-400-hsla: 60 100% 60%; + --color-laser-lemon-500-hsla: 60 100% 50%; + --color-laser-lemon-600-hsla: 60 99% 41%; + --color-laser-lemon-700-hsla: 60 98% 32%; + --color-laser-lemon-800-hsla: 59 93% 23%; + --color-laser-lemon-900-hsla: 58 86% 14%; + --color-laser-lemon-950-hsla: 55 50% 5%; + + --color-navy-blue-50-hsla: 223 64% 98%; + --color-navy-blue-100-hsla: 214 65% 88%; + --color-navy-blue-200-hsla: 212 67% 78%; + --color-navy-blue-300-hsla: 212 66% 68%; + --color-navy-blue-400-hsla: 212 67% 58%; + --color-navy-blue-500-hsla: 211 74% 47%; + --color-navy-blue-600-hsla: 212 73% 39%; + --color-navy-blue-700-hsla: 212 71% 30%; + --color-navy-blue-800-hsla: 212 69% 22%; + --color-navy-blue-900-hsla: 214 64% 13%; + --color-navy-blue-950-hsla: 225 36% 4%; + + --background: var(--color-silver-50-hsla); + --on-background: var(--color-silver-900-hsla); + --background-accent: var(--color-silver-100-hsla); + --on-background-accent: var(--color-silver-950-hsla); + --primary-container: var(--color-tropical-rain-forest-100-hsla); + --on-primary-container: var(--color-rain-forest-900-hsla); + --primary: var(--color-tropical-rain-forest-500-hsla); + --on-primary: var(--color-silver-50-hsla); + --secondary: var(--color-scarlet-500-hsla); + --on-secondary: var(--color-silver-50-hsla); + + --border: var(--color-silver-200-hsla); + --link: var(--color-navy-blue-700-hsla); + + --background-code: var(--color-silver-100-hsla); + --on-background-code: var(--color-laser-lemon-800-hsla); } .dark { - --background: var(--color-silver-950-hsla); - --on-background: var(--color-silver-50-hsla); - --background-accent: var(--color-silver-900-hsla); - --on-background-accent: var(--color-silver-50-hsla); - --primary-container: var(--color-tropical-rain-forest-800-hsla); - --on-primary-container: var(--color-tropical-rain-forest-50-hsla); - --primary: var(--color-tropical-rain-forest-500-hsla); - --on-primary: var(--color-silver-950-hsla); - --secondary: var(--color-scarlet-500-hsla); - --on-secondary: var(--color-silver-950-hsla); - - --border: var(--color-silver-900-hsla); - --link: var(--color-navy-blue-400-hsla); - - --background-code: var(--color-silver-900-hsla); - --on-background-code: var(--color-laser-lemon-300-hsla); + --background: var(--color-silver-950-hsla); + --on-background: var(--color-silver-50-hsla); + --background-accent: var(--color-silver-900-hsla); + --on-background-accent: var(--color-silver-50-hsla); + --primary-container: var(--color-tropical-rain-forest-800-hsla); + --on-primary-container: var(--color-tropical-rain-forest-50-hsla); + --primary: var(--color-tropical-rain-forest-500-hsla); + --on-primary: var(--color-silver-950-hsla); + --secondary: var(--color-scarlet-500-hsla); + --on-secondary: var(--color-silver-950-hsla); + + --border: var(--color-silver-900-hsla); + --link: var(--color-navy-blue-400-hsla); + + --background-code: var(--color-silver-900-hsla); + --on-background-code: var(--color-laser-lemon-300-hsla); } body { - @apply bg-background text-on-background min-h-[100svh]; + @apply bg-background text-on-background min-h-[100svh]; } .prose { - h1 { - @apply text-4xl font-bold text-on-background-accent my-6; - } - - h2 { - @apply text-3xl font-bold text-on-background-accent my-6; - } - - h3 { - @apply text-2xl font-bold text-on-background-accent my-5; - } - - h4 { - @apply text-xl font-bold text-on-background-accent my-5; - } - - h5 { - @apply text-lg font-bold text-on-background-accent my-4; - } - - h6 { - @apply text-base font-bold text-on-background-accent my-4; - } - - p { - @apply text-base text-on-background my-4; - } - - a { - @apply text-link underline; - } - - ul, - ol { - @apply list-disc pl-8 my-4; - } - - code { - @apply bg-background-code text-sm text-on-background-code rounded-md; - } - - pre > code { - @apply bg-transparent text-inherit rounded-md my-8; - } + h1 { + @apply text-4xl font-bold text-on-background-accent my-6; + } + + h2 { + @apply text-3xl font-bold text-on-background-accent my-6; + } + + h3 { + @apply text-2xl font-bold text-on-background-accent my-5; + } + + h4 { + @apply text-xl font-bold text-on-background-accent my-5; + } + + h5 { + @apply text-lg font-bold text-on-background-accent my-4; + } + + h6 { + @apply text-base font-bold text-on-background-accent my-4; + } + + p { + @apply text-base text-on-background my-4; + } + + a { + @apply text-link underline; + } + + ul, + ol { + @apply list-disc pl-8 my-4; + } + + code { + @apply bg-background-code text-sm text-on-background-code rounded-md; + } + + pre > code { + @apply bg-transparent text-inherit rounded-md my-8; + } } .banner { - @apply border-l-4 px-4 py-4 rounded-md flex flex-col mt-4; - - p { - @apply my-1; - } - - &--alert { - @apply bg-yellow-100 border-yellow-500; - - p, - span, - a { - @apply text-yellow-700; - } - } - - &--info { - @apply bg-blue-100 border-primary; - - p, - span, - a { - @apply text-blue-700; - } - } - - &__title { - @apply font-bold mb-0; - } - - &__link { - @apply underline self-end inline-flex items-center gap-2 rounded-md p-2; - @apply hover:text-yellow-900 hover:bg-yellow-50; - } + @apply border-l-4 px-4 py-4 rounded-md flex flex-col mt-4; + + p { + @apply my-1; + } + + &--alert { + @apply bg-yellow-100 border-yellow-500; + + p, + span, + a { + @apply text-yellow-700; + } + } + + &--info { + @apply bg-blue-100 border-blue-800; + + p, + span, + a { + @apply text-blue-700; + } + } + + &__title { + @apply font-bold mb-0; + } + + &__link { + @apply underline self-end inline-flex items-center gap-2 rounded-md p-2; + @apply hover:text-yellow-900 hover:bg-yellow-50; + } } diff --git a/biome.json b/biome.json index 2698c0b..ad8671e 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/1.9.2/schema.json", + "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", "organizeImports": { "enabled": true }, @@ -17,6 +17,9 @@ }, "correctness": { "useJsxKeyInIterable": "off" + }, + "complexity": { + "useArrowFunction": "off" } } }, diff --git a/bun.lockb b/bun.lockb index b425ba1f8c00ea68ab8fefa81cf307e0baffdc89..51ac8c6821ac322cadeee293c15bc6a90c4d459c 100755 GIT binary patch delta 64167 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 UeyVEa$4ktm_oW0=`l-(RKNYq_umAu6 delta 44978 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 diff --git a/package.json b/package.json index 0ab6c21..159a83d 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,15 @@ "format": "bunx @biomejs/biome format --write .", "lint": "bunx @biomejs/biome check --write .", "prepare": "husky", - "build:all": "bun run --filter '*' build:lib", + "build:all": "bun run --filter '*' build:lib | tee /dev/null", "test:all": "bun run --filter '*' test:lib | tee /dev/null", "changeset": "changeset", "prerelease": "bun run build:all && bun run test:all", "release": "changeset publish", "dev:docs": "bun run --filter radiant-docs dev | tee /dev/null", - "dev:playground-vite": "bun run --filter playground-vite dev & bun run --filter @ecopages/radiant watch:lib", + "watch:lib": "bun run --filter '*' watch:lib | tee /dev/null", + "dev:playground-vite": "bun run --filter playground-vite dev | tee /dev/null & bun run watch:lib ", + "dev:playground-vite:legacy": "bun run --filter playground-vite dev:legacy | tee /dev/null & bun run watch:lib | tee /dev/null", "build:docs": "bun run --filter radiant-docs build | tee /dev/null", "preview:docs": "bun run --filter radiant-docs preview", "postinstall": "bunx symlink-dir node_modules/@ecopages/core/src/bin/ecopages.js node_modules/.bin/ecopages" diff --git a/packages/radiant/build.ts b/packages/radiant/build.ts index 731a33d..d526a68 100644 --- a/packages/radiant/build.ts +++ b/packages/radiant/build.ts @@ -11,9 +11,9 @@ const build = await Bun.build({ outdir: 'dist', root: './src', target: 'browser', - minify: watchMode, + minify: !watchMode, format: 'esm', - splitting: watchMode, + splitting: !watchMode, sourcemap: 'external', }); diff --git a/packages/radiant/package.json b/packages/radiant/package.json index 9fbd179..bf1a972 100644 --- a/packages/radiant/package.json +++ b/packages/radiant/package.json @@ -17,8 +17,27 @@ "build:types": "tsc -p tsconfig.build.json && tsc-alias", "watch:lib": "bun run ./build.ts --watch", "clean": "rm -rf ./dist", - "test:lib": "bun test --coverage" + "test:standard": "vitest run", + "test:legacy": "vitest run -- --legacy", + "test:lib": "bun run test:standard && bun run test:legacy", + "test:lib:coverage": "bun run test:standard -- --coverage && bun run vitest run --coverage -- --legacy" }, + "dependencies": { + "@kitajs/html": "^4.1.0" + }, + "devDependencies": { + "@happy-dom/global-registrator": "^15.7.4", + "@kitajs/ts-html-plugin": "^4.0.1", + "@testing-library/dom": "^10.4.0", + "@testing-library/user-event": "^14.5.2", + "@vitest/coverage-istanbul": "^2.1.3", + "bun-types": "latest", + "tsc-alias": "1.8.10", + "esbuild": "^0.23.0", + "vitest": "^2.1.3" + }, + "files": ["/dist/*"], + "sideEffects": false, "exports": { "./package.json": { "import": "./package.json" @@ -31,26 +50,6 @@ "types": "./dist/context/index.d.ts", "import": "./dist/context/index.js" }, - "./core": { - "types": "./dist/core/index.d.ts", - "import": "./dist/core/index.js" - }, - "./decorators": { - "types": "./dist/decorators.d.ts", - "import": "./dist/decorators.js" - }, - "./mixins": { - "types": "./dist/mixins/index.d.ts", - "import": "./dist/mixins/index.js" - }, - "./tools": { - "types": "./dist/tools/index.d.ts", - "import": "./dist/tools/index.js" - }, - "./utils": { - "types": "./dist/utils/index.d.ts", - "import": "./dist/utils/index.js" - }, "./context/create-context": { "types": "./dist/context/create-context.d.ts", "import": "./dist/context/create-context.js" @@ -123,16 +122,5 @@ "types": "./dist/tools/event-emitter.d.ts", "import": "./dist/tools/event-emitter.js" } - }, - "dependencies": { - "@kitajs/html": "^4.1.0" - }, - "devDependencies": { - "@happy-dom/global-registrator": "^15.7.4", - "@kitajs/ts-html-plugin": "^4.0.1", - "bun-types": "latest", - "tsc-alias": "1.8.10", - "esbuild": "^0.23.0" - }, - "files": ["/dist/*"] + } } diff --git a/packages/radiant/src/context/context-provider.ts b/packages/radiant/src/context/context-provider.ts index 242fb11..c8ed3a3 100644 --- a/packages/radiant/src/context/context-provider.ts +++ b/packages/radiant/src/context/context-provider.ts @@ -1,6 +1,5 @@ -import type { RadiantElement } from '@/core/radiant-element'; -import { type AttributeTypeConstant, readAttributeValue } from '@/utils/attribute-utils'; -import { query } from '..'; +import type { RadiantElement } from '../core/radiant-element'; +import { type AttributeTypeConstant, readAttributeValue } from '../utils/attribute-utils'; import { ContextEventsTypes, ContextOnMountEvent, diff --git a/packages/radiant/src/context/decorators/consume-context.ts b/packages/radiant/src/context/decorators/consume-context.ts index 969bdb3..bf45686 100644 --- a/packages/radiant/src/context/decorators/consume-context.ts +++ b/packages/radiant/src/context/decorators/consume-context.ts @@ -1,6 +1,11 @@ -import { ContextRequestEvent } from '@/context/events'; -import type { UnknownContext } from '@/context/types'; -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../../types'; +import type { UnknownContext } from '../types'; +import { consumeContext as legacyConsumeContext } from './legacy/consume-context'; +import { consumeContext as standardConsumeContext } from './standard/consume-context'; /** * A decorator to provide a context to the target element. @@ -8,17 +13,19 @@ import type { RadiantElement } from '@/core/radiant-element'; * @returns */ export function consumeContext(contextToProvide: UnknownContext) { - return (proto: RadiantElement, propertyKey: string) => { - const originalConnectedCallback = proto.connectedCallback; - - proto.connectedCallback = function (this: RadiantElement) { - originalConnectedCallback.call(this); - this.dispatchEvent( - new ContextRequestEvent(contextToProvide, (context) => { - (this as any)[propertyKey] = context; - this.connectedContextCallback(contextToProvide); - }), + return function ( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext === 'object') { + return standardConsumeContext(contextToProvide)( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], ); - }; + } + return legacyConsumeContext(contextToProvide)( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); }; } diff --git a/packages/radiant/src/context/decorators/context-selector.ts b/packages/radiant/src/context/decorators/context-selector.ts index 3f5f33e..c1a2f8d 100644 --- a/packages/radiant/src/context/decorators/context-selector.ts +++ b/packages/radiant/src/context/decorators/context-selector.ts @@ -1,42 +1,39 @@ -import { ContextSubscriptionRequestEvent } from '@/context/events'; -import type { Context, ContextType, UnknownContext } from '@/context/types'; -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyMethodDecoratorArgs, + StandardMethodDecoratorArgs, + StandardOrLegacyMethodDecoratorArgs, +} from '../../types'; +import type { Context, UnknownContext } from '../types'; +import { contextSelector as legacyContextSelector } from './legacy/context-selector'; +import { contextSelector as standardContextSelector } from './standard/context-selector'; -type ArgsType = SubscribeToContextOptions['select'] extends (...args: any[]) => infer R - ? R - : ContextType; - -type SubscribeToContextOptions = { +export type SubscribeToContextOptions = { context: T; select?: (context: T['__context__']) => unknown; subscribe?: boolean; }; + /** * A decorator to subscribe to a context selector. - * @param context The context to subscribe to. - * @param selector The selector to subscribe to. If not provided, the whole context will be subscribed to. - * @param subscribe @default true Whether to subscribe or unsubscribe. Optional. + * @param option {@link SubscribeToContextOptions} * @returns */ -export function contextSelector>({ - context, - select, - subscribe = true, -}: SubscribeToContextOptions) { - return (proto: RadiantElement, _: string, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - const originalConnectedCallback = proto.connectedCallback; - - proto.connectedCallback = function (this: RadiantElement) { - originalConnectedCallback.call(this); - this.dispatchEvent(new ContextSubscriptionRequestEvent(context, originalMethod.bind(this), select, subscribe)); - }; - - descriptor.value = function (...args: ArgsType[]) { - const result = originalMethod.apply(this, args); - return result; - }; - - return descriptor; +export function contextSelector>(options: SubscribeToContextOptions) { + return function ( + protoOrTarget: StandardOrLegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyMethodDecoratorArgs['nameOrContext'], + descriptor?: StandardOrLegacyMethodDecoratorArgs['descriptor'], + ): any { + if (typeof nameOrContext === 'object') { + return standardContextSelector(options)( + protoOrTarget as StandardMethodDecoratorArgs['protoOrTarget'], + nameOrContext as StandardMethodDecoratorArgs['nameOrContext'], + ); + } + return legacyContextSelector(options)( + protoOrTarget as LegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyMethodDecoratorArgs['nameOrContext'], + descriptor as LegacyMethodDecoratorArgs['descriptor'], + ); }; } diff --git a/packages/radiant/src/context/decorators/legacy/consume-context.ts b/packages/radiant/src/context/decorators/legacy/consume-context.ts new file mode 100644 index 0000000..adc329d --- /dev/null +++ b/packages/radiant/src/context/decorators/legacy/consume-context.ts @@ -0,0 +1,19 @@ +import type { RadiantElement } from '../../../core/radiant-element'; +import { ContextRequestEvent } from '../../events'; +import type { UnknownContext } from '../../types'; + +export function consumeContext(contextToProvide: UnknownContext) { + return (proto: RadiantElement, propertyKey: string) => { + const originalConnectedCallback = proto.connectedCallback; + + proto.connectedCallback = function (this: RadiantElement) { + originalConnectedCallback.call(this); + this.dispatchEvent( + new ContextRequestEvent(contextToProvide, (context) => { + (this as any)[propertyKey] = context; + this.connectedContextCallback(contextToProvide); + }), + ); + }; + }; +} diff --git a/packages/radiant/src/context/decorators/legacy/context-selector.ts b/packages/radiant/src/context/decorators/legacy/context-selector.ts new file mode 100644 index 0000000..d030b16 --- /dev/null +++ b/packages/radiant/src/context/decorators/legacy/context-selector.ts @@ -0,0 +1,31 @@ +import type { RadiantElement } from '../../../core/radiant-element'; +import { ContextSubscriptionRequestEvent } from '../../events'; +import type { Context, ContextType, UnknownContext } from '../../types'; +import type { SubscribeToContextOptions } from '../context-selector'; + +type ArgsType = SubscribeToContextOptions['select'] extends (...args: any[]) => infer R + ? R + : ContextType; + +export function contextSelector>({ + context, + select, + subscribe = true, +}: SubscribeToContextOptions) { + return (proto: RadiantElement, _: string, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + const originalConnectedCallback = proto.connectedCallback; + + proto.connectedCallback = function (this: RadiantElement) { + originalConnectedCallback.call(this); + this.dispatchEvent(new ContextSubscriptionRequestEvent(context, originalMethod.bind(this), select, subscribe)); + }; + + descriptor.value = function (...args: ArgsType[]) { + const result = originalMethod.apply(this, args); + return result; + }; + + return descriptor; + }; +} diff --git a/packages/radiant/src/context/decorators/legacy/provide-context.ts b/packages/radiant/src/context/decorators/legacy/provide-context.ts new file mode 100644 index 0000000..7725689 --- /dev/null +++ b/packages/radiant/src/context/decorators/legacy/provide-context.ts @@ -0,0 +1,16 @@ +import { ContextProvider } from '../../../context/context-provider'; +import type { UnknownContext } from '../../../context/types'; +import type { RadiantElement } from '../../../core/radiant-element'; +import type { ProvideContextOptions } from '../provide-context'; + +export function provideContext({ context, initialValue, hydrate }: ProvideContextOptions) { + return (proto: RadiantElement, propertyKey: string) => { + const originalConnectedCallback = proto.connectedCallback; + + proto.connectedCallback = function (this: RadiantElement) { + (this as any)[propertyKey] = new ContextProvider(this, { context, initialValue, hydrate }); + originalConnectedCallback.call(this); + this.connectedContextCallback(context); + }; + }; +} diff --git a/packages/radiant/src/context/decorators/provide-context.ts b/packages/radiant/src/context/decorators/provide-context.ts index ebc9744..f1339fb 100644 --- a/packages/radiant/src/context/decorators/provide-context.ts +++ b/packages/radiant/src/context/decorators/provide-context.ts @@ -1,9 +1,14 @@ -import { ContextProvider } from '@/context/context-provider'; -import type { UnknownContext } from '@/context/types'; -import type { RadiantElement } from '@/core/radiant-element'; -import type { AttributeTypeConstant } from '@/utils/attribute-utils'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../../types'; +import type { AttributeTypeConstant } from '../../utils'; +import type { UnknownContext } from '../types'; +import { provideContext as legacyProvideContext } from './legacy/provide-context'; +import { provideContext as standardProvideContext } from './standard/provide-context'; -type CreateContextOptions = { +export type ProvideContextOptions = { context: T; initialValue?: T['__context__']; hydrate?: AttributeTypeConstant; @@ -11,17 +16,23 @@ type CreateContextOptions = { /** * A decorator to provide a context to the target element. - * @param contextToProvide + * @param options {@link ProvideContextOptions} * @returns */ -export function provideContext({ context, initialValue, hydrate }: CreateContextOptions) { - return (proto: RadiantElement, propertyKey: string) => { - const originalConnectedCallback = proto.connectedCallback; - - proto.connectedCallback = function (this: RadiantElement) { - originalConnectedCallback.call(this); - (this as any)[propertyKey] = new ContextProvider(this, { context, initialValue, hydrate }); - this.connectedContextCallback(context); - }; +export function provideContext(options: ProvideContextOptions) { + return function ( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext === 'object') { + return standardProvideContext(options)( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], + ); + } + return legacyProvideContext(options)( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); }; } diff --git a/packages/radiant/src/context/decorators/standard/consume-context.ts b/packages/radiant/src/context/decorators/standard/consume-context.ts new file mode 100644 index 0000000..32b6077 --- /dev/null +++ b/packages/radiant/src/context/decorators/standard/consume-context.ts @@ -0,0 +1,18 @@ +import type { RadiantElement } from '../../../core/radiant-element'; +import { ContextEventsTypes, ContextRequestEvent } from '../../events'; +import type { UnknownContext } from '../../types'; + +export function consumeContext(contextToProvide: UnknownContext) { + return (_: undefined, context: ClassFieldDecoratorContext) => { + const contextName = String(context.name); + context.addInitializer(function (this: T) { + this.dispatchEvent( + new ContextRequestEvent(contextToProvide, (context) => { + (this as any)[contextName] = context; + this.connectedContextCallback(contextToProvide); + this.dispatchEvent(new CustomEvent(ContextEventsTypes.MOUNTED, { detail: context })); + }), + ); + }); + }; +} diff --git a/packages/radiant/src/context/decorators/standard/context-selector.ts b/packages/radiant/src/context/decorators/standard/context-selector.ts new file mode 100644 index 0000000..134fdd0 --- /dev/null +++ b/packages/radiant/src/context/decorators/standard/context-selector.ts @@ -0,0 +1,18 @@ +import type { Method } from '../../../types'; +import { ContextSubscriptionRequestEvent } from '../../events'; +import type { Context } from '../../types'; +import type { SubscribeToContextOptions } from '../context-selector'; + +export function contextSelector>({ + context, + select, + subscribe = true, +}: SubscribeToContextOptions) { + return function (originalMethod: T, targetContext: ClassMethodDecoratorContext): void { + targetContext.addInitializer(function (this: any) { + queueMicrotask(() => { + this.dispatchEvent(new ContextSubscriptionRequestEvent(context, originalMethod.bind(this), select, subscribe)); + }); + }); + }; +} diff --git a/packages/radiant/src/context/decorators/standard/provide-context.ts b/packages/radiant/src/context/decorators/standard/provide-context.ts new file mode 100644 index 0000000..d821231 --- /dev/null +++ b/packages/radiant/src/context/decorators/standard/provide-context.ts @@ -0,0 +1,14 @@ +import type { RadiantElement } from '../../../core/radiant-element'; +import { ContextProvider } from '../../context-provider'; +import type { UnknownContext } from '../../types'; +import type { ProvideContextOptions } from '../provide-context'; + +export function provideContext({ context, initialValue, hydrate }: ProvideContextOptions) { + return (_: undefined, targetContext: ClassFieldDecoratorContext) => { + const contextName = String(targetContext.name); + targetContext.addInitializer(function (this: C) { + (this as any)[contextName] = new ContextProvider(this, { context, initialValue, hydrate }); + this.connectedContextCallback(context); + }); + }; +} diff --git a/packages/radiant/src/context/events.ts b/packages/radiant/src/context/events.ts index ab95e0c..16d2746 100644 --- a/packages/radiant/src/context/events.ts +++ b/packages/radiant/src/context/events.ts @@ -4,9 +4,10 @@ import type { Context, ContextCallback, ContextType, UnknownContext } from './ty * List of events which can be emitted by a context provider or requester. */ export enum ContextEventsTypes { - SUBSCRIPTION_REQUEST = 'context--subscription-request', + SUBSCRIPTION_REQUEST = 'context-subscription-request', CONTEXT_REQUEST = 'context-request', - ON_MOUNT = 'context--on-mount', + ON_MOUNT = 'context-on-mount', + MOUNTED = 'context-mounted', } /** diff --git a/packages/radiant/src/context/index.ts b/packages/radiant/src/context/index.ts deleted file mode 100644 index fcc7efc..0000000 --- a/packages/radiant/src/context/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './create-context'; -export * from './context-provider'; -export * from './events'; -export * from './decorators/provide-context'; -export * from './decorators/consume-context'; -export * from './decorators/context-selector'; -export * from './types'; diff --git a/packages/radiant/src/core/index.ts b/packages/radiant/src/core/index.ts deleted file mode 100644 index 3d65fa2..0000000 --- a/packages/radiant/src/core/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './radiant-element'; diff --git a/packages/radiant/src/core/radiant-element.ts b/packages/radiant/src/core/radiant-element.ts index 8b6b296..81cb646 100644 --- a/packages/radiant/src/core/radiant-element.ts +++ b/packages/radiant/src/core/radiant-element.ts @@ -1,5 +1,14 @@ -import type { UnknownContext } from '@/context/types'; -import type { AttributeTypeConstant, ReadAttributeValueReturnType, WriteAttributeValueReturnType } from '@/utils'; +import type { EventEmitter } from '@/tools'; +import type { UnknownContext } from '../context/types'; +import { + type AttributeTypeConstant, + type ReadAttributeValueReturnType, + type WriteAttributeValueReturnType, + getInitialValue, + isValueOfType, + readAttributeValue, + writeAttributeValue, +} from '../utils/attribute-utils'; /** * Possible positions to insert a rendered template. @@ -13,23 +22,40 @@ export type RadiantElementEventListener = { selector: string; type: string; listener: EventListener; - id: string; options?: AddEventListenerOptions; }; /** * Represents a property metadata object. */ -export interface PropertyConfig { +export interface ReactiveProperty { type: AttributeTypeConstant; - propertyName: string; - attributeKey: string; + value?: T; + initialValue?: T; + name: string; + attribute: string; converter: { fromAttribute: (value: string) => ReadAttributeValueReturnType; toAttribute: (value: any) => WriteAttributeValueReturnType; }; } +/** + * Represents the options for a reactive property. + */ +export type ReactivePropertyOptions = { + type: AttributeTypeConstant; + reflect?: boolean; + attribute?: string; + defaultValue?: T; +}; + +export type ReactiveField = { + name: string; + value: T; + initialValue: T; +}; + /** * Represents an interface for a Radiant element. */ @@ -40,7 +66,7 @@ export interface IRadiantElement { * @param oldValue - The old value of the property. * @param newValue - The new value of the property. */ - updated(changedProperty: string, oldValue: unknown, newValue: unknown): void; + notifyUpdate(changedProperty: string, oldValue: unknown, newValue: unknown): void; /** * Subscribes to a Radiant element event. @@ -55,15 +81,9 @@ export interface IRadiantElement { subscribeEvents(events: RadiantElementEventListener[]): void; /** - * Unsubscribes from a Radiant element event. - * @param id - The ID of the event listener to unsubscribe from. - */ - unsubscribeEvent(id: string): void; - - /** - * Removes all subscribed events from the Radiant element. + * It adds a callback to be executed when the Radiant element is disconnected from the DOM. */ - removeAllSubscribedEvents(): void; + registerCleanupCallback(callback: () => void): void; /** * Renders a template into the specified target element. @@ -83,6 +103,14 @@ export interface IRadiantElement { * @param context - The connected context. */ connectedContextCallback(context: UnknownContext): void; + + /** + * Gets a reference to a child element by its data-ref attribute. + * @param ref - The data-ref attribute value of the element to get. + * @param all - Whether to get all elements with the specified data-ref attribute value. + * @returns The element with the specified data-ref attribute value, an array of elements or null if no element was found. + */ + getRef(ref: string, all: boolean): T | T[]; } /** @@ -91,9 +119,39 @@ export interface IRadiantElement { * @implements IRadiantElement */ export class RadiantElement extends HTMLElement implements IRadiantElement { - declare propertyConfigMap: Map; - declare updatesRegistry: Map>; + /** + * A map of property metadata objects, it contains useful information about the properties configured via decorators. + */ + private reactiveProperties = new Map(); + + /** + * A map of reactive fields, it contains the reactive fields configured via decorators. + */ + private reactiveFields = new Map(); + + /** + * A map of property update callbacks. These callbacks are called when a property is updated. + */ + private updateCallbacks = new Map any>>(); + + /** + * A map of event subscriptions used to manage event listeners on the Radiant element. + */ private eventSubscriptions = new Map(); + + /** + * A map for event emitters + */ + private eventEmitters = new Map(); + + /** + * An array of cleanup callbacks to be executed when the Radiant element is disconnected from the DOM. + */ + private onDisconnectedCallback: (() => void)[] = []; + + /** + * A flag indicating whether the element has been connected to the DOM. + */ private elementReady = false; connectedCallback() { @@ -104,31 +162,42 @@ export class RadiantElement extends HTMLElement implements IRadiantElement { disconnectedCallback() { this.removeAllSubscribedEvents(); + for (const cleanup of this.onDisconnectedCallback) { + cleanup(); + } } - updated(changedProperty: string, oldValue: unknown, value: unknown) { - if (!this.elementReady || !this.updatesRegistry || oldValue === value) return; - const updates = this.updatesRegistry.get(changedProperty); + public notifyUpdate(changedProperty: string, oldValue: unknown, value: unknown) { + if (!this.updateCallbacks || oldValue === value) return; + const updates = this.updateCallbacks.get(changedProperty); + if (updates) { for (const update of updates) { - (this as any)[update](); + update(); } } } + private transformAttributeValue(value: string | null, config: any): unknown { + return value ? config?.converter.fromAttribute(value) : value; + } + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { if (oldValue === newValue || !this.elementReady) return; - if (name in this) { - const config = this.propertyConfigMap.get(name); - const transformedValue = newValue ? config?.converter.fromAttribute(newValue) : newValue; - const transformedOldValue = oldValue ? config?.converter.fromAttribute(oldValue) : oldValue; - (this as RadiantElement & { [key: string]: any })[name] = transformedValue; - this.updated(name, transformedOldValue, transformedValue); + if (this.reactiveProperties.has(name)) { + const config = this.reactiveProperties.get(name); + + const transformedValue = this.transformAttributeValue(newValue, config); + const transformedOldValue = this.transformAttributeValue(oldValue, config); + + const key = config ? config.attribute : name; + (this as any)[key] = transformedValue; + this.notifyUpdate(name, transformedOldValue, transformedValue); } } - renderTemplate({ + public renderTemplate({ target = this, template, insert = 'replace', @@ -150,24 +219,46 @@ export class RadiantElement extends HTMLElement implements IRadiantElement { } } - public subscribeEvents(events: RadiantElementEventListener[]): void { + public registerReactiveProperty(config: ReactiveProperty) { + this.reactiveProperties.set(config.name, config); + } + + public registerReactiveField(config: ReactiveField) { + this.reactiveFields.set(config.name, config); + } + + public registerUpdateCallback(property: string, update: (...rest: any[]) => any) { + if (!this.updateCallbacks.has(property)) { + this.updateCallbacks.set(property, new Set()); + } + this.updateCallbacks.get(property)?.add(update); + } + + public subscribeEvents(events: RadiantElementEventListener[]): Array<() => void> { + const unsubscribers: Array<() => void> = []; for (const event of events) { - this.subscribeEvent(event); + unsubscribers.push(this.subscribeEvent(event)); } + return unsubscribers; } - public subscribeEvent(eventConfig: RadiantElementEventListener): void { + public subscribeEvent(eventConfig: RadiantElementEventListener): () => void { const delegatedListener = (delegatedEvent: Event) => { if (delegatedEvent.target && (delegatedEvent.target as Element).matches(eventConfig.selector)) { eventConfig.listener.call(this, delegatedEvent); } }; - + const subscriptionId = `${eventConfig.type}:${eventConfig.selector}`; this.addEventListener(eventConfig.type, delegatedListener, eventConfig.options); - this.eventSubscriptions.set(eventConfig.id, { ...eventConfig, listener: delegatedListener }); + this.eventSubscriptions.set(subscriptionId, { + ...eventConfig, + listener: delegatedListener, + }); + + return this.unsubscribeEvent.bind(this, subscriptionId); } - public unsubscribeEvent(id: string): void { + private unsubscribeEvent(id: string): void { const eventSubscription = this.eventSubscriptions.get(id); if (eventSubscription) { this.removeEventListener(eventSubscription.type, eventSubscription.listener, eventSubscription.options); @@ -175,10 +266,118 @@ export class RadiantElement extends HTMLElement implements IRadiantElement { } } - public removeAllSubscribedEvents(): void { + private removeAllSubscribedEvents(): void { for (const eventSubscription of this.eventSubscriptions.values()) { this.removeEventListener(eventSubscription.type, eventSubscription.listener, eventSubscription.options); } this.eventSubscriptions.clear(); } + + public registerCleanupCallback(callback: () => void): void { + this.onDisconnectedCallback.push(callback); + } + + public registerEventEmitter(name: string, emitter: EventEmitter) { + this.eventEmitters.set(name, emitter); + } + + public getRef(ref: string, all: true): T[]; + public getRef(ref: string, all?: false): T; + public getRef(ref: string, all = false): T | T[] { + const selector = `[data-ref="${ref}"]`; + let result: T | T[]; + if (all) { + result = Array.from(this.querySelectorAll(selector)) as T[]; + if (result.length === 0) result = []; + } else { + result = this.querySelector(selector) as T; + if (!result) { + const fragment = document.createDocumentFragment(); + result = fragment as unknown as T; + } + } + return result; + } + + public createReactiveField(propertyName: string, initialValue: T): void { + const reactiveField: ReactiveField = { + name: propertyName, + value: initialValue, + initialValue: initialValue, + }; + + this.registerReactiveField(reactiveField); + + Object.defineProperty(this, propertyName, { + get(this: RadiantElement) { + return this.reactiveFields.get(propertyName)?.value ?? undefined; + }, + set(this: RadiantElement, newValue: T) { + const oldValue = this.reactiveFields.get(propertyName)?.value; + if (oldValue !== newValue) { + this.reactiveFields.set(propertyName, { ...reactiveField, value: newValue }); + this.notifyUpdate(propertyName, oldValue, newValue); + } + }, + enumerable: true, + configurable: true, + }); + + this.notifyUpdate(propertyName, undefined, initialValue); + } + + public createReactiveProp(propertyName: string, options: ReactivePropertyOptions): void { + const { type, attribute, reflect, defaultValue } = options; + const attributeKey = attribute ?? propertyName; + + if (defaultValue !== undefined && !isValueOfType(type, defaultValue)) { + throw new Error(`defaultValue does not match the expected type for ${type.name}`); + } + + const initialValue: T | undefined = getInitialValue(this, type, attributeKey, defaultValue) as T; + + const propertyMapping: ReactiveProperty = { + type, + name: propertyName, + value: initialValue, + initialValue, + attribute: attributeKey, + converter: { + fromAttribute: (value) => readAttributeValue(value, type), + toAttribute: (value) => writeAttributeValue(value, type), + }, + }; + + this.registerReactiveProperty(propertyMapping); + + const handleReflectRequest = (value: T) => { + if (reflect) { + const attributeValue = propertyMapping.converter.toAttribute(value); + this.setAttribute(attributeKey, attributeValue); + } + }; + + Object.defineProperty(this, propertyName, { + get: function (this: RadiantElement) { + return this.reactiveProperties.get(propertyName)?.value ?? undefined; + }, + set: function (this: RadiantElement, newValue: T) { + const oldValue = this.reactiveProperties.get(propertyName)?.value; + if (oldValue !== newValue) { + this.reactiveProperties.set(propertyName, { ...propertyMapping, value: newValue }); + handleReflectRequest(newValue); + this.notifyUpdate(propertyName, oldValue, newValue); + } + }, + enumerable: true, + configurable: true, + }); + + if (initialValue !== undefined) { + handleReflectRequest(initialValue as T); + queueMicrotask(() => { + this.notifyUpdate(propertyName, undefined, initialValue); + }); + } + } } diff --git a/packages/radiant/src/decorators.ts b/packages/radiant/src/decorators.ts deleted file mode 100644 index 5958d02..0000000 --- a/packages/radiant/src/decorators.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from './decorators/custom-element'; -export * from './decorators/event'; -export * from './decorators/on-event'; -export * from './decorators/on-updated'; -export * from './decorators/query'; -export * from './decorators/reactive-prop'; -export * from './decorators/reactive-field'; diff --git a/packages/radiant/src/decorators/bound.ts b/packages/radiant/src/decorators/bound.ts index 9a459bf..563daed 100644 --- a/packages/radiant/src/decorators/bound.ts +++ b/packages/radiant/src/decorators/bound.ts @@ -1,35 +1,28 @@ -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyMethodDecoratorArgs, + StandardMethodDecoratorArgs, + StandardOrLegacyMethodDecoratorArgs, +} from '../types'; +import { bound as legacyBound } from './legacy/bound'; +import { bound as standardBound } from './standard/bound'; /** * 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; - }, - }; +export function bound( + protoOrTarget: StandardOrLegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyMethodDecoratorArgs['nameOrContext'], + descriptor?: StandardOrLegacyMethodDecoratorArgs['descriptor'], +): any { + if (typeof nameOrContext === 'object') { + return standardBound( + protoOrTarget as StandardMethodDecoratorArgs['protoOrTarget'], + nameOrContext as StandardMethodDecoratorArgs['nameOrContext'], + ); + } + return legacyBound( + protoOrTarget as LegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyMethodDecoratorArgs['nameOrContext'], + descriptor as LegacyMethodDecoratorArgs['descriptor'], + ); } diff --git a/packages/radiant/src/decorators/custom-element.ts b/packages/radiant/src/decorators/custom-element.ts index 6ba3e94..fa6d6d0 100644 --- a/packages/radiant/src/decorators/custom-element.ts +++ b/packages/radiant/src/decorators/custom-element.ts @@ -1,12 +1,27 @@ +import type { + LegacyClassDecoratorArgs, + StandardClassDecoratorArgs, + StandardOrLegacyClassDecoratorArgs, +} from '../types'; +import { customElement as legacyCustomElement } from './legacy/custom-element'; +import { customElement as standardCustomElement } from './standard/custom-element'; + /** * Registers a web component with the given name on the global `window.customElements` registry. * @param name selector name. + * @param options {@link ElementDefinitionOptions} */ -export function customElement(name: string) { - return (target: CustomElementConstructor) => { - if (!globalThis.window) return; - if (!window.customElements.get(name)) { - window.customElements.define(name, target); +export function customElement(name: string, options?: ElementDefinitionOptions) { + return function ( + protoOrTarget: StandardOrLegacyClassDecoratorArgs['protoOrTarget'], + nameOrContext?: StandardOrLegacyClassDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext !== 'undefined') { + return standardCustomElement(name, options)( + protoOrTarget as StandardClassDecoratorArgs['protoOrTarget'], + nameOrContext as StandardClassDecoratorArgs['nameOrContext'], + ); } + return legacyCustomElement(name, options)(protoOrTarget as LegacyClassDecoratorArgs['protoOrTarget']); }; } diff --git a/packages/radiant/src/decorators/debounce.ts b/packages/radiant/src/decorators/debounce.ts index 54a3bba..8458626 100644 --- a/packages/radiant/src/decorators/debounce.ts +++ b/packages/radiant/src/decorators/debounce.ts @@ -1,23 +1,31 @@ -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyMethodDecoratorArgs, + StandardMethodDecoratorArgs, + StandardOrLegacyMethodDecoratorArgs, +} from '../types'; +import { debounce as legacyDebounce } from './legacy/debounce'; +import { debounce as standardDebounce } from './standard/debounce'; -export function debounce( - timeout: number, -): (target: any, propertyKey: string, descriptor: PropertyDescriptor) => PropertyDescriptor { - let timeoutRef: ReturnType | null = null; - - return (_target: RadiantElement, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => { - const originalMethod = descriptor.value; - - descriptor.value = function debounce(...args: any[]) { - if (timeoutRef !== null) { - clearTimeout(timeoutRef); - } - - timeoutRef = setTimeout(() => { - originalMethod.apply(this, args); - }, timeout); - }; - - return descriptor; +/** + * A decorator to debounce a method. + * @param timeout The debounce timeout in milliseconds. + */ +export function debounce(timeout: number) { + return function ( + protoOrTarget: StandardOrLegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyMethodDecoratorArgs['nameOrContext'], + descriptor?: StandardOrLegacyMethodDecoratorArgs['descriptor'], + ): any { + if (typeof nameOrContext === 'object') { + return standardDebounce(timeout)( + protoOrTarget as StandardMethodDecoratorArgs['protoOrTarget'], + nameOrContext as StandardMethodDecoratorArgs['nameOrContext'], + ); + } + return legacyDebounce(timeout)( + protoOrTarget as LegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyMethodDecoratorArgs['nameOrContext'], + descriptor as LegacyMethodDecoratorArgs['descriptor'], + ); }; } diff --git a/packages/radiant/src/decorators/event.ts b/packages/radiant/src/decorators/event.ts index b19725c..4971c35 100644 --- a/packages/radiant/src/decorators/event.ts +++ b/packages/radiant/src/decorators/event.ts @@ -1,35 +1,33 @@ -import { EventEmitter, type EventEmitterConfig } from '@/tools/event-emitter'; - -const eventEmitters = new WeakMap(); +import type { RadiantElement } from '../core/radiant-element'; +import { EventEmitter, type EventEmitterConfig } from '../tools/event-emitter'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../types'; +import { event as legacyEvent } from './legacy/event'; +import { event as standardEvent } from './standard/event'; /** * Decorator that attaches an EventEmitter to the class field property. * The EventEmitter can be used to dispatch custom events from the target element. - * @param eventConfig Configuration for the event emitter. + * @param eventConfig {@link EventEmitterConfig} Configuration for the event emitter. * @see {@link EventEmitter} for more details about how the EventEmitter works. */ export function event(eventConfig: EventEmitterConfig) { - return (target: any, propertyKey: string) => { - if (!propertyKey) { - throw new Error('The propertyKey is missing for the event decorator.'); - } - - if (!eventConfig || !eventConfig.name) { - throw new Error('Invalid eventConfig provided.'); + return function ( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext === 'object') { + return standardEvent(eventConfig)( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], + ); } - - const uniqueKey = Symbol(eventConfig.name); - - Object.defineProperty(target, propertyKey, { - get() { - const emittersMap: Map = eventEmitters.get(this) || new Map(); - if (!emittersMap.has(uniqueKey)) { - emittersMap.set(uniqueKey, new EventEmitter(this, eventConfig)); - eventEmitters.set(this, emittersMap); - } - - return emittersMap.get(uniqueKey); - }, - }); + return legacyEvent(eventConfig)( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); }; } diff --git a/packages/radiant/src/decorators/legacy/bound.ts b/packages/radiant/src/decorators/legacy/bound.ts new file mode 100644 index 0000000..85107a6 --- /dev/null +++ b/packages/radiant/src/decorators/legacy/bound.ts @@ -0,0 +1,29 @@ +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() { + if (this === (target as any).prototype || Object.hasOwn(this, propertyKey)) { + return originalMethod; + } + + const boundMethod = originalMethod.bind(this); + Object.defineProperty(this, propertyKey, { + value: boundMethod, + configurable: true, + writable: true, + }); + return boundMethod; + }, + }; +} diff --git a/packages/radiant/src/decorators/legacy/custom-element.ts b/packages/radiant/src/decorators/legacy/custom-element.ts new file mode 100644 index 0000000..e7fef3c --- /dev/null +++ b/packages/radiant/src/decorators/legacy/custom-element.ts @@ -0,0 +1,12 @@ +/** + * Registers a web component with the given name on the global `window.customElements` registry. + * @param name selector name. + * @param options {@link ElementDefinitionOptions} + */ +export function customElement(name: string, options?: ElementDefinitionOptions) { + return (target: CustomElementConstructor) => { + if (!window.customElements.get(name)) { + window.customElements.define(name, target, options); + } + }; +} diff --git a/packages/radiant/src/decorators/legacy/debounce.ts b/packages/radiant/src/decorators/legacy/debounce.ts new file mode 100644 index 0000000..1a71e0f --- /dev/null +++ b/packages/radiant/src/decorators/legacy/debounce.ts @@ -0,0 +1,23 @@ +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: RadiantElement, _propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor => { + const originalMethod = descriptor.value; + + descriptor.value = function debounce(...args: any[]) { + if (timeoutRef !== null) { + clearTimeout(timeoutRef); + } + + timeoutRef = setTimeout(() => { + originalMethod.apply(this, args); + }, timeout); + }; + + return descriptor; + }; +} diff --git a/packages/radiant/src/decorators/legacy/event.ts b/packages/radiant/src/decorators/legacy/event.ts new file mode 100644 index 0000000..e1f9800 --- /dev/null +++ b/packages/radiant/src/decorators/legacy/event.ts @@ -0,0 +1,28 @@ +import type { RadiantElement } from '../../core/radiant-element'; +import { EventEmitter, type EventEmitterConfig } from '../../tools/event-emitter'; + +const eventEmitters = new WeakMap(); + +/** + * Decorator that attaches an EventEmitter to the class field property. + * The EventEmitter can be used to dispatch custom events from the target element. + * @param eventConfig Configuration for the event emitter. + * @see {@link EventEmitter} for more details about how the EventEmitter works. + */ +export function event(eventConfig: EventEmitterConfig) { + return (proto: RadiantElement, propertyKey: string) => { + const originalConnectedCallback = proto.connectedCallback; + proto.connectedCallback = function () { + this.registerEventEmitter(eventConfig.name, new EventEmitter(this, eventConfig)); + + Object.defineProperty(this, propertyKey, { + get() { + return this.eventEmitters.get(eventConfig.name); + }, + enumerable: true, + configurable: true, + }); + originalConnectedCallback.call(this); + }; + }; +} diff --git a/packages/radiant/src/decorators/legacy/on-event.ts b/packages/radiant/src/decorators/legacy/on-event.ts new file mode 100644 index 0000000..dd10118 --- /dev/null +++ b/packages/radiant/src/decorators/legacy/on-event.ts @@ -0,0 +1,83 @@ +import type { RadiantElement, RadiantElementEventListener } from '../../core/radiant-element'; + +type OnEventConfig = Pick & + ( + | { + selector: string; + } + | { + ref: string; + } + | { + window: boolean; + } + | { + document: boolean; + } + ); + +/** + * A decorator to subscribe to an event on the target element. + * The event listener will be automatically unsubscribed when the element is disconnected. + * + * Note: This decorator uses event delegation, which means it relies on event bubbling. + * Therefore, it will not work with events that do not bubble, such as `focus`, `blur`, `load`, `unload`, `scroll`, etc. + * For focus and blur events, consider using `focusin` and `focusout` which are similar but do bubble. + * + * @param eventConfig The event configuration. + * @param eventConfig.selectors The CSS selector(s) of the target element(s). + * @param eventConfig.ref The data-ref attribute of the target element. + * @param eventConfig.type The type of the event to listen for. + * @param eventConfig.options Optional. An options object that specifies characteristics about the event listener. + */ +export function onEvent(eventConfig: OnEventConfig) { + return (proto: RadiantElement, _: string, descriptor: PropertyDescriptor) => { + const originalConnectedCallback = proto.connectedCallback; + const originalDisconnectedCallback = proto.disconnectedCallback; + + if ('window' in eventConfig) { + proto.connectedCallback = function (this: RadiantElement) { + window.addEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); + originalConnectedCallback.call(this); + }; + + proto.disconnectedCallback = function (this: RadiantElement) { + window.removeEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); + originalDisconnectedCallback.call(this); + }; + + return descriptor; + } + + if ('document' in eventConfig) { + proto.connectedCallback = function (this: RadiantElement) { + document.addEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); + originalConnectedCallback.call(this); + }; + + proto.disconnectedCallback = function (this: RadiantElement) { + document.removeEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); + originalDisconnectedCallback.call(this); + }; + + return descriptor; + } + + const selector = 'selector' in eventConfig ? eventConfig.selector : `[data-ref="${eventConfig.ref}"]`; + + const originalMethod = descriptor.value; + + proto.connectedCallback = function (this: RadiantElement) { + this.subscribeEvent({ + selector: selector, + type: eventConfig.type, + listener: originalMethod.bind(this), + options: eventConfig?.options ?? undefined, + }); + + originalConnectedCallback.call(this); + }; + + return descriptor; + }; +} diff --git a/packages/radiant/src/decorators/legacy/on-updated.ts b/packages/radiant/src/decorators/legacy/on-updated.ts new file mode 100644 index 0000000..b040ed4 --- /dev/null +++ b/packages/radiant/src/decorators/legacy/on-updated.ts @@ -0,0 +1,23 @@ +import type { RadiantElement } from '../../core/radiant-element'; + +/** + * A decorator to subscribe to an updated callback when a reactive field or property changes. + * @param eventConfig The event configuration. + */ +export function onUpdated(keyOrKeys: string | string[]) { + return (target: RadiantElement, methodName: string) => { + const originalConnectedCallback = target.connectedCallback; + + target.connectedCallback = function (this: RadiantElement) { + const boundedMethod = (this as any)[methodName].bind(this); + if (Array.isArray(keyOrKeys)) { + for (const key of keyOrKeys) { + (this as RadiantElement).registerUpdateCallback(key, boundedMethod); + } + } else if (typeof keyOrKeys === 'string') { + (this as RadiantElement).registerUpdateCallback(keyOrKeys, boundedMethod); + } + originalConnectedCallback.call(this); + }; + }; +} diff --git a/packages/radiant/src/decorators/legacy/query.ts b/packages/radiant/src/decorators/legacy/query.ts new file mode 100644 index 0000000..e5cdcbd --- /dev/null +++ b/packages/radiant/src/decorators/legacy/query.ts @@ -0,0 +1,63 @@ +import type { RadiantElement } from '../../core/radiant-element'; +import type { QueryConfig } from '../query'; + +/** + * A decorator to query by CSS selector or data-ref attribute. + * By default it queries for the first element that matches the selector, but it can be configured to query for all elements. + * + * @param {QueryConfig} options - The configuration object for the query. + * @param {boolean} [options.all] - A flag to query for all elements that match the selector. Defaults to `false`. + * @param {boolean} [options.cache] - A flag to cache the query result. Defaults to `true`. + * @param {string} [options.selector] - A CSS selector to match elements against. This property is mutually exclusive with `options.ref`. + * @param {string} [options.ref] - A reference to an element. This property is mutually exclusive with `options.selector`. + * + * @returns {Function} A decorator function that, when applied to a class property, will replace it with a getter. The getter will return the result of the query when accessed. + * + * @example + * class MyElement extends HTMLElement { + * @query({ selector: '.my-class' }) + * myElement; + * } + * + * // Now, `myElement` will return the first element in the light DOM of `MyElement` that matches the selector '.my-class'. + */ +export function query({ + cache: shouldBeCached = true, + ...options +}: QueryConfig): (proto: RadiantElement, propertyName: string | symbol) => void { + return (proto: RadiantElement, propertyKey: string | symbol) => { + const privatePropertyKey = Symbol(`__${String(propertyKey)}__cache`); + + const selector = 'selector' in options ? options.selector : `[data-ref="${options.ref}"]`; + + const executeQuery = (instance: RadiantElement) => { + let result: T | T[] = []; + if (options?.all) { + const queried = instance.querySelectorAll(selector); + result = queried.length ? (Array.from(queried) as T) : []; + return result; + } + + return instance.querySelector(selector); + }; + + const originalConnectedCallback = proto.connectedCallback; + + proto.connectedCallback = function (this: RadiantElement) { + Object.defineProperty(this, propertyKey, { + get() { + if (shouldBeCached) { + if (!this[privatePropertyKey] || (options?.all && !this[privatePropertyKey].length)) { + this[privatePropertyKey] = executeQuery(this); + } + return this[privatePropertyKey]; + } + return executeQuery(this) as T; + }, + enumerable: true, + configurable: true, + }); + originalConnectedCallback.call(this); + }; + }; +} diff --git a/packages/radiant/src/decorators/legacy/reactive-field.ts b/packages/radiant/src/decorators/legacy/reactive-field.ts new file mode 100644 index 0000000..ffad49d --- /dev/null +++ b/packages/radiant/src/decorators/legacy/reactive-field.ts @@ -0,0 +1,18 @@ +import type { RadiantElement } from '../../core/radiant-element'; + +/** + * A decorator to define a reactive field. + * Every time the property changes, the `updated` method will be called. + * Due the fact the value is always undefined before the first update, + * we are adding a `isDefined` WeakSet to track if the property has been defined. + * @param target The target element. + * @param propertyKey The property key. + */ +export function reactiveField(target: RadiantElement, propertyKey: string) { + const originalConnectedCallback = target.connectedCallback; + + target.connectedCallback = function (this: RadiantElement) { + this.createReactiveField(propertyKey, this[propertyKey as keyof typeof this]); + originalConnectedCallback.call(this); + }; +} diff --git a/packages/radiant/src/decorators/legacy/reactive-prop.ts b/packages/radiant/src/decorators/legacy/reactive-prop.ts new file mode 100644 index 0000000..65bfbc5 --- /dev/null +++ b/packages/radiant/src/decorators/legacy/reactive-prop.ts @@ -0,0 +1,40 @@ +import type { RadiantElement } from '../../core/radiant-element'; +import { type AttributeTypeConstant, isValueOfType } from '../../utils/attribute-utils'; + +type ReactivePropertyOptions = { + type: AttributeTypeConstant; + reflect?: boolean; + attribute?: string; + defaultValue?: T; +}; + +/** + * A decorator to define a reactive property. + * Every time the property changes, the `updated` method will be called. + * @param options The options for the reactive property. + * @param options.type The type of the property value. + * @param options.reflect Whether to reflect the property to the attribute. + * @param options.attribute The name of the attribute. + * @param options.defaultValue The default value of the property. + */ +export function reactiveProp({ type, attribute, reflect, defaultValue }: ReactivePropertyOptions) { + if (defaultValue !== undefined && !isValueOfType(type, defaultValue)) { + throw new Error(`defaultValue does not match the expected type for ${type.name}`); + } + + return (target: RadiantElement, propertyName: string) => { + const attributeKey = attribute ?? propertyName; + + const originalConnectedCallback = target.connectedCallback; + + target.connectedCallback = function (this: RadiantElement) { + originalConnectedCallback.call(this); + this.createReactiveProp(propertyName, { + type, + reflect, + attribute: attributeKey, + defaultValue, + }); + }; + }; +} diff --git a/packages/radiant/src/decorators/on-event.ts b/packages/radiant/src/decorators/on-event.ts index 368bb1e..39f1e89 100644 --- a/packages/radiant/src/decorators/on-event.ts +++ b/packages/radiant/src/decorators/on-event.ts @@ -1,4 +1,11 @@ -import type { RadiantElement, RadiantElementEventListener } from '@/core/radiant-element'; +import type { RadiantElementEventListener } from '../core/radiant-element'; +import type { + LegacyMethodDecoratorArgs, + StandardMethodDecoratorArgs, + StandardOrLegacyMethodDecoratorArgs, +} from '../types'; +import { onEvent as legacyOnEvent } from './legacy/on-event'; +import { onEvent as standardOnEvent } from './standard/on-event'; type OnEventConfig = Pick & ( @@ -24,62 +31,24 @@ type OnEventConfig = Pick & * Therefore, it will not work with events that do not bubble, such as `focus`, `blur`, `load`, `unload`, `scroll`, etc. * For focus and blur events, consider using `focusin` and `focusout` which are similar but do bubble. * - * @param eventConfig The event configuration. - * @param eventConfig.selectors The CSS selector(s) of the target element(s). - * @param eventConfig.ref The data-ref attribute of the target element. - * @param eventConfig.type The type of the event to listen for. - * @param eventConfig.options Optional. An options object that specifies characteristics about the event listener. + * @param options {@link OnEventConfig} The event configuration. */ -export function onEvent(eventConfig: OnEventConfig) { - return (proto: RadiantElement, _: string, descriptor: PropertyDescriptor) => { - const originalConnectedCallback = proto.connectedCallback; - const originalDisconnectedCallback = proto.disconnectedCallback; - - if ('window' in eventConfig) { - proto.connectedCallback = function (this: RadiantElement) { - window.addEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); - originalConnectedCallback.call(this); - }; - - proto.disconnectedCallback = function (this: RadiantElement) { - window.removeEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); - originalDisconnectedCallback.call(this); - }; - - return descriptor; - } - - if ('document' in eventConfig) { - proto.connectedCallback = function (this: RadiantElement) { - document.addEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); - originalConnectedCallback.call(this); - }; - - proto.disconnectedCallback = function (this: RadiantElement) { - document.removeEventListener(eventConfig.type, descriptor.value.bind(this), eventConfig.options); - originalDisconnectedCallback.call(this); - }; - - return descriptor; +export function onEvent(options: OnEventConfig) { + return function ( + protoOrTarget: StandardOrLegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyMethodDecoratorArgs['nameOrContext'], + descriptor?: StandardOrLegacyMethodDecoratorArgs['descriptor'], + ): any { + if (typeof nameOrContext === 'object') { + return standardOnEvent(options)( + protoOrTarget as StandardMethodDecoratorArgs['protoOrTarget'], + nameOrContext as StandardMethodDecoratorArgs['nameOrContext'], + ); } - - const selector = 'selector' in eventConfig ? eventConfig.selector : `[data-ref="${eventConfig.ref}"]`; - - const originalMethod = descriptor.value; - const subscriptionId = `${eventConfig.type}-${selector}`; - - proto.connectedCallback = function (this: RadiantElement) { - this.subscribeEvent({ - id: subscriptionId, - selector: selector, - type: eventConfig.type, - listener: originalMethod.bind(this), - options: eventConfig?.options ?? undefined, - }); - - originalConnectedCallback.call(this); - }; - - return descriptor; + return legacyOnEvent(options)( + protoOrTarget as LegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyMethodDecoratorArgs['nameOrContext'], + descriptor as LegacyMethodDecoratorArgs['descriptor'], + ); }; } diff --git a/packages/radiant/src/decorators/on-updated.ts b/packages/radiant/src/decorators/on-updated.ts index 1e27567..de9839a 100644 --- a/packages/radiant/src/decorators/on-updated.ts +++ b/packages/radiant/src/decorators/on-updated.ts @@ -1,32 +1,29 @@ -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyMethodDecoratorArgs, + StandardMethodDecoratorArgs, + StandardOrLegacyMethodDecoratorArgs, +} from '../types'; +import { onUpdated as legacyOnUpdated } from './legacy/on-updated'; +import { onUpdated as standardOnUpdated } from './standard/on-updated'; /** - * A decorator to subscribe to an updated callback when a reactive field or property changes. - * @param eventConfig The event configuration. + * A decorator to bind a method to the instance. */ export function onUpdated(keyOrKeys: string | string[]) { - return (proto: RadiantElement, methodName: string) => { - if (!('updatesRegistry' in proto)) { - Object.defineProperty(proto, 'updatesRegistry', { - value: new Map>(), - configurable: true, - }); - } - - const updatesRegistry = proto.updatesRegistry; - - if (Array.isArray(keyOrKeys)) { - for (const key of keyOrKeys) { - if (!updatesRegistry.has(key)) { - updatesRegistry.set(key, new Set()); - } - updatesRegistry.get(key)?.add(methodName); - } - } else if (typeof keyOrKeys === 'string') { - if (!updatesRegistry.has(keyOrKeys)) { - updatesRegistry.set(keyOrKeys, new Set()); - } - updatesRegistry.get(keyOrKeys)?.add(methodName); + return function ( + protoOrTarget: StandardOrLegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyMethodDecoratorArgs['nameOrContext'], + descriptor?: StandardOrLegacyMethodDecoratorArgs['descriptor'], + ): any { + if (typeof nameOrContext === 'object') { + return standardOnUpdated(keyOrKeys)( + protoOrTarget as StandardMethodDecoratorArgs['protoOrTarget'], + nameOrContext as StandardMethodDecoratorArgs['nameOrContext'], + ); } + return legacyOnUpdated(keyOrKeys)( + protoOrTarget as LegacyMethodDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyMethodDecoratorArgs['nameOrContext'], + ); }; } diff --git a/packages/radiant/src/decorators/query.ts b/packages/radiant/src/decorators/query.ts index 8552cc5..f97df9a 100644 --- a/packages/radiant/src/decorators/query.ts +++ b/packages/radiant/src/decorators/query.ts @@ -1,82 +1,42 @@ -import type { RadiantElement } from '@/core'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../types'; +import { query as legacyQuery } from './legacy/query'; +import { query as standardQuery } from './standard/query'; -/** - * The base configuration object for the query. - */ type BaseQueryConfig = { all?: boolean; cache?: boolean; }; -/** - * The configuration object for the query. - * It can be configured to query by CSS selector or data-ref attribute. - */ -export type QueryConfig = BaseQueryConfig & - ( - | { - selector: string; - } - | { - ref: string; - } - ); +type QueryBySelector = { selector: string }; + +type QueryByRef = { ref: string }; + +export type QueryConfig = BaseQueryConfig & (QueryBySelector | QueryByRef); /** * A decorator to query by CSS selector or data-ref attribute. * By default it queries for the first element that matches the selector, but it can be configured to query for all elements. - * - * @param {QueryConfig} options - The configuration object for the query. - * @param {boolean} [options.all] - A flag to query for all elements that match the selector. Defaults to `false`. - * @param {boolean} [options.cache] - A flag to cache the query result. Defaults to `true`. - * @param {string} [options.selector] - A CSS selector to match elements against. This property is mutually exclusive with `options.ref`. - * @param {string} [options.ref] - A reference to an element. This property is mutually exclusive with `options.selector`. - * - * @returns {Function} A decorator function that, when applied to a class property, will replace it with a getter. The getter will return the result of the query when accessed. - * - * @example - * class MyElement extends HTMLElement { - * @query({ selector: '.my-class' }) - * myElement; - * } - * - * // Now, `myElement` will return the first element in the light DOM of `MyElement` that matches the selector '.my-class'. + * It cache the result by default, but it can be configured to not cache it. + * @param options {@link QueryConfig} The options for the reactive property. */ -export function query({ - cache: shouldBeCached = true, - ...options -}: QueryConfig): (proto: RadiantElement, propertyKey: string | symbol) => void { - const cache = new WeakMap(); - - return (proto: RadiantElement, propertyKey: string | symbol) => { - const doQuery = function (this: Element) { - if (shouldBeCached) { - const cachedResult = cache.get(this); - if (cachedResult !== undefined) { - return cachedResult; - } - } - - const selector = 'selector' in options ? options.selector : `[data-ref="${options.ref}"]`; - const queryResult = options.all ? this.querySelectorAll(selector) : this.querySelector(selector); - - if (shouldBeCached) { - cache.set(this, queryResult); - } - - return queryResult; - }; - - const originalConnectedCallback = proto.connectedCallback; - - proto.connectedCallback = function (this: RadiantElement) { - Object.defineProperty(this, propertyKey, { - get: doQuery, - enumerable: true, - configurable: true, - }); - - originalConnectedCallback.call(this); - }; +export function query(options: QueryConfig) { + return function ( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext === 'object') { + return standardQuery(options)( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], + ); + } + return legacyQuery(options)( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); }; } diff --git a/packages/radiant/src/decorators/reactive-field.ts b/packages/radiant/src/decorators/reactive-field.ts index a2aef51..79346d2 100644 --- a/packages/radiant/src/decorators/reactive-field.ts +++ b/packages/radiant/src/decorators/reactive-field.ts @@ -1,32 +1,27 @@ -import type { RadiantElement } from '@/core/radiant-element'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../types'; +import { reactiveField as legacyReactiveField } from './legacy/reactive-field'; +import { reactiveField as standardReactiveField } from './standard/reactive-field'; /** * A decorator to define a reactive field. - * Every time the property changes, the `updated` method will be called. - * Due the fact the value is always undefined before the first update, - * we are adding a `isDefined` WeakSet to track if the property has been defined. - * @param target The target element. - * @param propertyKey The property key. + * Every time the field changes, the `notifyUpdate` method will be called. */ -export function reactiveField(proto: RadiantElement, propertyKey: string) { - const originalValues = new WeakMap(); - const isDefined = new WeakSet(); - - Object.defineProperty(proto, propertyKey, { - get: function () { - return originalValues.get(this); - }, - set: function (newValue: unknown) { - if (isDefined.has(this)) { - const oldValue = originalValues.get(this); - if (oldValue !== newValue) { - originalValues.set(this, newValue); - this.updated(propertyKey, oldValue, newValue); - } - } else { - originalValues.set(this, newValue); - isDefined.add(this); - } - }, - }); +export function reactiveField( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], +): any { + if (typeof nameOrContext === 'object') { + return standardReactiveField( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], + ); + } + return legacyReactiveField( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); } diff --git a/packages/radiant/src/decorators/reactive-prop.ts b/packages/radiant/src/decorators/reactive-prop.ts index 6603d0f..2a7c7d5 100644 --- a/packages/radiant/src/decorators/reactive-prop.ts +++ b/packages/radiant/src/decorators/reactive-prop.ts @@ -1,124 +1,31 @@ -import type { PropertyConfig, RadiantElement } from '@/core/radiant-element'; -import { - type AttributeTypeConstant, - defaultValueForType, - isValueOfType, - readAttributeValue, - writeAttributeValue, -} from '@/utils/attribute-utils'; - -type ReactivePropertyOptions = { - type: AttributeTypeConstant; - reflect?: boolean; - attribute?: string; - defaultValue?: T; -}; +import type { ReactivePropertyOptions } from '../core/radiant-element'; +import type { + LegacyFieldDecoratorArgs, + StandardFieldDecoratorArgs, + StandardOrLegacyFieldDecoratorArgs, +} from '../types'; +import { reactiveProp as legacyReactiveProp } from './legacy/reactive-prop'; +import { reactiveProp as standardReactiveProp } from './standard/reactive-prop'; /** * A decorator to define a reactive property. * Every time the property changes, the `updated` method will be called. - * @param options The options for the reactive property. - * @param options.type The type of the property value. - * @param options.reflect Whether to reflect the property to the attribute. - * @param options.attribute The name of the attribute. - * @param options.defaultValue The default value of the property. + * @param options {@link ReactivePropertyOptions} The options for the reactive property. */ -export function reactiveProp({ type, attribute, reflect, defaultValue }: ReactivePropertyOptions) { - if (defaultValue !== undefined && !isValueOfType(type, defaultValue)) { - throw new Error(`defaultValue does not match the expected type for ${type.name}`); - } - - return (target: RadiantElement, propertyName: string) => { - const originalValues = new WeakMap(); - const attributeKey = attribute ?? propertyName; - - if (propertyName in target) { - throw new Error(`Property "${propertyName}" already exists on ${target.constructor.name}`); +export function reactiveProp(options: ReactivePropertyOptions) { + return function ( + protoOrTarget: StandardOrLegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext: StandardOrLegacyFieldDecoratorArgs['nameOrContext'], + ): any { + if (typeof nameOrContext === 'object') { + return standardReactiveProp(options)( + protoOrTarget as StandardFieldDecoratorArgs['protoOrTarget'], + nameOrContext as StandardFieldDecoratorArgs['nameOrContext'], + ); } - - const propertyMapping: PropertyConfig = { - type, - propertyName, - attributeKey, - converter: { - fromAttribute: (value) => readAttributeValue(value, type), - toAttribute: (value) => writeAttributeValue(value, type), - }, - }; - - addPropertyToMappings(target, propertyMapping); - - Object.defineProperty(target, propertyName, { - get: function () { - if (!originalValues.has(this)) { - const initialValue = getInitialValue(this, type, attributeKey, defaultValue as T); - originalValues.set(this, initialValue); - } - return originalValues.get(this); - }, - set: function (newValue: T) { - const oldValue = originalValues.get(this); - if (oldValue === newValue) return; - originalValues.set(this, newValue); - if (reflect) { - const attributeValue = propertyMapping.converter.toAttribute(newValue); - this.setAttribute(attributeKey, attributeValue); - } - this.updated(propertyName, oldValue, newValue); - }, - enumerable: true, - configurable: true, - }); - - const originalConnectedCallback = target.connectedCallback; - - target.connectedCallback = function (this: RadiantElement) { - originalConnectedCallback.call(this); - this.updated(propertyName, null, defaultValue); - }; - - addObservedAttribute(target, attributeKey); + return legacyReactiveProp(options)( + protoOrTarget as LegacyFieldDecoratorArgs['protoOrTarget'], + nameOrContext as LegacyFieldDecoratorArgs['nameOrContext'], + ); }; } - -const getInitialValue = ( - target: RadiantElement, - type: AttributeTypeConstant, - attributeKey: string, - defaultValue: unknown, -) => { - if (type === Boolean) { - const hasAttribute = target.hasAttribute(attributeKey); - return hasAttribute || defaultValue; - } - - const attributeValue = target.getAttribute(attributeKey); - return attributeValue !== null - ? readAttributeValue(attributeValue, type) - : defaultValue || (defaultValueForType(type) as typeof defaultValue); -}; - -const addPropertyToMappings = (target: RadiantElement, propertyMapping: PropertyConfig) => { - if (!('propertyConfigMap' in target)) { - Object.defineProperty(target, 'propertyConfigMap', { - value: new Map(), - configurable: true, - }); - } - - target.propertyConfigMap.set(propertyMapping.propertyName, propertyMapping); -}; - -function addObservedAttribute(target: RadiantElement, attribute: string) { - const ctor = target.constructor as typeof RadiantElement; - const existingObservedAttributes = (ctor as any).observedAttributes || []; - if (!existingObservedAttributes.includes(attribute)) { - const newObservedAttributes = [...existingObservedAttributes, attribute]; - Object.defineProperty(ctor, 'observedAttributes', { - get() { - return newObservedAttributes; - }, - configurable: true, - }); - } -} diff --git a/packages/radiant/src/decorators/standard/bound.ts b/packages/radiant/src/decorators/standard/bound.ts new file mode 100644 index 0000000..1a3d44c --- /dev/null +++ b/packages/radiant/src/decorators/standard/bound.ts @@ -0,0 +1,11 @@ +import type { Method } from '../../types'; + +export function bound(_: T, context: ClassMethodDecoratorContext) { + const methodName = String(context.name); + if (context.private) { + throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`); + } + context.addInitializer(function (this: any) { + this[methodName] = this[methodName].bind(this); + }); +} diff --git a/packages/radiant/src/decorators/standard/custom-element.ts b/packages/radiant/src/decorators/standard/custom-element.ts new file mode 100644 index 0000000..5fc3f24 --- /dev/null +++ b/packages/radiant/src/decorators/standard/custom-element.ts @@ -0,0 +1,9 @@ +export function customElement(name: string, options?: ElementDefinitionOptions) { + return function (_: T, context: ClassDecoratorContext) { + context.addInitializer(function () { + if (!window.customElements.get(name)) { + customElements.define(name, this, options); + } + }); + }; +} diff --git a/packages/radiant/src/decorators/standard/debounce.ts b/packages/radiant/src/decorators/standard/debounce.ts new file mode 100644 index 0000000..53505bf --- /dev/null +++ b/packages/radiant/src/decorators/standard/debounce.ts @@ -0,0 +1,19 @@ +import type { Method } from '../../types'; + +export function debounce(timeout: number): Method { + let timeoutRef: ReturnType | null = null; + + return (originalMethod: T): Method => { + return function (this: any, ...args: any[]): T { + if (timeoutRef !== null) { + clearTimeout(timeoutRef); + } + + timeoutRef = setTimeout(() => { + originalMethod.apply(this, args); + }, timeout); + + return originalMethod; + }; + }; +} diff --git a/packages/radiant/src/decorators/standard/event.ts b/packages/radiant/src/decorators/standard/event.ts new file mode 100644 index 0000000..4d2d960 --- /dev/null +++ b/packages/radiant/src/decorators/standard/event.ts @@ -0,0 +1,24 @@ +import type { RadiantElement } from '../../core/radiant-element'; +import { EventEmitter, type EventEmitterConfig } from '../../tools/event-emitter'; + +/** + * Decorator that attaches an EventEmitter to the class field property. + * The EventEmitter can be used to dispatch custom events from the target element. + * @param eventConfig Configuration for the event emitter. + * @see {@link EventEmitter} for more details about how the EventEmitter works. + */ +export function event(eventConfig: EventEmitterConfig) { + return function (_: undefined, context: ClassFieldDecoratorContext) { + context.addInitializer(function (this: T) { + this.registerEventEmitter(eventConfig.name, new EventEmitter(this, eventConfig)); + + Object.defineProperty(this, context.name, { + get() { + return this.eventEmitters.get(eventConfig.name); + }, + enumerable: true, + configurable: true, + }); + }); + }; +} diff --git a/packages/radiant/src/decorators/standard/on-event.ts b/packages/radiant/src/decorators/standard/on-event.ts new file mode 100644 index 0000000..86798c0 --- /dev/null +++ b/packages/radiant/src/decorators/standard/on-event.ts @@ -0,0 +1,66 @@ +import type { RadiantElement, RadiantElementEventListener } from '../../core/radiant-element'; +import type { Method } from '../../types'; + +type OnEventConfig = Pick & + ( + | { + selector: string; + } + | { + ref: string; + } + | { + window: boolean; + } + | { + document: boolean; + } + ); + +/** + * A decorator to subscribe to an event on the target element. + * The event listener will be automatically unsubscribed when the element is disconnected. + * + * Note: This decorator uses event delegation, which means it relies on event bubbling. + * Therefore, it will not work with events that do not bubble, such as `focus`, `blur`, `load`, `unload`, `scroll`, etc. + * For focus and blur events, consider using `focusin` and `focusout` which are similar but do bubble. + * + * @param eventConfig The event configuration. + * @param eventConfig.selectors The CSS selector(s) of the target element(s). + * @param eventConfig.ref The data-ref attribute of the target element. + * @param eventConfig.type The type of the event to listen for. + * @param eventConfig.options Optional. An options object that specifies characteristics about the event listener. + */ +export function onEvent(eventConfig: OnEventConfig) { + return function (originalMethod: T, context: ClassMethodDecoratorContext): void { + context.addInitializer(function (this: any) { + const boundMethod = originalMethod.bind(this); + + if ('window' in eventConfig) { + window.addEventListener(eventConfig.type, boundMethod, eventConfig.options); + (this as RadiantElement).registerCleanupCallback(() => { + window.removeEventListener(eventConfig.type, boundMethod, eventConfig.options); + }); + } + + if ('document' in eventConfig) { + document.addEventListener(eventConfig.type, boundMethod, eventConfig.options); + (this as RadiantElement).registerCleanupCallback(() => { + document.removeEventListener(eventConfig.type, boundMethod, eventConfig.options); + }); + } + + const selector = + 'selector' in eventConfig ? eventConfig.selector : 'ref' in eventConfig && `[data-ref="${eventConfig.ref}"]`; + + if (selector) { + (this as RadiantElement).subscribeEvent({ + selector: selector, + type: eventConfig.type, + listener: boundMethod, + options: eventConfig?.options ?? undefined, + }); + } + }); + }; +} diff --git a/packages/radiant/src/decorators/standard/on-updated.ts b/packages/radiant/src/decorators/standard/on-updated.ts new file mode 100644 index 0000000..afb3336 --- /dev/null +++ b/packages/radiant/src/decorators/standard/on-updated.ts @@ -0,0 +1,17 @@ +import type { RadiantElement } from '../../core/radiant-element'; +import type { Method } from '../../types'; + +export function onUpdated(keyOrKeys: string | string[]) { + return function (_: T, context: ClassMethodDecoratorContext): void { + context.addInitializer(function (this: any) { + this[context.name] = this[context.name].bind(this); + if (Array.isArray(keyOrKeys)) { + for (const key of keyOrKeys) { + (this as RadiantElement).registerUpdateCallback(key, this[context.name]); + } + } else if (typeof keyOrKeys === 'string') { + (this as RadiantElement).registerUpdateCallback(keyOrKeys, this[context.name]); + } + }); + }; +} diff --git a/packages/radiant/src/decorators/standard/query.ts b/packages/radiant/src/decorators/standard/query.ts new file mode 100644 index 0000000..57aba14 --- /dev/null +++ b/packages/radiant/src/decorators/standard/query.ts @@ -0,0 +1,40 @@ +import type { QueryConfig } from '../query'; + +export function query(options: QueryConfig) { + return function ( + _: undefined, + context: ClassFieldDecoratorContext, + ) { + const propertyName = String(context.name); + const privatePropertyKey = Symbol(`__${String(propertyName)}__cache`); + + const selector = 'selector' in options ? options.selector : `[data-ref="${options.ref}"]`; + + const executeQuery = (instance: T) => { + let result: V | V[] = []; + if (options?.all) { + const queried = instance.querySelectorAll(selector); + result = queried.length ? (Array.from(queried) as V) : []; + return result; + } + + return instance.querySelector(selector); + }; + + context.addInitializer(function (this: T) { + Object.defineProperty(this, propertyName, { + get() { + if (options?.cache) { + if (!this[privatePropertyKey] || (options?.all && !this[privatePropertyKey].length)) { + this[privatePropertyKey] = executeQuery(this); + } + return this[privatePropertyKey]; + } + return executeQuery(this) as V; + }, + enumerable: true, + configurable: true, + }); + }); + }; +} diff --git a/packages/radiant/src/decorators/standard/reactive-field.ts b/packages/radiant/src/decorators/standard/reactive-field.ts new file mode 100644 index 0000000..b595852 --- /dev/null +++ b/packages/radiant/src/decorators/standard/reactive-field.ts @@ -0,0 +1,29 @@ +import type { RadiantElement } from '../../core/radiant-element'; + +export function reactiveField(_: undefined, context: ClassFieldDecoratorContext) { + const privatePropertyKey = Symbol(`__${String(context.name)}__value`); + + const contextName = String(context.name); + + context.addInitializer(function (this: T) { + Object.defineProperty(this, context.name, { + get() { + return this[privatePropertyKey]; + }, + set(newValue: unknown) { + const oldValue = this[privatePropertyKey]; + if (oldValue !== newValue) { + this[privatePropertyKey] = newValue; + (this as RadiantElement).notifyUpdate(contextName, oldValue, newValue); + } + }, + enumerable: true, + configurable: true, + }); + }); + + return function (this: T, value: V) { + (this as any)[privatePropertyKey] = value; + return value; + }; +} diff --git a/packages/radiant/src/decorators/standard/reactive-prop.ts b/packages/radiant/src/decorators/standard/reactive-prop.ts new file mode 100644 index 0000000..6314d9e --- /dev/null +++ b/packages/radiant/src/decorators/standard/reactive-prop.ts @@ -0,0 +1,12 @@ +import type { RadiantElement, ReactivePropertyOptions } from '../../core/radiant-element.js'; + +export function reactiveProp

({ type, attribute, reflect, defaultValue }: ReactivePropertyOptions

) { + return function (_: undefined, context: ClassFieldDecoratorContext) { + const propertyName = String(context.name); + const attributeKey = attribute ?? propertyName; + + context.addInitializer(function (this: T) { + this.createReactiveProp(propertyName, { type, reflect, attribute: attributeKey, defaultValue }); + }); + }; +} diff --git a/packages/radiant/src/index.ts b/packages/radiant/src/index.ts index ac2843f..26d88af 100644 --- a/packages/radiant/src/index.ts +++ b/packages/radiant/src/index.ts @@ -1,6 +1,16 @@ -export * from './core'; -export * from './decorators'; -export * from './context'; -export * from './mixins'; +export * from './core/radiant-element'; +export * from './decorators/custom-element'; +export * from './decorators/event'; +export * from './decorators/on-event'; +export * from './decorators/on-updated'; +export * from './decorators/query'; +export * from './decorators/reactive-prop'; +export * from './decorators/reactive-field'; +export * from './context/context-provider'; +export * from './context/create-context'; +export * from './context/decorators/consume-context'; +export * from './context/decorators/context-selector'; +export * from './context/decorators/provide-context'; +export * from './mixins/with-kita'; export * from './tools'; export * from './utils'; diff --git a/packages/radiant/src/mixins/with-kita.ts b/packages/radiant/src/mixins/with-kita.ts index caa285e..10d4b23 100644 --- a/packages/radiant/src/mixins/with-kita.ts +++ b/packages/radiant/src/mixins/with-kita.ts @@ -1,4 +1,4 @@ -import type { RadiantElement, RenderInsertPosition } from '@/core/radiant-element'; +import type { RadiantElement, RenderInsertPosition } from '../core/radiant-element'; type Constructor = new (...args: any[]) => T; diff --git a/packages/radiant/src/tools/event-emitter.ts b/packages/radiant/src/tools/event-emitter.ts index dd1f51f..285a5a8 100644 --- a/packages/radiant/src/tools/event-emitter.ts +++ b/packages/radiant/src/tools/event-emitter.ts @@ -1,4 +1,4 @@ -import type { RadiantElement } from '..'; +import type { RadiantElement } from '../core/radiant-element'; export interface EventEmitterConfig { name: string; diff --git a/packages/radiant/src/types.ts b/packages/radiant/src/types.ts new file mode 100644 index 0000000..6e5bb36 --- /dev/null +++ b/packages/radiant/src/types.ts @@ -0,0 +1,104 @@ +import type { RadiantElement } from './core/radiant-element'; + +export type Constructor = { new (...args: any[]): NonNullable }; + +export type ConstructorParams = ConstructorParameters; + +export type Context = { + kind: string; + name: string | symbol; + access: { + get?(): unknown; + set?(value: unknown): void; + has?(value: unknown): boolean; + }; + private?: boolean; + static?: boolean; + addInitializer?(initializer: () => void): void; +}; + +export type ClassDecorator = (target: T, context: Context) => T | void; + +export type Method = (...args: any[]) => any; + +/** + * @deprecated + */ +export type StanderOrLegacyClassDecorator = { + (target: Constructor): typeof target | void; + (target: CustomElementConstructor, context: ClassDecoratorContext): void; +}; + +export type LegacyClassDecoratorArgs = { + protoOrTarget: CustomElementConstructor; + nameOrContext: string; + descriptor: PropertyDescriptor; +}; + +export type StandardClassDecoratorArgs = { + protoOrTarget: CustomElementConstructor; + nameOrContext: ClassDecoratorContext; +}; + +export type StandardOrLegacyClassDecoratorArgs = LegacyClassDecoratorArgs | StandardClassDecoratorArgs; + +/** + * @deprecated + */ +export type StandardOrLegacMethodDecorator = { + (proto: Constructor): PropertyDescriptor; + (target: Method, context: ClassMethodDecoratorContext): void; +}; + +export type LegacyMethodDecoratorArgs = { + protoOrTarget: RadiantElement; + nameOrContext: string; + descriptor: PropertyDescriptor; +}; + +export type StandardMethodDecoratorArgs = { + protoOrTarget: Method; + nameOrContext: ClassMethodDecoratorContext; + descriptor: undefined; +}; + +export type StandardOrLegacyMethodDecoratorArgs = LegacyMethodDecoratorArgs | StandardMethodDecoratorArgs; + +/** + * @deprecated + */ +export type StandardOrLegacyClassFieldDecorator = { + (proto: RadiantElement, propertyKey: string, descriptor: PropertyDescriptor): PropertyDescriptor; + (target: undefined, context: ClassFieldDecoratorContext): void; +}; + +export type LegacyFieldDecoratorArgs = { + protoOrTarget: RadiantElement; + nameOrContext: string; + descriptor: PropertyDescriptor; +}; + +export type StandardFieldDecoratorArgs = { + protoOrTarget: undefined; + nameOrContext: ClassFieldDecoratorContext; +}; + +export type StandardOrLegacyFieldDecoratorArgs = LegacyFieldDecoratorArgs | StandardFieldDecoratorArgs; + +export type CustomElementClass = Omit; + +export const guards = { + isStandard: { + ClassDecorator: function ( + args: any, + ): args is [CustomElementConstructor, ClassDecoratorContext] { + return typeof args[1] !== 'undefined'; + }, + MethodDecorator: function (args: any): args is [Method, ClassMethodDecoratorContext] { + return typeof args[1] === 'object'; + }, + FieldDecorator: function (args: any): args is [undefined, ClassFieldDecoratorContext] { + return typeof args[1] === 'object'; + }, + }, +}; diff --git a/packages/radiant/src/utils/attribute-utils.ts b/packages/radiant/src/utils/attribute-utils.ts index e4dd8ee..74f93a4 100644 --- a/packages/radiant/src/utils/attribute-utils.ts +++ b/packages/radiant/src/utils/attribute-utils.ts @@ -1,3 +1,5 @@ +import type { RadiantElement } from '../core/radiant-element'; + export type AttributeTypeConstant = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String; export type AttributeTypeDefault = Array | boolean | number | Record | string; @@ -207,3 +209,20 @@ export function isValueOfType(type: AttributeTypeConstant, defaultValue: unknown return false; } } + +export const getInitialValue = ( + target: RadiantElement, + type: AttributeTypeConstant, + attributeKey: string, + defaultValue: unknown, +) => { + if (type === Boolean) { + const hasAttribute = target.hasAttribute(attributeKey); + return hasAttribute || defaultValue; + } + + const attributeValue = target.getAttribute(attributeKey); + return attributeValue !== null + ? readAttributeValue(attributeValue, type) + : defaultValue || (defaultValueForType(type) as typeof defaultValue); +}; diff --git a/packages/radiant/test/context/context.test.tsx b/packages/radiant/test/context/context.test.tsx index f3db343..d749d71 100644 --- a/packages/radiant/test/context/context.test.tsx +++ b/packages/radiant/test/context/context.test.tsx @@ -1,13 +1,13 @@ -import { describe, expect, test } from 'bun:test'; -import { - type ContextProvider, - RadiantElement, - consumeContext, - contextSelector, - createContext, - customElement, - provideContext, -} from '@/index'; +import { waitFor } from '@testing-library/dom'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import type { ContextProvider } from '../../src/context/context-provider'; +import { createContext } from '../../src/context/create-context'; +import { consumeContext } from '../../src/context/decorators/consume-context'; +import { contextSelector } from '../../src/context/decorators/context-selector'; +import { provideContext } from '../../src/context/decorators/provide-context'; +import { ContextEventsTypes, ContextRequestEvent } from '../../src/context/events'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { customElement } from '../../src/decorators/custom-element'; type TestContext = { value: number; @@ -15,7 +15,6 @@ type TestContext = { const testContext = createContext(Symbol('todo-context')); -@customElement('my-context-provider') class MyContextProvider extends RadiantElement { @provideContext({ context: testContext, @@ -23,13 +22,10 @@ class MyContextProvider extends RadiantElement { hydrate: Object, }) context!: ContextProvider; - - updateContextValue() { - this.context.setContext({ value: this.context.getContext().value + 1 }); - } } -@customElement('my-context-consumer') +customElements.define('my-context-provider', MyContextProvider); + class MyContextConsumer extends RadiantElement { @consumeContext(testContext) context!: ContextProvider; @contextSelector({ context: testContext, select: (context) => context.value }) @@ -38,16 +34,64 @@ class MyContextConsumer extends RadiantElement { } } -const template = ''; +customElements.define('my-context-consumer', MyContextConsumer); describe('Context', () => { - test('it provides and consumes context correctly', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + test('it provides and consumes context correctly', async () => { const contextProvider = document.createElement('my-context-provider') as MyContextProvider; const contextConsumer = document.createElement('my-context-consumer') as MyContextConsumer; contextProvider.appendChild(contextConsumer); document.body.appendChild(contextProvider); - expect(contextConsumer.innerHTML).toEqual('1'); - contextProvider.updateContextValue(); - expect(contextConsumer.innerHTML).toEqual('2'); + + contextConsumer.addEventListener(ContextEventsTypes.MOUNTED, async () => { + expect(contextConsumer.innerHTML).toEqual('1'); + contextProvider.context.setContext({ value: 3 }); + expect(contextConsumer.innerHTML).toEqual('3'); + contextProvider.context.setContext({ value: 3 }); + expect(contextConsumer.innerHTML).toEqual('5'); + }); + }); + + test('it initializes with the provided context and initial value', () => { + const initialValue = { value: 10 }; + const contextProvider = document.createElement('my-context-provider') as MyContextProvider; + + contextProvider.addEventListener(ContextEventsTypes.MOUNTED, () => { + contextProvider.context.setContext(initialValue); + expect(contextProvider.context.getContext()).toEqual(initialValue); + }); + }); + + test('it sets and gets context correctly', async () => { + const contextProvider = document.createElement('my-context-provider') as MyContextProvider; + const update = { value: 20 }; + contextProvider.addEventListener(ContextEventsTypes.MOUNTED, () => { + contextProvider.context.setContext(update); + expect(contextProvider.context.getContext()).toEqual(update); + }); + }); + + test('it notifies subscribers on context update', () => { + const callback = vi.fn(); + class ManualConsumer extends RadiantElement { + context!: ContextProvider; + override connectedCallback() { + super.connectedCallback(); + this.dispatchEvent(new ContextRequestEvent(testContext, callback, true)); + } + } + customElements.define('manual-context-element', ManualConsumer); + + const contextProvider = document.createElement('my-context-provider') as MyContextProvider; + const contextConsumer = document.createElement('manual-context-element') as MyContextConsumer; + contextProvider.appendChild(contextConsumer); + document.body.appendChild(contextProvider); + contextProvider.addEventListener(ContextEventsTypes.MOUNTED, () => { + expect(callback).toHaveBeenCalled(); + }); }); }); diff --git a/packages/radiant/test/core/radiant-element.test.ts b/packages/radiant/test/core/radiant-element.test.ts index eb77f46..6ecc2c4 100644 --- a/packages/radiant/test/core/radiant-element.test.ts +++ b/packages/radiant/test/core/radiant-element.test.ts @@ -1,7 +1,12 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { RadiantElement } from '@/index'; +import { waitFor } from '@testing-library/dom'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; -class MyRadiantElement extends RadiantElement {} +class MyRadiantElement extends RadiantElement { + static observedAttributes = ['number', 'string']; + declare number: number; + declare string: string; +} customElements.define('my-radiant-element', MyRadiantElement); @@ -23,45 +28,110 @@ describe('RadiantElement', () => { document.body.appendChild(customElement); customElement.subscribeEvents([ { - id: 'my-id', - selector: '[data-ref="click-me"] ', + selector: '[data-ref="click-me"]', type: 'click', listener: () => {}, }, { - id: 'my-id-2', - selector: '[data-ref="click-it"] ', + selector: '[data-ref="click-it"]', type: 'click', listener: () => {}, }, ]); // @ts-expect-error: private property - expect(customElement.eventSubscriptions.has('my-id')).toBeTruthy(); + expect(customElement.eventSubscriptions.has('click:[data-ref="click-me"]')).toBeTruthy(); // @ts-expect-error: private property - expect(customElement.eventSubscriptions.has('my-id-2')).toBeTruthy(); + expect(customElement.eventSubscriptions.has('click:[data-ref="click-it"]')).toBeTruthy(); }); test('it can unsubscribe from events', () => { const customElement = document.createElement('my-radiant-element') as MyRadiantElement; document.body.appendChild(customElement); - customElement.subscribeEvents([ + const [unsubscribeClickMe] = customElement.subscribeEvents([ { - id: 'my-id', - selector: '[data-ref="click-me"] ', + selector: '[data-ref="click-me"]', type: 'click', listener: () => {}, }, { - id: 'my-id-2', - selector: '[data-ref="click-it"] ', + selector: '[data-ref="click-it"]', type: 'click', listener: () => {}, }, ]); - customElement.unsubscribeEvent('my-id'); + + unsubscribeClickMe(); + // @ts-expect-error: private property - expect(customElement.eventSubscriptions.has('my-id')).toBeFalsy(); + expect(customElement.eventSubscriptions.has('click:[data-ref="click-me"]')).toBeFalsy(); // @ts-expect-error: private property - expect(customElement.eventSubscriptions.has('my-id-2')).toBeTruthy(); + expect(customElement.eventSubscriptions.has('click:[data-ref="click-it"]')).toBeTruthy(); + }); + + test('it can create a reactive property', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + customElement.createReactiveProp('number', { type: Number, defaultValue: 5 }); + expect(customElement.number).toEqual(5); + customElement.number = 10; + expect(customElement.number).toEqual(10); + }); + + test('it can reflect a reactive property to an attribute', async () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + customElement.createReactiveProp('number', { type: Number, defaultValue: 5, reflect: true }); + await waitFor(() => expect(customElement.getAttribute('number')).toEqual('5')); + customElement.setAttribute('number', '10'); + expect(customElement.getAttribute('number')).toEqual('10'); + }); + + test('it can add multiple reactive properties', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + customElement.createReactiveProp('number', { type: Number, defaultValue: 5 }); + customElement.createReactiveProp('string', { type: String, defaultValue: 'John' }); + expect(customElement.number).toEqual(5); + expect(customElement.string).toEqual('John'); + }); + + test('it can create a reactive field', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + customElement.createReactiveField('number', 5); + expect(customElement.number).toEqual(5); + customElement.number = 10; + expect(customElement.number).toEqual(10); + }); + + test('it can create multiple reactive fields', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + customElement.createReactiveField('number', 5); + customElement.createReactiveField('string', 'John'); + expect(customElement.number).toEqual(5); + expect(customElement.string).toEqual('John'); + }); + + test('it can get a reference to an element', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + const span = document.createElement('span'); + span.setAttribute('data-ref', 'my-ref'); + customElement.appendChild(span); + const ref = customElement.getRef('my-ref'); + expect(ref).toEqual(span); + }); + + test('it can get all references to elements', () => { + const customElement = document.createElement('my-radiant-element') as MyRadiantElement; + document.body.appendChild(customElement); + for (let i = 0; i < 3; i++) { + const span = document.createElement('span'); + span.setAttribute('data-ref', 'my-ref'); + customElement.appendChild(span); + } + const refs = customElement.getRef('my-ref', true); + expect(refs.length).toEqual(3); }); }); diff --git a/packages/radiant/test/decorators/bound.test.ts b/packages/radiant/test/decorators/bound.test.ts new file mode 100644 index 0000000..3bf8612 --- /dev/null +++ b/packages/radiant/test/decorators/bound.test.ts @@ -0,0 +1,32 @@ +import { waitFor } from '@testing-library/dom'; +import { describe, expect, test } from 'vitest'; +import { bound } from '../../src/decorators/bound'; + +class MyBoundElement extends HTMLElement { + nextValue = 'Hello, bound!'; + connectedCallback(): void { + this.innerHTML = 'Hello, world!'; + this.addEventListener('click', this.handleClick); + } + + @bound + handleClick() { + this.innerHTML = this.nextValue; + } +} + +customElements.define('my-bound-element', MyBoundElement); + +describe('@bound', () => { + test('decorator binds the method correctly', async () => { + const customElement = document.createElement('my-bound-element') as MyBoundElement; + document.body.appendChild(customElement); + + customElement.click(); + await waitFor(() => expect(customElement.innerHTML).toEqual('Hello, bound!')); + + const unboundClick = customElement.handleClick; + unboundClick.call(null); + await waitFor(() => expect(customElement.innerHTML).toEqual('Hello, bound!')); + }); +}); diff --git a/packages/radiant/test/decorators/custom-element.test.ts b/packages/radiant/test/decorators/custom-element.test.ts index 4ad4304..d36d7dc 100644 --- a/packages/radiant/test/decorators/custom-element.test.ts +++ b/packages/radiant/test/decorators/custom-element.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, test } from 'bun:test'; -import { RadiantElement, customElement } from '@/index'; +import { describe, expect, test } from 'vitest'; +import { customElement } from '../../src/decorators/custom-element'; @customElement('my-custom-element') -class MyCustomElement extends RadiantElement { - override connectedCallback(): void { - super.connectedCallback(); +class MyCustomElement extends HTMLElement { + connectedCallback(): void { this.innerHTML = 'Hello, world!'; } } diff --git a/packages/radiant/test/decorators/debounce.test.ts b/packages/radiant/test/decorators/debounce.test.ts index f98b38c..5789582 100644 --- a/packages/radiant/test/decorators/debounce.test.ts +++ b/packages/radiant/test/decorators/debounce.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, test } from 'bun:test'; -import { debounce } from '@/decorators/debounce'; +import { describe, expect, test } from 'vitest'; +import { debounce } from '../../src/decorators/debounce'; describe('@debounce', () => { test('decorator debounces a method correctly', async () => { diff --git a/packages/radiant/test/decorators/event.test.ts b/packages/radiant/test/decorators/event.test.ts index 442acf8..2bb5ac6 100644 --- a/packages/radiant/test/decorators/event.test.ts +++ b/packages/radiant/test/decorators/event.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, test } from 'bun:test'; -import { type EventEmitter, RadiantElement, customElement, event, onEvent, query } from '@/index'; +import { describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { event } from '../../src/decorators/event'; +import { onEvent } from '../../src/decorators/on-event'; +import type { EventEmitter } from '../../src/tools/event-emitter'; enum RadiantEventEvents { CustomEvent = 'custom-event', @@ -9,43 +12,51 @@ type RadiantEventDetail = { value: string; }; -@customElement('radiant-event-emitter') class RadiantEventEmitter extends RadiantElement { @event({ name: RadiantEventEvents.CustomEvent, bubbles: true, composed: true }) customEvent!: EventEmitter; @onEvent({ ref: 'emit-button', type: 'click' }) - onEmitButtonClick() { + emitEvent() { this.customEvent.emit({ value: 'Hello, World!' }); } } -@customElement('radiant-event-listener') +customElements.define('radiant-event-emitter', RadiantEventEmitter); + class RadiantEventListener extends RadiantElement { - @query({ ref: 'event-detail' }) eventDetail!: HTMLDivElement; + eventDetail!: HTMLDivElement; + + override connectedCallback(): void { + super.connectedCallback(); + this.eventDetail = this.getRef('event-detail'); + } @onEvent({ selector: 'radiant-event-emitter', type: RadiantEventEvents.CustomEvent }) - onCustomEvent(event: CustomEvent) { + updateText(event: CustomEvent) { this.eventDetail.textContent = event.detail.value; } } +customElements.define('radiant-event-listener', RadiantEventListener); + const createTemplate = () => { - const customElement = document.createElement('radiant-event-listener'); - customElement.innerHTML = ` -

Click to change the text
- - `; - return customElement; + const radiantEventListener = document.createElement('radiant-event-listener') as RadiantEventListener; + const divEventDetail = document.createElement('div'); + divEventDetail.setAttribute('data-ref', 'event-detail'); + divEventDetail.textContent = 'Click to change the text'; + const radiantEventEmitter = document.createElement('radiant-event-emitter') as RadiantEventEmitter; + radiantEventListener.appendChild(divEventDetail); + radiantEventListener.appendChild(radiantEventEmitter); + document.body.appendChild(radiantEventListener); + return { radiantEventListener, radiantEventEmitter }; }; describe('@event', () => { - test('decorator emits and listens to custom event correctly', () => { - const customElement = createTemplate(); - document.body.appendChild(customElement); - const radiantEventListener = document.querySelector('radiant-event-listener') as RadiantEventListener; - const radiantEventEmitter = document.querySelector('radiant-event-emitter') as RadiantEventEmitter; + test('decorator emits and listens to custom event correctly', async () => { + const { radiantEventListener, radiantEventEmitter } = createTemplate(); expect(radiantEventListener.eventDetail.innerHTML).toEqual('Click to change the text'); + radiantEventEmitter.customEvent.emit({ value: 'Hello, World!' }); expect(radiantEventListener.eventDetail.innerHTML).toEqual('Hello, World!'); }); diff --git a/packages/radiant/test/decorators/on-event.test.ts b/packages/radiant/test/decorators/on-event.test.ts new file mode 100644 index 0000000..fccec9c --- /dev/null +++ b/packages/radiant/test/decorators/on-event.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { customElement } from '../../src/decorators/custom-element'; +import { onEvent } from '../../src/decorators/on-event'; + +describe('onEvent', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should add event listener to window when window is true in eventConfig', () => { + @customElement('window-event-emitter') + class WindowEventLister extends RadiantElement { + received = false; + @onEvent({ window: true, type: 'click' }) + emitEvent() { + this.received = true; + } + } + + const element = document.createElement('window-event-emitter') as WindowEventLister; + document.appendChild(element); + window.dispatchEvent(new Event('click')); + window.addEventListener('click', () => { + expect(element.received).toBeTruthy(); + }); + }); + + it('should add event listener to document when document is true in eventConfig', () => { + @customElement('window-event-emitter') + class DocumentEventLister extends RadiantElement { + received = false; + @onEvent({ document: true, type: 'click' }) + emitEvent() { + this.received = true; + } + } + + const element = document.createElement('window-event-emitter') as DocumentEventLister; + document.appendChild(element); + document.dispatchEvent(new Event('click')); + document.addEventListener('click', () => { + expect(element.received).toBeTruthy(); + }); + }); +}); diff --git a/packages/radiant/test/decorators/on-updated.test.ts b/packages/radiant/test/decorators/on-updated.test.ts index c533143..0a6bcff 100644 --- a/packages/radiant/test/decorators/on-updated.test.ts +++ b/packages/radiant/test/decorators/on-updated.test.ts @@ -1,71 +1,139 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { RadiantElement, customElement, onUpdated, query, reactiveProp } from '@/index'; +import { waitFor } from '@testing-library/dom'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { onUpdated } from '../../src/decorators/on-updated'; -@customElement('radiant-counter') -class RadiantCounter extends RadiantElement { - @reactiveProp({ type: Number, reflect: true, defaultValue: 3 }) declare value: number; - @query({ ref: 'count' }) countText!: HTMLElement; +describe('@onUpdated', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); - decrement() { - if (this.value > 0) this.value--; - } + class RadiantCounter extends RadiantElement { + static observedAttributes = ['value']; + declare value: number; + countText!: HTMLElement; - increment() { - this.value++; - } + constructor() { + super(); + this.createReactiveProp('value', { + type: Number, + defaultValue: 3, + }); + } - @onUpdated('value') - updateCount() { - this.countText.textContent = this.value.toString(); + override connectedCallback() { + super.connectedCallback(); + this.countText = this.getRef('count'); + } + + decrement() { + if (this.value > 0) this.value--; + } + + increment() { + this.value++; + } + + @onUpdated('value') + updateCount() { + this.countText.textContent = this.value.toString(); + } } -} -const REACTIVE_PROP = 'value'; -const DATA_REF = 'count'; + customElements.define('radiant-counter', RadiantCounter); -const createRadiantCounter = (initialValue?: string) => { - const customElement = document.createElement('radiant-counter') as RadiantCounter; - if (initialValue) customElement.setAttribute(REACTIVE_PROP, initialValue); - const span = document.createElement('span'); - span.setAttribute('data-ref', DATA_REF); - if (initialValue) span.innerHTML = initialValue; - customElement.appendChild(span); - return customElement; -}; + const REACTIVE_PROP = 'value'; + const DATA_REF = 'count'; -describe('@onUpdated', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); + const createRadiantCounter = (initialValue?: string) => { + const customElement = document.createElement('radiant-counter') as RadiantCounter; + if (initialValue) customElement.setAttribute(REACTIVE_PROP, initialValue); + const span = document.createElement('span'); + span.setAttribute('data-ref', DATA_REF); + if (initialValue) span.innerHTML = initialValue; + customElement.appendChild(span); + return customElement; + }; test('decorator updates the element correctly', () => { const customElement = createRadiantCounter('5'); document.body.appendChild(customElement); - customElement.value = 10; - expect(customElement.value).toEqual(10); + customElement[REACTIVE_PROP] = 10; + expect(customElement[REACTIVE_PROP]).toEqual(10); expect(customElement.countText.innerHTML).toEqual('10'); - customElement.setAttribute(REACTIVE_PROP, '15'); - expect(customElement.value).toEqual(15); + customElement[REACTIVE_PROP] = 15; + expect(customElement[REACTIVE_PROP]).toEqual(15); expect(customElement.countText.innerHTML).toEqual('15'); customElement.decrement(); - expect(customElement.value).toEqual(14); + expect(customElement[REACTIVE_PROP]).toEqual(14); expect(customElement.countText.innerHTML).toEqual('14'); customElement.increment(); - expect(customElement.value).toEqual(15); + expect(customElement[REACTIVE_PROP]).toEqual(15); expect(customElement.countText.innerHTML).toEqual('15'); }); - test('decorator updates the element correctly when setAttribute is used', () => { + test('decorator updates the element correctly when setAttribute is used and observedAttributes is defined', () => { const customElement = createRadiantCounter('5'); document.body.appendChild(customElement); customElement.setAttribute(REACTIVE_PROP, '10'); expect(customElement.countText.innerHTML).toEqual('10'); }); - test('decorator updates the value on load if no value is provided', () => { + test('decorator updates the value on load if no value is provided', async () => { const customElement = createRadiantCounter(); document.body.appendChild(customElement); - expect(customElement.value).toEqual(3); - expect(customElement.countText.innerHTML).toEqual('3'); + expect(customElement[REACTIVE_PROP]).toEqual(3); + await waitFor(() => expect(customElement.countText.innerHTML).toEqual('3')); + }); + + test('decorator works correctly when multiple elements are created', async () => { + const customElement1 = createRadiantCounter('5'); + const customElement2 = createRadiantCounter('10'); + const customElement3 = createRadiantCounter(); + document.body.appendChild(customElement1); + document.body.appendChild(customElement2); + document.body.appendChild(customElement3); + customElement1[REACTIVE_PROP] = 15; + customElement2[REACTIVE_PROP] = 20; + expect(customElement1.countText.innerHTML).toEqual('15'); + expect(customElement2.countText.innerHTML).toEqual('20'); + await waitFor(() => expect(customElement3.countText.innerHTML).toEqual('3')); + }); + + test('decorator can be used with multiple reactive props', async () => { + class StepperCounter extends RadiantElement { + declare value: number; + declare step: number; + multiplied = 0; + + constructor() { + super(); + this.createReactiveProp('value', { + type: Number, + defaultValue: 3, + }); + this.createReactiveProp('step', { + type: Number, + defaultValue: 1, + }); + } + + @onUpdated(['value', 'step']) + updateCount() { + this.multiplied = this.value * this.step; + } + } + + customElements.define('stepper-counter', StepperCounter); + + const customElement = document.createElement('stepper-counter') as StepperCounter; + document.body.appendChild(customElement); + customElement.value = 3; + customElement.step = 5; + expect(customElement.multiplied).toEqual(15); + customElement.value = 5; + expect(customElement.multiplied).toEqual(25); + customElement.step = 2; + expect(customElement.multiplied).toEqual(10); }); }); diff --git a/packages/radiant/test/decorators/query.test.ts b/packages/radiant/test/decorators/query.test.ts index 52fc92a..7e34084 100644 --- a/packages/radiant/test/decorators/query.test.ts +++ b/packages/radiant/test/decorators/query.test.ts @@ -1,12 +1,13 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { RadiantElement, customElement, query } from '@/index'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { query } from '../../src/decorators/query'; -@customElement('my-query-element') class MyQueryElement extends RadiantElement { - @query({ ref: 'my-ref' }) myRef!: HTMLDivElement; - @query({ ref: 'my-ref', all: true, cache: false }) myRefs!: HTMLDivElement[]; - @query({ selector: '.my-class' }) declare myClass: HTMLElement; - @query({ selector: '.my-class', all: true }) declare myClasses: HTMLElement[]; + @query({ ref: 'my-ref' }) myRef: HTMLDivElement; + @query({ ref: 'my-ref', all: true, cache: false }) myRefs: HTMLDivElement[]; + @query({ ref: 'my-ref', all: true, cache: true }) myRefsCache: HTMLDivElement[]; + @query({ selector: '.my-class' }) myClass: HTMLElement; + @query({ selector: '.my-class', all: true }) myClasses: HTMLElement[]; addElement() { const div = document.createElement('div'); @@ -16,15 +17,65 @@ class MyQueryElement extends RadiantElement { } } +customElements.define('my-query-element', MyQueryElement); + +const createElementWithRef = (text: string, dataRef: string) => { + const div = document.createElement('div'); + div.textContent = text; + div.setAttribute('data-ref', dataRef); + return div; +}; + +const createElementWithClass = (text: string, className: string) => { + const div = document.createElement('div'); + div.textContent = text; + div.classList.add(className); + return div; +}; + const createTemplate = () => { const customElement = document.createElement('my-query-element'); - customElement.innerHTML = ` -
My Ref 1
-
My Ref 2
-
My Class 1
-
My Class 2
-
My Class 3
- `; + + const innerElementsMap: ( + | { + text: string; + dataRef: string; + } + | { + text: string; + className: string; + } + )[] = [ + { + text: 'My Ref 1', + dataRef: 'my-ref', + }, + { + text: 'My Ref 2', + dataRef: 'my-ref', + }, + { + text: 'My Class 1', + className: 'my-class', + }, + { + text: 'My Class 2', + className: 'my-class', + }, + { + text: 'My Class 3', + className: 'my-class', + }, + ]; + + for (const innerElement of innerElementsMap) { + if ('dataRef' in innerElement) { + customElement.appendChild(createElementWithRef(innerElement.text, innerElement.dataRef)); + } else { + customElement.appendChild(createElementWithClass(innerElement.text, innerElement.className)); + } + } + return customElement as MyQueryElement; }; @@ -33,7 +84,7 @@ describe('@query', () => { document.body.innerHTML = ''; }); - test('decorator queries ref correctly', () => { + test('decorator queries ref correctly', async () => { const customElement = createTemplate(); document.body.appendChild(customElement); expect(customElement.myRef.textContent).toEqual('My Ref 1'); @@ -70,4 +121,20 @@ describe('@query', () => { expect(customElement.myRefs.length).toEqual(3); expect(customElement.myRefs[2].textContent).toEqual('My Ref 3'); }); + + test('decorator queries ref with cache true correctly after adding element', () => { + const customElement = createTemplate(); + document.body.appendChild(customElement); + expect(customElement.myRefs.length).toEqual(2); + customElement.addElement(); + expect(customElement.myRefs.length).toEqual(3); + }); + + test('decorator queries ref with cache true correctly after adding element', () => { + const customElement = createTemplate(); + document.body.appendChild(customElement); + expect(customElement.myRefsCache.length).toEqual(2); + customElement.addElement(); + expect(customElement.myRefsCache.length).toEqual(2); + }); }); diff --git a/packages/radiant/test/decorators/reactive-field.test.ts b/packages/radiant/test/decorators/reactive-field.test.ts index 387735a..9b1b65b 100644 --- a/packages/radiant/test/decorators/reactive-field.test.ts +++ b/packages/radiant/test/decorators/reactive-field.test.ts @@ -1,20 +1,27 @@ -import { describe, expect, test } from 'bun:test'; -import { RadiantElement, customElement, onUpdated, reactiveField } from '@/index'; +import { describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { reactiveField } from '../../src/decorators/reactive-field'; -@customElement('my-reactive-field') class MyReactiveField extends RadiantElement { @reactiveField numberOfClicks = 1; + override connectedCallback() { + super.connectedCallback(); + this.updateClicks = this.updateClicks.bind(this); + this.registerUpdateCallback('numberOfClicks', this.updateClicks); + } + addClick() { this.numberOfClicks++; } - @onUpdated('numberOfClicks') updateClicks() { this.innerHTML = this.numberOfClicks.toString(); } } +customElements.define('my-reactive-field', MyReactiveField); + describe('@reactiveField', () => { test('decorator updates the element correctly', () => { const customElement = document.createElement('my-reactive-field') as MyReactiveField; diff --git a/packages/radiant/test/decorators/reactive-prop.test.ts b/packages/radiant/test/decorators/reactive-prop.test.ts index f804440..463b32e 100644 --- a/packages/radiant/test/decorators/reactive-prop.test.ts +++ b/packages/radiant/test/decorators/reactive-prop.test.ts @@ -1,5 +1,7 @@ -import { beforeEach, describe, expect, test } from 'bun:test'; -import { RadiantElement, customElement, reactiveProp } from '@/index'; +import { beforeEach, describe, expect, test } from 'vitest'; +import { RadiantElement } from '../../src/core/radiant-element'; +import { customElement } from '../../src/decorators/custom-element'; +import { reactiveProp } from '../../src/decorators/reactive-prop'; describe('@reactiveProp', () => { beforeEach(() => { @@ -9,7 +11,7 @@ describe('@reactiveProp', () => { describe('string', () => { @customElement('my-reactive-string') class MyReactiveString extends RadiantElement { - @reactiveProp({ type: String, defaultValue: 'Frank' }) declare name: string; + @reactiveProp({ type: String, defaultValue: 'Frank' }) name: string; changeName(name: string) { this.name = name; @@ -18,8 +20,8 @@ describe('@reactiveProp', () => { test('decorator updates the string correctly', () => { const customElement = document.createElement('my-reactive-string') as MyReactiveString; - customElement.setAttribute('name', 'John'); document.body.appendChild(customElement); + customElement.changeName('John'); expect(customElement.name).toEqual('John'); customElement.changeName('Jane'); expect(customElement.name).toEqual('Jane'); @@ -35,7 +37,7 @@ describe('@reactiveProp', () => { describe('number', () => { @customElement('my-reactive-number') class MyReactiveNumber extends RadiantElement { - @reactiveProp({ type: Number }) declare num: number; + @reactiveProp({ type: Number }) num: number; add() { this.num++; @@ -44,8 +46,8 @@ describe('@reactiveProp', () => { test('decorator updates the number correctly', () => { const customElement = document.createElement('my-reactive-number') as MyReactiveNumber; - customElement.setAttribute('num', '1'); document.body.appendChild(customElement); + customElement.num = 1; expect(customElement.num).toEqual(1); customElement.add(); expect(customElement.num).toEqual(2); @@ -61,7 +63,7 @@ describe('@reactiveProp', () => { describe('boolean', () => { @customElement('my-reactive-boolean') class MyReactiveBoolean extends RadiantElement { - @reactiveProp({ type: Boolean, defaultValue: false }) declare bool: boolean; + @reactiveProp({ type: Boolean, defaultValue: false }) bool: boolean; toggleBoolean() { this.bool = !this.bool; @@ -69,8 +71,8 @@ describe('@reactiveProp', () => { } test('decorator updates the boolean correctly', () => { const customElement = document.createElement('my-reactive-boolean') as MyReactiveBoolean; - customElement.setAttribute('bool', ''); document.body.appendChild(customElement); + customElement.bool = true; expect(customElement.bool).toEqual(true); customElement.toggleBoolean(); expect(customElement.bool).toEqual(false); @@ -86,33 +88,33 @@ describe('@reactiveProp', () => { describe('object', () => { @customElement('my-reactive-object') class MyReactiveObject extends RadiantElement { - @reactiveProp({ type: Object, defaultValue: { name: 'Frank' } }) declare data: { name: string }; + @reactiveProp({ type: Object, defaultValue: { name: 'Frank' } }) obj: { name: string }; changeName(name: string) { - this.data.name = name; + this.obj.name = name; } } test('decorator updates the object correctly', () => { const customElement = document.createElement('my-reactive-object') as MyReactiveObject; - customElement.setAttribute('data', JSON.stringify({ name: 'John' })); document.body.appendChild(customElement); - expect(customElement.data.name).toEqual('John'); + customElement.obj = { name: 'John' }; + expect(customElement.obj.name).toEqual('John'); customElement.changeName('Jane'); - expect(customElement.data.name).toEqual('Jane'); + expect(customElement.obj.name).toEqual('Jane'); }); test('decorator has the correct default object value', () => { const customElement = document.createElement('my-reactive-object') as MyReactiveObject; document.body.appendChild(customElement); - expect(customElement.data.name).toEqual('Frank'); + expect(customElement.obj.name).toEqual('Frank'); }); }); describe('array', () => { @customElement('my-reactive-array') class MyReactiveArray extends RadiantElement { - @reactiveProp({ type: Array, defaultValue: ['Frank'] }) declare names: string[]; + @reactiveProp({ type: Array, defaultValue: ['Frank'] }) names: string[]; addName(name: string) { this.names.push(name); @@ -121,8 +123,8 @@ describe('@reactiveProp', () => { test('decorator updates the array correctly', () => { const customElement = document.createElement('my-reactive-array') as MyReactiveArray; - customElement.setAttribute('names', JSON.stringify(['John'])); document.body.appendChild(customElement); + customElement.names = ['John']; expect(customElement.names).toEqual(['John']); customElement.addName('Jane'); expect(customElement.names).toEqual(['John', 'Jane']); @@ -138,54 +140,55 @@ describe('@reactiveProp', () => { describe('reflect', () => { @customElement('my-reactive-reflect') class MyReactiveReflect extends RadiantElement { - @reactiveProp({ type: Number, reflect: true, defaultValue: 5 }) declare count: number; + @reactiveProp({ type: Number, reflect: true, defaultValue: 5 }) value: number; increment() { - this.count++; + this.value++; } } test('decorator updates the reflect correctly', () => { const customElement = document.createElement('my-reactive-reflect') as MyReactiveReflect; - customElement.setAttribute('count', '1'); document.body.appendChild(customElement); - expect(customElement.count).toEqual(1); + customElement.value = 1; + expect(customElement.value).toEqual(1); customElement.increment(); - expect(customElement.count).toEqual(2); - expect(customElement.getAttribute('count')).toEqual('2'); + expect(customElement.value).toEqual(2); + expect(customElement.getAttribute('value')).toEqual('2'); }); test('decorator has the correct default reflect value', () => { const customElement = document.createElement('my-reactive-reflect') as MyReactiveReflect; document.body.appendChild(customElement); - expect(customElement.count).toEqual(5); + expect(customElement.value).toEqual(5); }); }); describe('not reflect', () => { @customElement('my-reactive-not-reflect') class MyReactiveNotReflect extends RadiantElement { - @reactiveProp({ type: Number, reflect: false, defaultValue: 5 }) declare count: number; + @reactiveProp({ type: Number, reflect: false, defaultValue: 5 }) value: number; increment() { - this.count++; + this.value++; } } - test('decorator updates the not reflect correctly', () => { + test('decorator updates the value correctly but does not reflect it to the attribute', () => { const customElement = document.createElement('my-reactive-not-reflect') as MyReactiveNotReflect; - customElement.setAttribute('count', '1'); document.body.appendChild(customElement); - expect(customElement.count).toEqual(1); + customElement.value = 1; + expect(customElement.value).toEqual(1); customElement.increment(); - expect(customElement.count).toEqual(2); - expect(customElement.getAttribute('count')).toEqual('1'); + expect(customElement.value).toEqual(2); + expect(customElement.getAttribute('value')).toEqual(null); }); - test('decorator has the correct default not reflect value', () => { + test('decorator do not reflect the value to the attribute by default', () => { const customElement = document.createElement('my-reactive-not-reflect') as MyReactiveNotReflect; document.body.appendChild(customElement); - expect(customElement.count).toEqual(5); + expect(customElement.value).toEqual(5); + expect(customElement.getAttribute('value')).toEqual(null); }); }); }); diff --git a/packages/radiant/test/mixins/with-kita.test.tsx b/packages/radiant/test/mixins/with-kita.test.tsx index 30a86f0..98253ba 100644 --- a/packages/radiant/test/mixins/with-kita.test.tsx +++ b/packages/radiant/test/mixins/with-kita.test.tsx @@ -1,5 +1,7 @@ -import { describe, expect, test } from 'bun:test'; -import { RadiantElement, type RenderInsertPosition, WithKita, reactiveProp } from '@/index'; +import { describe, expect, test } from 'vitest'; +import { RadiantElement, type RenderInsertPosition } from '../../src/core/radiant-element'; +import { reactiveProp } from '../../src/decorators/reactive-prop'; +import { WithKita } from '../../src/mixins/with-kita'; const Message = ({ children, extra }: { children: string; extra: string }) => { return ( @@ -10,7 +12,7 @@ const Message = ({ children, extra }: { children: string; extra: string }) => { }; class MyWithKitaElement extends WithKita(RadiantElement) { - @reactiveProp({ type: String }) insert: RenderInsertPosition = 'replace'; + @reactiveProp({ type: String, defaultValue: 'replace' }) insert: RenderInsertPosition; override connectedCallback(): void { super.connectedCallback(); this.renderTemplate({ @@ -29,7 +31,7 @@ class MyWithKitaElement extends WithKita(RadiantElement) { customElements.define('my-with-kita-element', MyWithKitaElement); describe('WithKita', () => { - test('it renders template correctly using insert: replace', () => { + test('it renders template correctly using insert: replace', async () => { const element = document.createElement('my-with-kita-element'); document.body.appendChild(element); expect(element.innerHTML).toEqual('

My Radiant Element

Hello World

'); diff --git a/packages/radiant/test/utils/attribute-utils.test.ts b/packages/radiant/test/utils/attribute-utils.test.ts index 6f4848f..05bd589 100644 --- a/packages/radiant/test/utils/attribute-utils.test.ts +++ b/packages/radiant/test/utils/attribute-utils.test.ts @@ -1,18 +1,19 @@ -import { describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'vitest'; import { defaultValueForType, + getInitialValue, parseAttributeTypeConstant, parseAttributeTypeDefault, readAttributeValue, writeAttributeValue, -} from '@/index'; +} from '../../src/utils/attribute-utils'; describe('readAttributeValue', async () => { test.each([ ['true', true], ['1', true], ['0', false], - ])('%p should be parsed as %p', (a, b) => { + ])('%s should be parsed as %s', (a, b) => { const read = readAttributeValue(a, Boolean); expect(read).toBe(b); }); @@ -21,7 +22,7 @@ describe('readAttributeValue', async () => { ['1', 1], ['1_000', 1000], ['1_000_000', 1000000], - ])('%p should be parsed as %p', (a, b) => { + ])('%s should be parsed as %i', (a, b) => { const read = readAttributeValue(a, Number); expect(read).toBe(b); }); @@ -29,7 +30,7 @@ describe('readAttributeValue', async () => { test.each([ ['hello', 'hello'], ['', ''], - ])('%p should be parsed as %p', (a, b) => { + ])('%s should be parsed as %s', (a, b) => { const read = readAttributeValue(a, String); expect(read).toBe(b); }); @@ -37,7 +38,7 @@ describe('readAttributeValue', async () => { test.each([ ['{"hello":"world"}', { hello: 'world' }], ['{}', {}], - ])('%p should be parsed as %p', (a, b) => { + ])('%j should be parsed as %o', (a, b) => { const read = readAttributeValue(a, Object); expect(read).toEqual(b); }); @@ -45,7 +46,7 @@ describe('readAttributeValue', async () => { test.each([ ['["hello","world"]', ['hello', 'world']], ['[]', []], - ])('%p should be parsed as %p', (a, b) => { + ])('%j should be parsed as %o', (a, b) => { const read = readAttributeValue(a, Array); expect(read).toEqual(b); }); @@ -57,7 +58,7 @@ describe('readAttributeValue', async () => { ['1', Object], [{}, Number], [[], Number], - ])('%p should throw %p', (a, b) => { + ])('%s should throw %s', (a, b) => { const read = () => readAttributeValue(a as any, b); expect(read).toThrow(); }); @@ -67,7 +68,7 @@ describe('writeAttributeValue', async () => { test.each([ [true, 'true'], [false, 'false'], - ])('%p should be written as %p', (a, b) => { + ])('%s should be written as %s', (a, b) => { const write = writeAttributeValue(a, Boolean); expect(write).toBe(b); }); @@ -76,7 +77,7 @@ describe('writeAttributeValue', async () => { [1, '1'], [1000, '1000'], [1000000, '1000000'], - ])('%p should be written as %p', (a, b) => { + ])('%i should be written as %s', (a, b) => { const write = writeAttributeValue(a, Number); expect(write).toBe(b); }); @@ -84,7 +85,7 @@ describe('writeAttributeValue', async () => { test.each([ ['hello', 'hello'], ['', ''], - ])('%p should be written as %p', (a, b) => { + ])('%s should be written as %s', (a, b) => { const write = writeAttributeValue(a, String); expect(write).toBe(b); }); @@ -92,7 +93,7 @@ describe('writeAttributeValue', async () => { test.each([ [{ hello: 'world' }, '{"hello":"world"}'], [{}, '{}'], - ])('%p should be written as %p', (a, b) => { + ])('%o should be written as %j', (a, b) => { const write = writeAttributeValue(a, Object); expect(write).toBe(b); }); @@ -100,7 +101,7 @@ describe('writeAttributeValue', async () => { test.each([ [['hello', 'world'], '["hello","world"]'], [[], '[]'], - ])('%p should be written as %p', (a, b) => { + ])('%o should be written as %j', (a, b) => { const write = writeAttributeValue(a, Array); expect(write).toBe(b); }); @@ -113,7 +114,7 @@ describe('parseAttributeTypeDefault', async () => { ['hello', 'string'], [{ hello: 'world' }, 'object'], [['hello', 'world'], 'array'], - ])('%p should be parsed as %p', (a, b) => { + ])('%s should be parsed as %s', (a, b) => { const parsed = parseAttributeTypeDefault(a); expect(parsed).toBe(b); }); @@ -126,7 +127,7 @@ describe('parseAttributeTypeConstant', async () => { [String, 'string'], [Object, 'object'], [Array, 'array'], - ])('%p should be parsed as %p', (a, b) => { + ])('%o should be parsed as %s', (a, b) => { const parsed = parseAttributeTypeConstant(a); expect(parsed).toBe(b); }); @@ -139,8 +140,37 @@ describe('defaultValueForType', async () => { [String, ''], [Object, null], [Array, null], - ])('%p should be parsed as %p', (a, b) => { + ])('%o should be parsed as %s', (a, b) => { const parsed = defaultValueForType(a); expect(parsed).toBe(b); }); }); + +describe('getInitialValue defaults', () => { + test.each([ + [Boolean, 'value', false], + [Number, 'value', 0], + [String, 'value', ''], + [Object, 'value', null], + [Array, 'value', null], + ])('should parse type %o as default value %s', (type, attributeKey, defaultValue) => { + const radiantElement = document.createElement('radiant-counter') as any; + const parsed = getInitialValue(radiantElement, type, attributeKey, defaultValue); + expect(parsed).toBe(defaultValue); + }); +}); + +describe('getInitialValue with attribute', () => { + test.each([ + [Boolean, 'value', 'true', true], + [Number, 'value', '1', 1], + [String, 'value', 'hello', 'hello'], + [Object, 'value', '{"hello":"world"}', { hello: 'world' }], + [Array, 'value', '["hello","world"]', ['hello', 'world']], + ])('should parse attribute type %o with value %s as %s', (type, attributeKey, attributeValue, expectedValue) => { + const radiantElement = document.createElement('radiant-counter') as any; + radiantElement.setAttribute(attributeKey, attributeValue); + const parsed = getInitialValue(radiantElement, type, attributeKey, null); + expect(parsed).toEqual(expectedValue); + }); +}); diff --git a/packages/radiant/test/utils/stringify-typed.test.ts b/packages/radiant/test/utils/stringify-typed.test.ts new file mode 100644 index 0000000..2169d58 --- /dev/null +++ b/packages/radiant/test/utils/stringify-typed.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest'; +import { stringifyTyped } from '../../src/tools/stringify-typed'; + +describe('stringifyTyped', () => { + test('it stringifies a boolean', () => { + expect(stringifyTyped(true)).toBe('true'); + expect(stringifyTyped(false)).toBe('false'); + }); + + test('it stringifies a number', () => { + expect(stringifyTyped(1)).toBe('1'); + expect(stringifyTyped(1_000)).toBe('1000'); + expect(stringifyTyped(1_000_000)).toBe('1000000'); + }); + + test('it stringifies an object', () => { + expect(stringifyTyped({ hello: 'world' })).toBe('{"hello":"world"}'); + expect(stringifyTyped({})).toBe('{}'); + }); + + test('it stringifies an array', () => { + expect(stringifyTyped(['hello', 'world'])).toBe('["hello","world"]'); + expect(stringifyTyped([])).toBe('[]'); + }); + + test('it stringifies a null value', () => { + expect(stringifyTyped(null)).toBe('null'); + }); + + test('it stringifies an undefined value', () => { + expect(stringifyTyped(undefined)).toBe(undefined); + }); + + test('it stringifies a symbol', () => { + expect(stringifyTyped(Symbol('hello'))).toBe(undefined); + }); +}); diff --git a/packages/radiant/tsconfig.json b/packages/radiant/tsconfig.json index 47db314..3a32036 100644 --- a/packages/radiant/tsconfig.json +++ b/packages/radiant/tsconfig.json @@ -2,15 +2,24 @@ "compilerOptions": { "allowSyntheticDefaultImports": true, "baseUrl": ".", + "rootDir": ".", "esModuleInterop": true, - "experimentalDecorators": true, + "experimentalDecorators": false, "forceConsistentCasingInFileNames": true, "jsx": "react-jsx", "jsxImportSource": "@kitajs/html", "module": "ESNext", "moduleResolution": "node", "noImplicitOverride": true, + "strictPropertyInitialization": false, "outDir": "./dist", + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["bun-types"], + "useDefineForClassFields": true, + "verbatimModuleSyntax": true, "paths": { "@/*": ["./src/*"], "dist/*": ["./dist/*"] @@ -19,15 +28,7 @@ { "name": "@kitajs/ts-html-plugin" } - ], - "resolveJsonModule": true, - "rootDir": ".", - "skipLibCheck": true, - "strict": true, - "target": "ESNext", - "types": ["bun-types"], - "useDefineForClassFields": true, - "verbatimModuleSyntax": true + ] }, "include": ["**/*"] } diff --git a/packages/radiant/tsconfig.legacy.json b/packages/radiant/tsconfig.legacy.json new file mode 100644 index 0000000..1a2df4b --- /dev/null +++ b/packages/radiant/tsconfig.legacy.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "baseUrl": ".", + "rootDir": ".", + "esModuleInterop": true, + "experimentalDecorators": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", + "module": "ESNext", + "moduleResolution": "node", + "noImplicitOverride": true, + "strictPropertyInitialization": false, + "outDir": "./dist", + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "types": ["bun-types"], + "useDefineForClassFields": true, + "verbatimModuleSyntax": true, + "paths": { + "@/*": ["./src/*"], + "dist/*": ["./dist/*"] + }, + "plugins": [ + { + "name": "@kitajs/ts-html-plugin" + } + ] + }, + "include": ["**/*"] +} diff --git a/packages/radiant/vite.config.ts b/packages/radiant/vite.config.ts new file mode 100644 index 0000000..07f996c --- /dev/null +++ b/packages/radiant/vite.config.ts @@ -0,0 +1,26 @@ +/// +import { defineConfig } from 'vite'; +import standardConfig from './tsconfig.json'; +import legacyConfig from './tsconfig.legacy.json'; + +const LEGACY_ENVIRONMENT = process.argv.includes('--legacy'); +const tsconfigRaw = LEGACY_ENVIRONMENT ? JSON.stringify(legacyConfig) : JSON.stringify(standardConfig); + +const exclude = LEGACY_ENVIRONMENT + ? ['src/decorators/standard', 'src/context/decorators/standard'] + : ['src/decorators/legacy', 'src/context/decorators/legacy']; + +export default defineConfig({ + esbuild: { + target: 'es2022', + tsconfigRaw, + }, + test: { + environmentMatchGlobs: [['test/**/*.test.*', 'happy-dom']], + coverage: { + provider: 'istanbul', + include: ['src'], + exclude: ['src/playground.tsx', 'src/types.ts'].concat(exclude), + }, + }, +}); diff --git a/playground/vite/package.json b/playground/vite/package.json index 110440e..d2de4fd 100644 --- a/playground/vite/package.json +++ b/playground/vite/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:legacy": "vite -- --legacy", "build": "tsc && vite build", "preview": "vite preview" }, diff --git a/playground/vite/src/components/accordion/accordion.script.ts b/playground/vite/src/components/accordion/accordion.script.ts index 28d8726..9740175 100644 --- a/playground/vite/src/components/accordion/accordion.script.ts +++ b/playground/vite/src/components/accordion/accordion.script.ts @@ -1,5 +1,5 @@ import { reactiveField } from '@ecopages/radiant'; -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; import { query } from '@ecopages/radiant/decorators/query'; @@ -18,9 +18,9 @@ type DetailsAnimation = { @customElement('radiant-accordion') export class RadiantAccordion extends RadiantElement { - @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare multiple: boolean; - @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare shouldAnimate: boolean; - @reactiveField declare currentIndex: number; + @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) multiple!: boolean; + @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) shouldAnimate!: boolean; + @reactiveField currentIndex!: number; @query({ selector: 'details', all: true }) detailsTargets!: HTMLDetailsElement[]; @query({ selector: 'summary', all: true }) toggleTargets!: HTMLElement[]; @query({ ref: 'panel', all: true }) panelTargets!: HTMLElement[]; diff --git a/playground/vite/src/components/dropdown/dropdown.script.ts b/playground/vite/src/components/dropdown/dropdown.script.ts index 8448f1d..0376fe5 100644 --- a/playground/vite/src/components/dropdown/dropdown.script.ts +++ b/playground/vite/src/components/dropdown/dropdown.script.ts @@ -1,6 +1,6 @@ import type { FocusableElement } from '@/types'; import { onUpdated } from '@ecopages/radiant'; -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { bound } from '@ecopages/radiant/decorators/bound'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; @@ -42,10 +42,10 @@ export class RadiantDropdown extends RadiantElement { @query({ ref: 'content' }) contentTarget!: HTMLElement; @query({ ref: 'arrow' }) arrowTarget!: HTMLElement; - @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare defaultOpen: boolean; - @reactiveProp({ type: String, defaultValue: 'left' }) declare placement: Placement; - @reactiveProp({ type: Number, defaultValue: 6 }) declare offset: number; - @reactiveProp({ type: Boolean, defaultValue: true }) declare focusOnOpen: boolean; + @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) defaultOpen!: boolean; + @reactiveProp({ type: String, defaultValue: 'left' }) placement!: Placement; + @reactiveProp({ type: Number, defaultValue: 6 }) offset!: number; + @reactiveProp({ type: Boolean, defaultValue: true }) focusOnOpen!: boolean; cleanup: ReturnType | null = null; diff --git a/playground/vite/src/components/radiant-counter/radiant-counter.script.ts b/playground/vite/src/components/radiant-counter/radiant-counter.script.ts index d4503a4..50a23d1 100644 --- a/playground/vite/src/components/radiant-counter/radiant-counter.script.ts +++ b/playground/vite/src/components/radiant-counter/radiant-counter.script.ts @@ -1,4 +1,4 @@ -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; import { onUpdated } from '@ecopages/radiant/decorators/on-updated'; @@ -11,7 +11,7 @@ export type RadiantCounterProps = { @customElement('radiant-counter') export class RadiantCounter extends RadiantElement { - @reactiveProp({ type: Number, reflect: true, defaultValue: 0 }) declare value: number; + @reactiveProp({ type: Number, reflect: true, defaultValue: 0 }) value!: number; @query({ ref: 'count' }) countText!: HTMLElement; @onEvent({ ref: 'decrement', type: 'click' }) diff --git a/playground/vite/src/components/radiant-event/radiant-event.script.ts b/playground/vite/src/components/radiant-event/radiant-event.script.ts index b3c70a2..fda19bd 100644 --- a/playground/vite/src/components/radiant-event/radiant-event.script.ts +++ b/playground/vite/src/components/radiant-event/radiant-event.script.ts @@ -12,7 +12,7 @@ type RadiantEventDetail = { @customElement('radiant-event-emitter') export class RadiantEventEmitter extends RadiantElement { @event({ name: RadiantEventEvents.CustomEvent, bubbles: true, composed: true }) - declare customEvent: EventEmitter; + customEvent!: EventEmitter; @onEvent({ ref: 'emit-button', type: 'click' }) onEmitButtonClick() { diff --git a/playground/vite/src/components/radiant-todo-app/radiant-todo-app.script.tsx b/playground/vite/src/components/radiant-todo-app/radiant-todo-app.script.tsx index 4ebcc3f..6024b9c 100644 --- a/playground/vite/src/components/radiant-todo-app/radiant-todo-app.script.tsx +++ b/playground/vite/src/components/radiant-todo-app/radiant-todo-app.script.tsx @@ -41,7 +41,7 @@ class Logger { export class RadiantTodoItem extends WithKita(RadiantElement) { @query({ selector: 'input[type="checkbox"]' }) checkbox!: HTMLInputElement; @query({ selector: 'button' }) removeButton!: HTMLButtonElement; - @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare complete: boolean; + @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) complete!: boolean; @consumeContext(todoContext) context!: ContextProvider; override connectedCallback(): void { diff --git a/playground/vite/src/components/value-tester/value-tester.script.tsx b/playground/vite/src/components/value-tester/value-tester.script.tsx index 494d51b..ab1a5be 100644 --- a/playground/vite/src/components/value-tester/value-tester.script.tsx +++ b/playground/vite/src/components/value-tester/value-tester.script.tsx @@ -1,4 +1,4 @@ -import { RadiantElement } from '@ecopages/radiant/core'; +import { RadiantElement } from '@ecopages/radiant/core/radiant-element'; import { customElement } from '@ecopages/radiant/decorators/custom-element'; import { onEvent } from '@ecopages/radiant/decorators/on-event'; import { onUpdated } from '@ecopages/radiant/decorators/on-updated'; @@ -16,14 +16,11 @@ export type RadiantValueTesterProps = { @customElement('radiant-tester') export class RadiantValueTester extends RadiantElement { - @reactiveProp({ type: Number, reflect: true, defaultValue: 0 }) declare number: number; - @reactiveProp({ type: String, reflect: true, defaultValue: 'string' }) declare string: string; - @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) declare boolean: boolean; - @reactiveProp({ type: Object, reflect: true, defaultValue: { key: 'value' } }) declare object: Record< - string, - unknown - >; - @reactiveProp({ type: Array, reflect: true, defaultValue: ['value'] }) declare array: unknown[]; + @reactiveProp({ type: Number, reflect: true, defaultValue: 0 }) number!: number; + @reactiveProp({ type: String, reflect: true, defaultValue: 'string' }) string!: string; + @reactiveProp({ type: Boolean, reflect: true, defaultValue: false }) boolean!: boolean; + @reactiveProp({ type: Object, reflect: true, defaultValue: { key: 'value' } }) object!: Record; + @reactiveProp({ type: Array, reflect: true, defaultValue: ['value'] }) array!: unknown[]; @query({ ref: 'number' }) numberText!: HTMLElement; @query({ ref: 'string' }) stringText!: HTMLElement; diff --git a/playground/vite/src/main.tsx b/playground/vite/src/main.tsx index 4524152..833da29 100644 --- a/playground/vite/src/main.tsx +++ b/playground/vite/src/main.tsx @@ -5,8 +5,8 @@ import { RadiantCounter } from './components/radiant-counter/radiant-counter.kit import { RadiantEvent } from './components/radiant-event/radiant-event.kita.tsx'; import { RadiantRefs } from './components/radiant-refs/radiant-refs.kita.tsx'; import { RadiantTodoApp } from './components/radiant-todo-app/radiant-todo-app.kita.tsx'; -import './styles/tailwind.css'; import { ValueTester } from './components/value-tester/value-tester.script.tsx'; +import './styles/tailwind.css'; const appRoot = document.querySelector('#app'); @@ -73,6 +73,10 @@ const App = async () => { ); }; -if (appRoot) { - appRoot.innerHTML = await (); -} +const renderApp = async () => { + if (appRoot) { + appRoot.innerHTML = await (); + } +}; + +renderApp(); diff --git a/playground/vite/tsconfig.json b/playground/vite/tsconfig.json index 14aaacd..c06df57 100644 --- a/playground/vite/tsconfig.json +++ b/playground/vite/tsconfig.json @@ -1,13 +1,12 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, "module": "ESNext", "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, - "experimentalDecorators": true, + "experimentalDecorators": false, - /* JSX */ "jsx": "react-jsx", "jsxImportSource": "@kitajs/html", "plugins": [ @@ -16,7 +15,6 @@ } ], - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, @@ -24,14 +22,12 @@ "moduleDetection": "force", "noEmit": true, - /* Paths */ "baseUrl": ".", "rootDir": ".", "paths": { "@/*": ["./src/*"] }, - /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/playground/vite/tsconfig.legacy.json b/playground/vite/tsconfig.legacy.json new file mode 100644 index 0000000..123f3a9 --- /dev/null +++ b/playground/vite/tsconfig.legacy.json @@ -0,0 +1,37 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "experimentalDecorators": true, + + "jsx": "react-jsx", + "jsxImportSource": "@kitajs/html", + "plugins": [ + { + "name": "@kitajs/ts-html-plugin" + } + ], + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + "baseUrl": ".", + "rootDir": ".", + "paths": { + "@/*": ["./src/*"] + }, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/playground/vite/vite.config.ts b/playground/vite/vite.config.ts new file mode 100644 index 0000000..6ef06e6 --- /dev/null +++ b/playground/vite/vite.config.ts @@ -0,0 +1,14 @@ +/// +import { defineConfig } from 'vite'; +import standardConfig from './tsconfig.json'; +import legacyConfig from './tsconfig.legacy.json'; + +const LEGACY_ENVIRONMENT = process.argv.includes('--legacy'); +const tsconfigRaw = LEGACY_ENVIRONMENT ? JSON.stringify(legacyConfig) : JSON.stringify(standardConfig); + +export default defineConfig({ + esbuild: { + target: 'es2022', + tsconfigRaw, + }, +});