commit 851f44c20518fa4c6ead026f334ae09610ba9bd0 Author: defaultkavy Date: Thu Feb 1 23:47:13 2024 +0800 publish diff --git a/$index.ts b/$index.ts new file mode 100644 index 0000000..7efc19f --- /dev/null +++ b/$index.ts @@ -0,0 +1,134 @@ +import { $Node } from "./index"; +import { $Anchor } from "./lib/$Anchor"; +import { $Container } from "./lib/$Container"; +import { $Element } from "./lib/$Element"; +import { $Input } from "./lib/$Input"; +import { $Label } from "./lib/$Label"; +import { Router } from "./lib/Router/Router"; + +export function $(resolver: K): $.TagNameTypeMap[K]; +export function $(resolver: K): $Container; +export function $(htmlElement: H): $.HTMLElementTo$ElementMap +export function $(resolver: any) { + if (typeof resolver === 'string') { + if (resolver in $.TagNameElementMap) { + const instance = $.TagNameElementMap[resolver as keyof typeof $.TagNameElementMap] + switch (instance) { + case $Element: return new $Element(resolver); + case $Anchor: return new $Anchor(); + case $Container: return new $Container(resolver); + } + } else return new $Container(resolver); + } + if (resolver instanceof HTMLElement) { + if (resolver.$) return resolver.$; + else throw new Error('HTMLElement PROPERTY $ MISSING'); + } +} + +export namespace $ { + export let anchorHandler: null | ((url: URL, e: Event) => void) = null; + export let anchorPreventDefault: boolean = false; + export const routers = new Set; + export const TagNameElementMap = { + 'a': $Anchor, + 'p': $Container, + 'pre': $Container, + 'code': $Container, + 'blockquote': $Container, + 'strong': $Container, + 'h1': $Container, + 'h2': $Container, + 'h3': $Container, + 'h4': $Container, + 'h5': $Container, + 'h6': $Container, + 'div': $Container, + 'ol': $Container, + 'ul': $Container, + 'dl': $Container, + 'li': $Container, + 'input': $Input + } + export type TagNameTypeMap = { + [key in keyof typeof $.TagNameElementMap]: InstanceType; + }; + export type ContainerTypeTagName = Exclude; + export type SelfTypeTagName = 'input'; + + export type HTMLElementTo$ElementMap = + H extends HTMLLabelElement ? $Label + : H extends HTMLInputElement ? $Input + : H extends HTMLAnchorElement ? $Anchor + : $Element; + + export function fluent(instance: T, args: IArguments, value: () => V, action: (...args: any[]) => void) { + if (!args.length) return value(); + action(); + return instance; + } + + export function multableResolve(multable: OrArray) { + if (multable instanceof Array) return multable; + else return [multable]; + } + + export function mixin(target: any, constructors: OrArray) { + $.multableResolve(constructors).forEach(constructor => { + Object.getOwnPropertyNames(constructor.prototype).forEach(name => { + if (name === 'constructor') return; + Object.defineProperty( + target.prototype, + name, + Object.getOwnPropertyDescriptor(constructor.prototype, name) || Object.create(null) + ) + }) + }) + return target; + } + + export function set(object: O, key: K, value: any) { + if (value !== undefined) object[key] = value; + } +} +$.builder = builder + +/**Build multiple element in once. */ +function builder>(bulder: F, params: [...Parameters][], callback?: BuilderSelfFunction): R[] +function builder>(bulder: [F, ...Parameters], size: number, callback?: BuilderSelfFunction): R[] +function builder>(bulder: [F, ...Parameters], options: ($Node | string | BuilderSelfFunction)[]): R[] +function builder(tagname: K, size: number, callback?: BuilderSelfFunction<$.TagNameTypeMap[K]>): $.TagNameTypeMap[K][] +function builder(tagname: K, callback: BuilderSelfFunction<$.TagNameTypeMap[K]>[]): $.TagNameTypeMap[K][] +function builder(tagname: K, size: number, callback?: BuilderSelfFunction<$.TagNameTypeMap[K]>): $.TagNameTypeMap[K][] +function builder(tagname: K, options: ($Node | string | BuilderSelfFunction<$.TagNameTypeMap[K]>)[]): $.TagNameTypeMap[K][] +function builder(tagname: any, resolver: any, callback?: BuilderSelfFunction) { + if (typeof resolver === 'number') { + return Array(resolver).fill('').map(v => { + const ele = isTuppleBuilder(tagname) ? tagname[0](...tagname.slice(1) as []) : $(tagname); + if (callback) callback(ele); + return ele + }); + } + else { + const eleArray = []; + for (const item of resolver) { + const ele = tagname instanceof Function ? tagname(...item) // tagname is function, item is params + : isTuppleBuilder(tagname) ? tagname[0](...tagname.slice(1) as []) + : $(tagname); + if (item instanceof Function) { item(ele) } + else if (item instanceof $Node || typeof item === 'string') { ele.content(item) } + eleArray.push(ele); + } + return eleArray; + } + + function isTuppleBuilder(target: any): target is [BuildNodeFunction, ...any] { + if (target instanceof Array && target[0] instanceof Function) return true; + else return false; + } +} +type BuildNodeFunction = (...args: any[]) => $Node; +type BuilderSelfFunction = (self: K) => void + +//@ts-expect-error +globalThis.$ = $; \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bd39cf --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# Fluent - A mordern way to build web. +Inspired by jQuery, but not selecting query anymore, just create it. + +## Usage +```ts +import { $ } from 'fluent' + +const $app = $('app').content([ + $('h1').content('Hello World!') +]) + +document.body.append($app.dom) // render $app +``` + +## Forget HTML, create any element just like this +```ts +$('a') +``` + +## Yes, Fluent Method. +```ts +$('h1').class('amazing-title').css({color: 'red'}).content('Fluuuuuuuuuuuuent!') +``` + +## Router? I got you. +```ts +const router = new Router('/') + // example.com + .addRoute(new Route('/', () => 'Welcome to your first page!')) + + // example.com/user/anyusername + .addRoute(new Route('/user/:username', (params) => { + return $('div').content([ + $('h1').content(params.username) + ]) + })) + + .listen() // start resolve pathname and listen state change +``` + +## Single Page App +```ts +$.anchorPreventDefault = true; +$.anchorHandler = (url) => { router.open(url) } + +$('a').href('/about').content('Click me will not reload page.') +``` + +## Insert element(s) with condition +```ts +// Example 1 +$('div').content([ + $('h1').content(params.username), + // conditional + params.username === 'admin' ? $('span').content('Admin is here!') : undefined +]) + +// Example 2 +$('div').content([ + $('h1').content(params.username), + params.username === 'alien' ? [ + // the elements in this array will insert to
when conditional is true + $('span').content('Warning'), + $('span').content('You are contacting with alien!') + ] : undefined +]) +``` + +## Replace or Insert +```ts +$('div').content(['1', '2', '3']) // 123 + .content(['4']) // 4 + // content method will replace children with elements + + .insert(['5', '6', '7']) // 4567 + // using insert method to avoid replacement + + .class('class1, class2') // class1, class2 + // class method is replacement method + + .addClass('class3') // class1, class2, class3 + // using addClass method +``` + +## Multiple element builder +```ts +$('ul').content([ + // create 10
  • element with same content + $.builder('li', 10, ($li) => $li.content('Not a unique content of list item!')) + + // create
  • element depend on array length + $.builder('li', [ + // if insert a function, + // builder will callback this function after create this
  • element + ($li) => $li.css({color: 'red'}).content('List item with customize style!'), + + // if insert a string or element, + // builder will create
  • element and insert this into
  • + 'List item with just text', + $('a').href('/').content('List item but with a link!') + ]) +]) +``` + +## Element builder with function +```ts +// This is a template function that return a
    element +function UserCard(name: string, age: number) { + return $('div').content([ + $('h2').content(name), + $('span').content(`${bio} year old`) + ]) +} + +// A user data array +const userDataList = [ + { name: 'Amateras', age: 16 }, + { name: 'Tsukimi', age: 16}, + { name: 'Rei', age: 14}, + { name: 'Ichi', age: 14}, +] + +// This function will create 10 UserCard element with same name and age +// Using tuple [Function, ...args] to call function with paramerters +$.builder([UserCard, 'Shizuka', 16], 100) + +// This function will create UserCard with the amount depend on array length +$.builder( + UserCard, + userDataList.map(userData => [userData.name, userData.age])) + +// Same result as (prefer) +userDataList.map(userData => UserCard(userData.name, userData.age)) +``` \ No newline at end of file diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..bb6e9d8 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,21 @@ +import { $ as fluent } from "./$index"; +import { $Element } from "./lib/$Element"; + +declare global { + const $ = fluent; + type OrMatrix = T | OrMatrix[]; + type OrArray = T | T[]; + type OrPromise = T | Promise; + type Mutable = { + -readonly [k in keyof T]: T[k]; + }; + type Types = 'string' | 'number' | 'boolean' | 'object' | 'symbol' | 'bigint' | 'function' | 'undefined' + type Autocapitalize = 'none' | 'off' | 'sentences' | 'on' | 'words' | 'characters'; + type SelectionDirection = "forward" | "backward" | "none"; + type InputType = "button" | "checkbox" | "color" | "date" | "datetime-local" | "email" | "file" | "hidden" | "image" | "month" | "number" | "password" | "radio" | "range" | "reset" | "search" | "submit" | "tel" | "text" | "time" | "url" | "week"; + type TextDirection = 'ltr' | 'rtl' | 'auto' | ''; + + interface HTMLElement { + $: $Element; + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..9048085 --- /dev/null +++ b/index.ts @@ -0,0 +1,20 @@ +declare global { + interface Array { + detype(...types: F[]): Array> + } +} +Array.prototype.detype = function (this: O[], ...types: T[]) { + return this.filter(item => { + for (const type of types) typeof item !== typeof type + }) as Exclude[]; +} +export * from "./$index"; +export * from "./lib/Router/Route"; +export * from "./lib/Router/Router"; +export * from "./lib/$Node"; +export * from "./lib/$Anchor"; +export * from "./lib/$Element"; +export * from "./lib/$ElementManager"; +export * from "./lib/$Text"; +export * from "./lib/$Container"; +export * from "./lib/$EventManager"; \ No newline at end of file diff --git a/lib/$Anchor.ts b/lib/$Anchor.ts new file mode 100644 index 0000000..ac11d4d --- /dev/null +++ b/lib/$Anchor.ts @@ -0,0 +1,24 @@ +import { $Container, $ContainerOptions } from "./$Container"; + +export interface AnchorOptions extends $ContainerOptions {} + +export class $Anchor extends $Container { + constructor(options?: AnchorOptions) { + super('a', options); + // Link Handler event + this.dom.addEventListener('click', e => { + if ($.anchorPreventDefault) e.preventDefault(); + if ($.anchorHandler) $.anchorHandler(this.href(), e) + }) + } + /**Set URL of anchor element. */ + href(): URL; + href(url: string | undefined): this; + href(url?: string | undefined) { return $.fluent(this, arguments, () => new URL(this.dom.href), () => {if (url) this.dom.href = url}) } + /**Link open with this window, new tab or other */ + target(): string; + target(target: $AnchorTarget | undefined): this; + target(target?: $AnchorTarget | undefined) { return $.fluent(this, arguments, () => this.dom.target, () => {if (target) this.dom.target = target}) } +} + +export type $AnchorTarget = '_blank' | '_self' | '_parent' | '_top'; \ No newline at end of file diff --git a/lib/$Container.ts b/lib/$Container.ts new file mode 100644 index 0000000..c819df8 --- /dev/null +++ b/lib/$Container.ts @@ -0,0 +1,31 @@ +import { $Element, $ElementOptions } from "./$Element"; +import { $ElementManager } from "./$ElementManager"; +import { $Node } from "./$Node"; + +export interface $ContainerOptions extends $ElementOptions {} + +export class $Container extends $Element { + readonly children: $ElementManager = new $ElementManager(this); + constructor(tagname: string, options?: $ContainerOptions) { + super(tagname, options) + } + + /**Replace element to this element. + * @example Element.content([$('div')]) + * Element.content('Hello World')*/ + content(children: OrMatrix<$Node | string | undefined>): this { return $.fluent(this, arguments, () => this, () => { + this.children.removeAll(); + this.insert(children); + })} + + /**Insert element to this element */ + insert(children: OrMatrix<$Node | string | undefined>): this { return $.fluent(this, arguments, () => this, () => { + children = $.multableResolve(children); + for (const child of children) { + if (child === undefined) return; + if (child instanceof Array) this.insert(child) + else this.children.add(child); + } + this.children.render(); + })} +} diff --git a/lib/$Element.ts b/lib/$Element.ts new file mode 100644 index 0000000..840cc2c --- /dev/null +++ b/lib/$Element.ts @@ -0,0 +1,69 @@ +import { $Node } from "./$Node"; + +export interface $ElementOptions { + id?: string; + class?: string[]; +} + +export class $Element extends $Node { + readonly dom: H; + constructor(tagname: string, options?: $ElementOptions) { + super(); + this.dom = document.createElement(tagname) as H; + this.dom.$ = this; + this.options(options); + } + + options(options: $ElementOptions | undefined) { + this.id(options?.id) + if (options && options.class) this.class(...options.class) + return this; + } + + /**Replace id of element. @example Element.id('customId');*/ + id(): string; + id(name: string | undefined): this; + id(name?: string | undefined): this | string {return $.fluent(this, arguments, () => this.dom.id, () => {if (name) this.dom.id === name})} + + /**Replace list of class name to element. @example Element.class('name1', 'name2') */ + class(): DOMTokenList; + class(...name: (string | undefined)[]): this; + class(...name: (string | undefined)[]): this | DOMTokenList {return $.fluent(this, arguments, () => this.dom.classList, () => {this.dom.className = ''; this.dom.classList.add(...name.detype())})} + /**Add class name to dom. */ + addClass(...name: (string | undefined)[]): this {return $.fluent(this, arguments, () => this, () => {this.dom.classList.add(...name.detype())})} + /**Remove class name from dom */ + removeClass(...name: (string | undefined)[]): this {return $.fluent(this, arguments, () => this, () => {this.dom.classList.remove(...name.detype())})} + + /**Modify css of element. */ + css(): CSSStyleDeclaration + css(style: Partial): this; + css(style?: Partial) { return $.fluent(this, arguments, () => this.dom.style, () => {Object.assign(this.dom.style, style)})} + + /**Remove this element from parent */ + remove() { + this.parent?.children.remove(this); + (this as Mutable).parent = undefined; + this.dom.remove(); + return this; + } + + autocapitalize(): Autocapitalize; + autocapitalize(autocapitalize: Autocapitalize): this; + autocapitalize(autocapitalize?: Autocapitalize) { return $.fluent(this, arguments, () => this.dom.autocapitalize, () => $.set(this.dom, 'autocapitalize', autocapitalize))} + + dir(): TextDirection; + dir(dir: TextDirection): this; + dir(dir?: TextDirection) { return $.fluent(this, arguments, () => this.dom.dir, () => $.set(this.dom, 'dir', dir))} + + innerText(): string; + innerText(text: string): this; + innerText(text?: string) { return $.fluent(this, arguments, () => this.dom.innerText, () => $.set(this.dom, 'innerText', text))} + + title(): string; + title(title: string): this; + title(title?: string) { return $.fluent(this, arguments, () => this.dom.title, () => $.set(this.dom, 'title', title))} + + translate(): boolean; + translate(translate: boolean): this; + translate(translate?: boolean) { return $.fluent(this, arguments, () => this.dom.translate, () => $.set(this.dom, 'translate', translate))} +} \ No newline at end of file diff --git a/lib/$ElementManager.ts b/lib/$ElementManager.ts new file mode 100644 index 0000000..4f875db --- /dev/null +++ b/lib/$ElementManager.ts @@ -0,0 +1,45 @@ +import { $Container } from "./$Container"; +import { $Element } from "./$Element"; +import { $Node } from "./$Node"; +import { $Text } from "./$Text"; + +export class $ElementManager { + #container: $Container; + #dom: HTMLElement; + elementList = new Set<$Node> + constructor(container: $Container) { + this.#container = container; + this.#dom = this.#container.dom + } + + add(element: $Node | string) { + if (typeof element === 'string') { + const text = new $Text(element); + this.elementList.add(text); + } else { + this.elementList.add(element); + } + } + + remove(element: $Node) { + this.elementList.delete(element); + } + + removeAll() { + this.elementList.clear(); + } + + render() { + const [domList, nodeList] = [this.array.map(node => node.dom), Array.from(this.#dom.childNodes)]; + // Rearrange + while (nodeList.length || domList.length) { + const [node, dom] = [nodeList.at(0), domList.at(0)]; + if (!dom) { node?.remove(); nodeList.shift()} + else if (!node) { this.#dom.append(dom); domList.shift();} + else if (dom !== node) { this.#dom.insertBefore(dom, node); domList.shift();} + else {domList.shift(); nodeList.shift();} + } + } + + get array() {return [...this.elementList.values()]}; +} \ No newline at end of file diff --git a/lib/$EventManager.ts b/lib/$EventManager.ts new file mode 100644 index 0000000..54c6bca --- /dev/null +++ b/lib/$EventManager.ts @@ -0,0 +1,66 @@ +export function EventMethod(target: T) {return $.mixin(target, $EventMethod)} +export abstract class $EventMethod { + abstract events: $EventManager; + //@ts-expect-error + on(type: K, callback: (...args: EM[K]) => void) { this.events.on(type, callback); return this } + //@ts-expect-error + off(type: K, callback: (...args: EM[K]) => void) { this.events.off(type, callback); return this } + //@ts-expect-error + once(type: K, callback: (...args: EM[K]) => void) { this.events.once(type, callback); return this } +} +export class $EventManager { + private eventMap = new Map(); + register(...names: string[]) { + names.forEach(name => { + const event = new $Event(name); + this.eventMap.set(event.name, event); + }) + return this; + } + delete(name: string) { this.eventMap.delete(name); return this } + //@ts-expect-error + fire(type: K, ...args: EM[K]) { + const event = this.get(type) + //@ts-expect-error + if (event instanceof $Event) event.fire(...args); + return this + } + //@ts-expect-error + on(type: K, callback: (...args: EM[K]) => void) { + this.get(type).add(callback); + return this + } + //@ts-expect-error + off(type: K, callback: (...args: EM[K]) => void) { + this.get(type).delete(callback); + return this + } + //@ts-expect-error + once(type: K, callback: (...args: EM[K]) => void) { + //@ts-expect-error + const onceFn = (...args: EM[K]) => { + this.get(type).delete(onceFn); + //@ts-expect-error + callback(...args); + } + this.get(type).add(onceFn); + return this; + } + + get(type: K) { + //@ts-expect-error + const event = this.eventMap.get(type); + if (!event) throw new Error('EVENT NOT EXIST') + return event; + } +} +export class $Event { + name: string; + private callbackList = new Set() + constructor(name: string) { + this.name = name; + } + fire(...args: any[]) { this.callbackList.forEach(callback => callback(...args)) } + add(callback: Function) { this.callbackList.add(callback) } + delete(callback: Function) { this.callbackList.delete(callback) } +} \ No newline at end of file diff --git a/lib/$Input.ts b/lib/$Input.ts new file mode 100644 index 0000000..49880a4 --- /dev/null +++ b/lib/$Input.ts @@ -0,0 +1,181 @@ +import { $Element, $ElementOptions } from "./$Element"; + +export interface $InputOptions extends $ElementOptions {} +export class $Input extends $Element { + constructor(options: $InputOptions) { + super('input', options); + } + + + accept(): string[] + accept(...filetype: string[]): this + accept(...filetype: string[]) { return $.fluent(this, arguments, () => this.dom.accept.split(','), () => this.dom.accept = filetype.toString() )} + + capture(): string; + capture(capture: string): this; + capture(capture?: string) { return $.fluent(this, arguments, () => this.dom.capture, () => $.set(this.dom, 'capture', capture))} + + alt(): string; + alt(alt: string): this; + alt(alt?: string) { return $.fluent(this, arguments, () => this.dom.alt, () => $.set(this.dom, 'alt', alt))} + + height(): number; + height(height: number): this; + height(height?: number) { return $.fluent(this, arguments, () => this.dom.height, () => $.set(this.dom, 'height', height))} + + width(): number; + width(wdith: number): this; + width(width?: number) { return $.fluent(this, arguments, () => this.dom.width, () => $.set(this.dom, 'width', width))} + + formAction(): string; + formAction(action: string): this; + formAction(action?: string) { return $.fluent(this, arguments, () => this.dom.formAction, () => $.set(this.dom, 'formAction', action))} + + formEnctype(): string; + formEnctype(enctype: string): this; + formEnctype(enctype?: string) { return $.fluent(this, arguments, () => this.dom.formEnctype, () => $.set(this.dom, 'formEnctype', enctype))} + + formMethod(): string; + formMethod(method: string): this; + formMethod(method?: string) { return $.fluent(this, arguments, () => this.dom.formMethod, () => $.set(this.dom, 'formMethod', method))} + + formNoValidate(): boolean; + formNoValidate(boolean: boolean): this; + formNoValidate(boolean?: boolean) { return $.fluent(this, arguments, () => this.dom.formNoValidate, () => $.set(this.dom, 'formNoValidate', boolean))} + + formTarget(): string; + formTarget(target: string): this; + formTarget(target?: string) { return $.fluent(this, arguments, () => this.dom.formTarget, () => $.set(this.dom, 'formTarget', target))} + + + checked(): boolean; + checked(boolean: boolean): this; + checked(boolean?: boolean) { return $.fluent(this, arguments, () => this.dom.checked, () => $.set(this.dom, 'checked', boolean))} + + max(): string; + max(max: string): this; + max(max?: string) { return $.fluent(this, arguments, () => this.dom.max, () => $.set(this.dom, 'max', max))} + + min(): string; + min(min: string): this; + min(min?: string) { return $.fluent(this, arguments, () => this.dom.min, () => $.set(this.dom, 'min', min))} + + maxLength(): number; + maxLength(maxLength: number): this; + maxLength(maxLength?: number) { return $.fluent(this, arguments, () => this.dom.maxLength, () => $.set(this.dom, 'maxLength', maxLength))} + + minLength(): number; + minLength(minLength: number): this; + minLength(minLength?: number) { return $.fluent(this, arguments, () => this.dom.minLength, () => $.set(this.dom, 'minLength', minLength))} + + autocomplete(): AutoFill; + autocomplete(autocomplete: AutoFill): this; + autocomplete(autocomplete?: AutoFill) { return $.fluent(this, arguments, () => this.dom.autocomplete, () => $.set(this.dom, 'autocomplete', autocomplete))} + + defaultValue(): string; + defaultValue(defaultValue: string): this; + defaultValue(defaultValue?: string) { return $.fluent(this, arguments, () => this.dom.defaultValue, () => $.set(this.dom, 'defaultValue', defaultValue))} + + defaultChecked(): boolean; + defaultChecked(defaultChecked: boolean): this; + defaultChecked(defaultChecked?: boolean) { return $.fluent(this, arguments, () => this.dom.defaultChecked, () => $.set(this.dom, 'defaultChecked', defaultChecked))} + + dirName(): string; + dirName(dirName: string): this; + dirName(dirName?: string) { return $.fluent(this, arguments, () => this.dom.dirName, () => $.set(this.dom, 'dirName', dirName))} + + disabled(): boolean; + disabled(disabled: boolean): this; + disabled(disabled?: boolean) { return $.fluent(this, arguments, () => this.dom.disabled, () => $.set(this.dom, 'disabled', disabled))} + + multiple(): boolean; + multiple(multiple: boolean): this; + multiple(multiple?: boolean) { return $.fluent(this, arguments, () => this.dom.multiple, () => $.set(this.dom, 'multiple', multiple))} + + name(): string; + name(name: string): this; + name(name?: string) { return $.fluent(this, arguments, () => this.dom.name, () => $.set(this.dom, 'name', name))} + + pattern(): string; + pattern(pattern: string): this; + pattern(pattern?: string) { return $.fluent(this, arguments, () => this.dom.pattern, () => $.set(this.dom, 'pattern', pattern))} + + placeholder(): string; + placeholder(placeholder: string): this; + placeholder(placeholder?: string) { return $.fluent(this, arguments, () => this.dom.placeholder, () => $.set(this.dom, 'placeholder', placeholder))} + + readOnly(): boolean; + readOnly(readOnly: boolean): this; + readOnly(readOnly?: boolean) { return $.fluent(this, arguments, () => this.dom.readOnly, () => $.set(this.dom, 'readOnly', readOnly))} + + required(): boolean; + required(required: boolean): this; + required(required?: boolean) { return $.fluent(this, arguments, () => this.dom.required, () => $.set(this.dom, 'required', required))} + + selectionDirection(): SelectionDirection | null; + selectionDirection(selectionDirection: SelectionDirection | null): this; + selectionDirection(selectionDirection?: SelectionDirection | null) { return $.fluent(this, arguments, () => this.dom.selectionDirection, () => $.set(this.dom, 'selectionDirection', selectionDirection))} + + selectionEnd(): number | null; + selectionEnd(selectionEnd: number | null): this; + selectionEnd(selectionEnd?: number | null) { return $.fluent(this, arguments, () => this.dom.selectionEnd, () => $.set(this.dom, 'selectionEnd', selectionEnd))} + + selectionStart(): number | null; + selectionStart(selectionStart: number | null): this; + selectionStart(selectionStart?: number | null) { return $.fluent(this, arguments, () => this.dom.selectionStart, () => $.set(this.dom, 'selectionStart', selectionStart))} + + size(): number; + size(size: number): this; + size(size?: number) { return $.fluent(this, arguments, () => this.dom.size, () => $.set(this.dom, 'size', size))} + + src(): string; + src(src: string): this; + src(src?: string) { return $.fluent(this, arguments, () => this.dom.src, () => $.set(this.dom, 'src', src))} + + step(): string; + step(step: string): this; + step(step?: string) { return $.fluent(this, arguments, () => this.dom.step, () => $.set(this.dom, 'step', step))} + + type(): InputType; + type(type: InputType): this; + type(type?: InputType) { return $.fluent(this, arguments, () => this.dom.type, () => $.set(this.dom, 'type', type))} + + value(): string; + value(value: string): this; + value(value?: string) { return $.fluent(this, arguments, () => this.dom.value, () => $.set(this.dom, 'value', value))} + + valueAsDate(): Date | null; + valueAsDate(date: Date | null): this; + valueAsDate(date?: Date | null) { return $.fluent(this, arguments, () => this.dom.valueAsDate, () => $.set(this.dom, 'valueAsDate', date))} + + valueAsNumber(): number; + valueAsNumber(number: number): this; + valueAsNumber(number?: number) { return $.fluent(this, arguments, () => this.dom.valueAsNumber, () => $.set(this.dom, 'valueAsNumber', number))} + + webkitdirectory(): boolean; + webkitdirectory(boolean: boolean): this; + webkitdirectory(boolean?: boolean) { return $.fluent(this, arguments, () => this.dom.webkitdirectory, () => $.set(this.dom, 'webkitdirectory', boolean))} + + select() { this.dom.select(); return this } + setCustomValidity(error: string) { this.dom.setCustomValidity(error); return this } + setRangeText(replacement: string): this; + setRangeText(replacement: string, start: number, end: number, selectionMode?: SelectionMode): this; + setRangeText(replacement: string, start?: number, end?: number, selectionMode?: SelectionMode) { + if (typeof start === 'number' && typeof end === 'number') this.dom.setRangeText(replacement, start, end, selectionMode) + this.dom.setRangeText(replacement); + return this + } + setSelectionRange(start: number | null, end: number | null, direction?: SelectionDirection) { this.dom.setSelectionRange(start, end, direction); return this } + showPicker() { this.dom.showPicker(); return this } + stepDown() { this.dom.stepDown(); return this } + stepUp() { this.dom.stepUp(); return this } + + checkValidity() { return this.dom.checkValidity() } + reportValidity() { return this.dom.reportValidity() } + get files() { return this.dom.files } + get labels() { return Array.from(this.dom.labels ?? []).map(label => $(label)) } + get validationMessage() { return this.dom.validationMessage } + get validity() { return this.dom.validity } + get webkitEntries() { return this.dom.webkitEntries } + get willValidate() { return this.dom.willValidate } +} \ No newline at end of file diff --git a/lib/$Label.ts b/lib/$Label.ts new file mode 100644 index 0000000..9e249d6 --- /dev/null +++ b/lib/$Label.ts @@ -0,0 +1,14 @@ +import { $Container, $ContainerOptions } from "./$Container"; +export interface $LabelOptions extends $ContainerOptions {} +export class $Label extends $Container { + constructor(options: $LabelOptions) { + super('label', options); + } + + for(): string; + for(name: string): this; + for(name?: string | undefined) { return $.fluent(this, arguments, () => this.dom.htmlFor, () => {if (name) this.dom.htmlFor = name}) } + + get form() { return this.dom.form } + get control() { return this.dom.control } +} \ No newline at end of file diff --git a/lib/$Node.ts b/lib/$Node.ts new file mode 100644 index 0000000..471e815 --- /dev/null +++ b/lib/$Node.ts @@ -0,0 +1,25 @@ +import { $Container } from "./$Container"; + +export abstract class $Node { + readonly parent?: $Container; + abstract readonly dom: N; + constructor() { + + } + + on(type: K, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean) { + this.dom.addEventListener(type, callback, options) + } + + off(type: K, callback: EventListenerOrEventListenerObject | null, options?: AddEventListenerOptions | boolean) { + this.dom.removeEventListener(type, callback, options) + } + + once(type: K, callback: (event: Event) => void, options?: AddEventListenerOptions | boolean) { + const onceFn = (event: Event) => { + this.dom.removeEventListener(type, onceFn, options) + callback(event); + }; + this.dom.addEventListener(type, onceFn, options) + } +} \ No newline at end of file diff --git a/lib/$Text.ts b/lib/$Text.ts new file mode 100644 index 0000000..ca93696 --- /dev/null +++ b/lib/$Text.ts @@ -0,0 +1,9 @@ +import { $Node } from "./$Node"; + +export class $Text extends $Node { + dom: Text; + constructor(data: string) { + super(); + this.dom = new Text(data); + } +} \ No newline at end of file diff --git a/lib/Router/Route.ts b/lib/Router/Route.ts new file mode 100644 index 0000000..e7c395f --- /dev/null +++ b/lib/Router/Route.ts @@ -0,0 +1,17 @@ +import { $Node } from "../$Node"; + +export class Route { + path: string; + builder: (path: PathParams) => string | $Node; + constructor(path: Path, builder: (params: PathParams) => $Node | string) { + if (!path.startsWith('/')) throw new Error('PATH SHOULD START WITH /') + this.path = path; + this.builder = builder; + } +} + +type PathParams = Path extends `${infer Segment}/${infer Rest}` + ? Segment extends `:${infer Param}` ? Record & PathParams : PathParams + : Path extends `:${infer Param}` ? Record : {} + +type A = PathParams<'/:userId/post/:postId'> \ No newline at end of file diff --git a/lib/Router/Router.ts b/lib/Router/Router.ts new file mode 100644 index 0000000..8a44bfe --- /dev/null +++ b/lib/Router/Router.ts @@ -0,0 +1,119 @@ +import { $Container } from "../$Container"; +import { $EventManager, $EventMethod, EventMethod } from "../$EventManager"; +import { $Node } from "../$Node"; +import { $Text } from "../$Text"; +import { Route } from "./Route"; +export interface Router extends $EventMethod {}; +@EventMethod +export class Router { + routeMap = new Map>(); + contentMap = new Map(); + view: $Container; + index: number = 0; + events = new $EventManager().register('pathchange', 'notfound'); + basePath: string; + constructor(basePath: string, view: $Container) { + this.basePath = basePath; + this.view = view + $.routers.add(this); + } + + /**Add route to Router. @example Router.addRoute(new Route('/', 'Hello World')) */ + addRoute(routes: OrArray>) { + routes = $.multableResolve(routes); + for (const route of routes) this.routeMap.set(route.path, route); + return this; + } + + /**Start listen to the path change */ + listen() { + if (!history.state || 'index' in history.state === false) { + const routeData: RouteData = {index: this.index} + history.replaceState(routeData, '') + } else { + this.index = history.state.index + } + addEventListener('popstate', this.popstate) + this.resolvePath(); + return this; + } + + /**Open path */ + open(path: string | URL) { + if (path instanceof URL) path = path.pathname; + if (path === location.pathname) return this; + this.index += 1; + const routeData: RouteData = { index: this.index }; + history.pushState(routeData, '', path); + $.routers.forEach(router => router.resolvePath()) + this.events.fire('pathchange', path, 'Forward'); + return this; + } + + /**Back to previous page */ + back() { history.back(); } + + private popstate = (() => { + // Forward + if (history.state.index > this.index) { } + // Back + else if (history.state.index < this.index) { } + this.index = history.state.index; + this.resolvePath(); + this.events.fire('pathchange', location.pathname, 'Forward'); + }).bind(this) + + private resolvePath(path = location.pathname) { + if (!path.startsWith(this.basePath)) return; + path = path.replace(this.basePath, '/').replace('//', '/') + let found = false; + const openCached = () => { + const cacheContent = this.contentMap.get(path); + if (cacheContent) { + this.view.content(cacheContent); + found = true; + return true; + } + return false; + } + const create = (content: $Node | string) => { + if (typeof content === 'string') content = new $Text(content); + this.contentMap.set(path, content) + this.view.content(content); + found = true; + } + for (const route of this.routeMap.values()) { + const [_routeParts, _pathParts] = [route.path.split('/').map(p => `/${p}`), path.split('/').map(p => `/${p}`)]; + _routeParts.shift(); _pathParts.shift(); + const data = {}; + for (let i = 0; i < _pathParts.length; i++) { + const [routePart, pathPart] = [_routeParts.at(i), _pathParts.at(i)]; + if (!routePart || !pathPart) continue; + if (routePart === pathPart) { + if (routePart === _routeParts.at(-1)) { + if (!openCached()) create(route.builder(data)); + return; + } + } + else if (routePart.includes(':')) { + Object.assign(data, {[routePart.split(':')[1]]: pathPart.replace('/', '')}) + if (routePart === _routeParts.at(-1)) { + if (!openCached()) create(route.builder(data)); + return; + } + } + } + } + + if (!found) this.events.fire('notfound', path); + } +} +interface RouterEventMap { + pathchange: [path: string, navigation: 'Back' | 'Forward']; + notfound: [path: string] +} + +type RouteData = { + index: number; + data?: any; +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..bbc1e53 --- /dev/null +++ b/package.json @@ -0,0 +1,23 @@ +{ + "name": "fluent.ts", + "description": "Front-end builder library", + "version": "0.0.1", + "type": "module", + "module": "index.ts", + "main": "index.ts", + "author": { + "name": "defaultkavy", + "email": "defaultkavy@gmail.com", + "url": "https://github.com/defaultkavy" + }, + "keywords": ["web", "front-end", "lib", "fluent", "framework"], + "homepage": "https://github.com/defaultkavy/fluent", + "repository": { + "type": "git", + "url": "git+https://github.com/defaultkavy/fluent.git" + }, + "bugs": { + "url": "https://github.com/defaultkavy/fluent/issues" + }, + "license": "ISC" +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c6d7f6f --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "lib": ["dom", "ES2022"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "bundler", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "experimentalDecorators": true + }, + } + \ No newline at end of file