From 1260642e07b10812cf3cc02e88d32cb03c32fd24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Samuel=20=C5=A0tancl?= Date: Fri, 26 Feb 2021 14:43:39 +0100 Subject: [PATCH] initial commit --- LICENSE | 21 +++++++ README.md | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 28 +++++++++ src/index.ts | 81 +++++++++++++++++++++++++ tsconfig.json | 8 +++ 5 files changed, 298 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..888f818 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Samuel Štancl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e1638ed --- /dev/null +++ b/README.md @@ -0,0 +1,160 @@ +# TypeScript support for Alpine.js + +This package comes with a light TypeScript layer which provides full support for class components in Alpine.js. + +It's used like this: + +**Register a component** +```ts +import { DarkModeToggle } + +Alpine.component('DarkModeToggle', DarkModeToggle); +``` + +**Use it the template** +```html +
+ +
+``` + +## Installation + +``` +npm install --save-dev github:leanadmin/alpine-typescript +``` + +```ts +// todo +``` + +## Usage + +You can get a component by calling `Alpine.component('component-name')(arg1, arg2)`. If your component has no arguments, still append the `()` after the call. + +The `component()` call itself returns a function that creates an instance of the component. Invoking the function ensures that the component has a unique instance each time. + +```html +
+ +
+``` + +```html +
+
+ ... +
+
+``` + +## Creating components + +To create a component, you need to create the component object and register it using one of the provided helpers. + +Component objects can be: +- functions returning plain objects +- classes + +In the context of plain objects, the wrapper function acts as a constructor that can pass initial data to the object. + +## Registering components + +A component can be registered like this: +```ts +import { ExampleComponent } from './ExampleComponent'; +import { component } from '@leanadmin/alpine-typescript'; + +component('example', ExampleComponent); +``` + +Which will make it accessible using `Alpine.component('example')('foo', 'bar)`. + +**Note: It's better to avoid using `Alpine.component('example', ExampleComponent)`** even if it might work in some cases. The reason for this is that `window.Alpine` might not yet be accessible when you're registering components, and if it is, it's possible that it's already evaluated some of the `x-data` attributes. + +To register multiple components, you can use the `registerComponents()` helper. + +This can pair well with scripts that crawl your e.g. `alpine/` directory to register all components using their file names. + +```ts +import { registerComponents } from '@leanadmin/alpine-typescript'; + +const files = require.context('./', true, /.*.ts/) + .keys() + .map(file => file.substr(2, file.length - 5)) // Remove ./ and .ts + .filter(file => file !== 'index') + .reduce((files: { [name: string]: Function }, file: string) => { + files[file] = require(`./${file}.ts`).default; + + return files; +}, {}); + +registerComponents(files); +``` + +## Class components + +You can create class components by extending `AlpineComponent` and exporting the class as `default`. + +The `AlpineComponent` provides IDE support for Alpine's magic properties. This means that you can use `this.$el`, `this.$nextTick(() => this.foo = this.bar)`, and more with full type support. + +```ts +import { AlpineComponent } from '@leanadmin/alpine-typescript'; + +export default class DarkModeToggle extends AlpineComponent { + public theme: string|null = null; + + /** Used for determining the transition direction. */ + public previousTheme: string|null = null; + + public browserTheme(): string { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + public switchTheme(theme: string): void { + this.$nextTick(() => this.previousTheme = this.theme); + + this.theme = theme; + + window.localStorage.setItem('leanTheme', theme); + + this.updateDocumentClass(theme); + } + + // ... + + public init(): void { + this.loadStoredTheme(); + this.registerListener(); + } +} +``` + +## Plain object components + +To register a plain object as an Alpine component, return a function that wraps the object like this: +```ts +export default (foo: string, bar: number) => ({ + foo, + bar, + + someFunction() { + console.log(this.foo); + } +}) +``` + +The function will serve as a "constructor" for the object, setting default values and anything else you might need. + +Note that the `=> ({` part is just syntactic sugar, you're free to use `return` if it's useful in your case: + +```ts +export default (foo: string, bar: number) => { + return { + foo, + bar, + + // ... + } +} +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..8fe67c7 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "@leanadmin/alpine-typescript", + "version": "0.1.0", + "description": "TypeScript support for Alpine.js", + "main": "src/index.js", + "repository": { + "type": "git", + "url": "git+https://github.com/LeanAdmin/alpine-typescript.git" + }, + "files": [ + "src/index.ts" + ], + "scripts": { + "build": "npx mix --production", + "prepublishOnly": "npm run-script build" + }, + "keywords": [ + "alpine", + "alpine.js", + "typescript" + ], + "author": "Samuel Štancl ", + "license": "MIT", + "bugs": { + "url": "https://github.com/LeanAdmin/alpine-typescript/issues" + }, + "homepage": "https://github.com/LeanAdmin/alpine-typescript#readme" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..529a0d6 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,81 @@ +declare global { + interface Window { + AlpineComponents: { [name: string]: ComponentConstructor }; + Alpine: any; + deferLoadingAlpine: Function; + } +} + +type ComponentConstructor = (...args: any[]) => object; + +export class AlpineComponent { + /** Retrieve the root component DOM node. */ + $el?: Element; + + /** Retrieve DOM elements marked with x-ref inside the component. */ + $refs?: { [name: string]: Element }; + + /** Retrieve the native browser "Event" object within an event listener. */ + $event?: Event; + + /** Create a CustomEvent and dispatch it using .dispatchEvent() internally. */ + $dispatch?: (event: string, data: object) => void; + + /** Execute a given expression AFTER Alpine has made its reactive DOM updates. */ + $nextTick?: (callback: () => void) => void; + + /** Will fire a provided callback when a component property you "watched" gets changed. */ + $watch?: (property: string, callback: (value: any) => void) => void; +} + +export function registerComponents(components: { [name: string]: Function }): { [name: string]: ComponentConstructor } { + Object.entries(components).forEach(([name, file]) => { + component(name, file); + }); + + return window.AlpineComponents; +} + +export function component(name: string, component: Function = null): ComponentConstructor { + if (! component) { + return window.AlpineComponents[name]; + } + + if (component['prototype'] instanceof AlpineComponent) { + component = convertClassToAlpineConstructor(component); + } + + // @ts-ignore + window.AlpineComponents[name] = component; +} + +export function convertClassToAlpineConstructor(component: any): ComponentConstructor { + return function (...args: any[]) { + let instance: AlpineComponent = new component(...args); + + // Copy methods + const methods = Object.getOwnPropertyNames( + Object.getPrototypeOf(instance) + ) + .reduce((obj, method) => { + obj[method] = instance[method]; + + return obj; + }, {}); + + // Copy properties + return Object.assign(methods, instance); + } +} + +export default () => { + window.AlpineComponents = {}; + + const deferrer = window.deferLoadingAlpine || function (callback: CallableFunction) { callback() }; + + window.deferLoadingAlpine = function (callback: Function) { + window.Alpine.component = component; + + deferrer(callback); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..992dc80 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "es6", + "lib": ["ES2017", "DOM"] + }, + "include": ["src/**/*"] +}