x4js
Version:
1,067 lines (822 loc) • 20.9 kB
text/typescript
/**
* ___ ___ __
* \ \/ / / _
* \ / /_| |_
* / \____ _|
* /__/\__\ |_|
*
* @file component.ts
* @author Etienne Cochard
*
* @copyright (c) 2024 R-libre ingenierie
*
* Use of this source code is governed by an MIT-style license
* that can be found in the LICENSE file or at https://opensource.org/licenses/MIT.
**/
import { isArray, UnsafeHtml, isNumber, Rect, Constructor, class_ns, x4_class_ns_sym, IRect } from './core_tools';
import { CoreElement } from './core_element';
import { ariaValues, unitless } from './core_styles';
import { CoreEvent, EventMap } from './core_events';
import { addEvent, DOMEventHandler, GlobalDOMEvents } from './core_dom';
interface RefType<T extends Component> {
dom: T;
}
type ComponentAttributes = Record<string,string|number|boolean>;
type CreateComponentCallBack = ( attrs: Record<string,string> ) => ComponentContent;
const FRAGMENT = Symbol( "fragment" );
const COMPONENT = Symbol( "component" );
const RE_NUMBER = /^-?\d+(\.\d*)?$/;
/**
* you can change css classname prefix by adding
*
* ```
* static "$cls-ns" = "<your prefix>";
* ```
*
* to your class to avoid autogenerated css class names conflicts
*/
function genClassNames( x: any ): string[] {
const classes = [];
let self = Object.getPrototypeOf(x);
if( self.constructor==Component ) {
return ["x4-comp"];
}
while (self && self.constructor !== Component ) {
const clsname:string = self.constructor.name;
const clsns: string = Object.prototype.hasOwnProperty.call(self.constructor,x4_class_ns_sym) ? self.constructor[x4_class_ns_sym] : "";
classes.push( clsns+clsname.toLowerCase() );
self = Object.getPrototypeOf(self);
}
return classes;
}
/**
*
*/
export type ComponentContent = Component | Component[] | string | string[] | UnsafeHtml| UnsafeHtml[] | number | boolean;
let gen_id = 1000;
export const makeUniqueComponentId = ( ) => {
return `x4-${gen_id++}`;
}
/**
*
*/
export interface ComponentProps {
tag?: string;
ns?: string;
style?: Partial<CSSStyleDeclaration>;
attrs?: Record<string,string|number|boolean>;
content?: ComponentContent;
dom_events?: GlobalDOMEvents;
cls?: string;
id?: string;
ref?: RefType<any>;
// shortcuts
width?: string | number;
height?: string | number;
disabled?: true,
hidden?: true,
flex?: boolean | number;
tooltip?: string;
// wrapper
existingDOM?: HTMLElement;
// index signature
// to avoid errors: Type 'X' has no properties in common with type 'Y'
// because all memebers here are optional.
// this allow TS to recongnize derived props as ComponentProps
//[key: string]: any;
};
/**
*
*/
export interface ComponentEvent extends CoreEvent {
}
/**
*
*/
export interface ComponentEvents extends EventMap {
}
/**
*
*/
export class Component<P extends ComponentProps = ComponentProps, E extends ComponentEvents = ComponentEvents>
extends CoreElement<E> {
readonly dom: Element;
readonly props: P;
protected readonly clsprefix: string; // internal class name prefix (x4 internal)
#store: Map<string|symbol,any>;
constructor( props: P ) {
super( );
this.props = props; // copy ?
if( props.existingDOM ) {
this.dom = props.existingDOM;
}
else {
if( props.ns ) {
this.dom = document.createElementNS( props.ns, props.tag ?? "div" );
}
else {
this.dom = document.createElement( props.tag ?? "div" );
}
if (props.attrs) {
this.setAttributes( props.attrs );
}
if( props.cls ) {
this.addClass( props.cls );
}
if( props.hidden ) {
this.show( false );
}
if( props.flex===true ) {
this.addClass( "x4flex" );
}
else if( props.flex!==undefined ) {
this.setStyle( {
"flexGrow": props.flex+""
});
}
if( props.id!==undefined ) {
this.setAttribute( "id", props.id );
}
// small shortcut
if( props.width!==undefined ) {
this.setStyleValue( "width", props.width );
}
if( props.height!==undefined ) {
this.setStyleValue( "height", props.height );
}
if( props.tooltip ) {
this.setAttribute( "tooltip", props.tooltip );
}
if( props.style ) {
this.setStyle( props.style );
}
if( props.content ) {
this.setContent( props.content );
}
if( props.dom_events ) {
this.setDOMEvents( props.dom_events );
}
const classes = genClassNames( this );
this.dom.classList.add( ...classes );
// need to have children for next statements
// and children way be created in caller
if( props.disabled ) {
this.addDOMEvent( "created", ( ) => {
this.enable( false );
} );
}
}
(this.dom as any)[COMPONENT] = this;
}
// :: CLASSES ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
*
*/
hasClass( cls: string ) {
return this.dom.classList.contains( cls );
}
/**
*
*/
addClass( cls: string ) {
if( !cls ) return;
if( cls.indexOf(' ')>=0 ) {
cls = cls.trim( );
const ccs = cls.split( " " );
this.dom.classList.add(...ccs);
}
else {
this.dom.classList.add(cls);
}
}
/**
* special case: '*' mean clear class list
*/
removeClass( cls: string ) {
if( !cls ) return;
if( cls=='*' ) {
this.dom.classList.value = "";
return;
}
if( cls.indexOf(' ')>=0 ) {
const ccs = cls.split( " " );
this.dom.classList.remove(...ccs);
}
else {
this.dom.classList.remove(cls);
}
}
/**
*
*/
removeClassEx( re: RegExp ) {
const all = Array.from( this.dom.classList );
all.forEach( x => {
if( x.match(re) ) {
this.dom.classList.remove( x );
}
});
}
/**
*
*/
toggleClass( cls: string ) {
if( !cls ) return;
const toggle = ( x: string ) => {
this.dom.classList.toggle(x);
}
if( cls.indexOf(' ')>=0 ) {
const ccs = cls.split( " " );
ccs.forEach( toggle );
}
else {
toggle( cls );
}
}
/**
*
*/
setClass( cls: string, set: boolean = true ) : this {
if( set ) this.addClass(cls);
else this.removeClass( cls );
return this;
}
// :: ATTRIBUTES ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* attributes
*/
setAttributes( attrs: ComponentAttributes ): this {
for( const name in attrs ) {
this.setAttribute( name, attrs[name] );
}
return this;
}
/**
*
*/
setAttribute( name: string, value: string | number | boolean ) {
if( value===null || value===undefined ) {
this.dom.removeAttribute( name );
}
else {
this.dom.setAttribute( name, ""+value );
}
}
/**
*
*/
getAttribute( name: string ): string {
return this.dom.getAttribute( name );
}
/**
*
*/
getData( name: string ) : string {
return this.getAttribute( "data-"+name );
}
/**
* @returns undefined if not a number
*/
getIntData( name: string ) : number {
const v = parseInt( this.getAttribute( "data-"+name ) );
if( Number.isFinite(v) ) {
return v;
}
return undefined;
}
/**
*
*/
setData( name: string, value: string ) {
return this.setAttribute( "data-"+name, value );
}
/**
* idem as setData but onot on dom, you can store anything
*/
setInternalData( name: string|symbol, value: any ): this {
if( !this.#store ) {
this.#store = new Map( );
}
this.#store.set( name, value );
return this;
}
getInternalData( name: string|symbol ): any {
return this.#store?.get(name);
}
// :: DOM EVENTS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
*
*/
addDOMEvent<K extends keyof GlobalDOMEvents>( name: K, listener: GlobalDOMEvents[K], prepend = false ) {
addEvent( this.dom, name, listener as DOMEventHandler, prepend );
}
/**
*
*/
setDOMEvents( events: GlobalDOMEvents ) {
for( const name in events ) {
this.addDOMEvent( name as any, (events as any)[name] );
}
}
// :: HILEVEL EVENTS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* tool to move named events to internal event map
* @internal
*/
protected mapPropEvents<N extends keyof E>(props: P, ...elements: N[] ) {
const p = props as any;
elements.forEach( n => {
if (Object.prototype.hasOwnProperty.call(p,n) && p[n]) {
this.on( n, p[n] );
}
});
}
// :: CONTENT ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* remove all content from component
*/
clearContent( ) {
const d = this.dom;
while( d.firstChild ) {
d.removeChild( d.firstChild );
}
}
/**
* change the whole content of the component
* clear the content before
* @param content new content
*/
setContent( content: ComponentContent ) {
this.clearContent( );
this.appendContent( content );
}
/**
* cf. appendContent
* @param content content to append
*/
appendContent( content: ComponentContent ) {
const set = ( d: any, c: Component | string | UnsafeHtml | number | boolean ) => {
if (c instanceof Component ) {
d.appendChild( c.dom );
}
else if( c instanceof UnsafeHtml) {
d.insertAdjacentHTML( 'beforeend' , c.toString() );
}
else if (typeof c === "string" || typeof c === "number") {
const tnode = document.createTextNode(c.toString());
d.appendChild( tnode );
}
else if( c ) {
console.warn("Unknown type to append: ", c);
}
}
if( !isArray(content) ) {
set( this.dom, content );
}
else if( content.length<=8 ) {
for( const c of content ) {
set( this.dom, c );
}
}
else {
const fragment = document.createDocumentFragment( );
for (const child of content ) {
set( fragment, child );
}
this.dom.appendChild( fragment );
}
}
/**
* cf. appendContent
* @param content content to append
*/
prependContent( content: ComponentContent ) {
const d = this.dom;
const set = ( c: Component | string | UnsafeHtml | number | boolean ) => {
if (c instanceof Component ) {
d.insertAdjacentElement( 'afterbegin', c.dom );
}
else if( c instanceof UnsafeHtml) {
d.insertAdjacentHTML( 'afterbegin', c.toString() );
}
else if (typeof c === "string" || typeof c === "number") {
d.insertAdjacentText( 'afterbegin', c.toString() );
}
else {
console.warn("Unknown type to append: ", c);
}
}
if( !isArray(content) ) {
set( content );
}
else {
const fragment = document.createDocumentFragment( );
for (const child of content ) {
set( child );
}
d.insertBefore( d.firstChild, fragment );
}
}
/**
* remove a single child
* @see clearContent
*/
removeChild( child: Component ) {
this.dom.removeChild( child.dom );
}
/**
* query all elements by selector
*/
queryAll( selector: string ): Component[] {
const all = this.dom.querySelectorAll( selector );
const rc = new Array( all.length );
all.forEach( (x,i) => rc[i]=wrapDOM(x as HTMLElement) );
return rc;
}
/**
*
*/
query<T extends Component = Component>( selector: string ): T {
const r = this.dom.querySelector( selector );
return componentFromDOM<T>(r);
}
// :: STYLES ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
*
*/
setAria( name: keyof ariaValues, value: string | number | boolean ): this {
this.setAttribute( name, value );
return this;
}
/**
*
*/
setStyle( style: Partial<CSSStyleDeclaration> ): this {
const _style = (this.dom as HTMLElement).style;
for( const name in style ) {
let value = style[name];
if( !unitless[name] && (isNumber(value) || RE_NUMBER.test(value)) ) {
value += "px";
}
_style[name] = value;
}
return this;
}
/**
*
*/
setStyleValue<K extends keyof CSSStyleDeclaration>( name: K, value: CSSStyleDeclaration[K] | number ): this {
const _style = (this.dom as HTMLElement).style;
if( isNumber(value) ) {
let v = value+"";
if( !unitless[name as string] ) {
v += "px";
}
(_style as any)[name] = v;
}
else {
_style[name] = value;
}
return this;
}
/**
*
* @param name
* @returns
*/
getStyleValue<K extends keyof CSSStyleDeclaration>( name: K ) {
const _style = (this.dom as HTMLElement).style;
return _style[name];
}
setWidth( w: number | string ) {
this.setStyleValue( "width", isNumber(w) ? w+"px" : w );
}
setHeight( h: number | string ) {
this.setStyleValue( "height", isNumber(h) ? h+"px" : h );
}
/**
*
*/
setStyleVariable( name: string, value: string ) {
(this.dom as HTMLElement).style.setProperty( name, value );
}
/**
*
*/
getStyleVariable( name: string ) {
const style = this.getComputedStyle( );
return style.getPropertyValue( name );
}
/**
*
* @returns
*/
getComputedStyle( ) {
return getComputedStyle( this.dom );
}
/**
*
*/
setCapture( pointerId: number ) {
this.dom.setPointerCapture( pointerId );
}
/**
*
*/
releaseCapture( pointerId: number ) {
this.dom.releasePointerCapture( pointerId );
}
/**
*
*/
getBoundingRect( ): Rect {
const rc = this.dom.getBoundingClientRect( );
return new Rect( rc.x, rc.y, rc.width, rc.height );
}
// :: MISC ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
*
*/
focus( ): this {
(this.dom as HTMLElement).focus( );
return this;
}
hasFocus( ) {
return document.activeElement==this.dom;
}
/**
*
*/
scrollIntoView(arg?: boolean | ScrollIntoViewOptions) {
this.dom.scrollIntoView(arg);
}
/**
*
*/
isVisible( ) {
return (this.dom as HTMLElement).offsetParent !== null;
}
/**
*
*/
show( vis = true ): this {
this.setClass( 'x4hidden', !vis );
return this;
}
/**
*
*/
hide( ): this {
this.show( false );
return this;
}
/**
* enable or disable a component (all sub HTMLElement will be also disabled)
*/
enable( ena = true ): this {
this.setAttribute( "disabled", !ena ? 'true' : null );
if( this.dom instanceof HTMLInputElement || this.dom instanceof HTMLButtonElement ) {
this.dom.disabled = !ena;
}
// propagate diable state to all input children
const nodes = this.enumChildNodes( true );
nodes.forEach( x => {
if( x instanceof HTMLInputElement || x instanceof HTMLButtonElement ) {
x.disabled = !ena;
}
});
return this;
}
/**
*
*/
disable( ): this {
this.enable( false );
return this;
}
/**
* check if element is marked disabled
*/
isDisabled( ) {
return this.getAttribute('disabled');
}
/**
*
*/
nextElement<T extends Component = Component>( ): T {
const nxt = this.dom.nextElementSibling;
return componentFromDOM<T>( nxt );
}
/**
*
* @returns
*/
prevElement<T extends Component = Component>( ): T {
const nxt = this.dom.previousElementSibling;
return componentFromDOM<T>( nxt );
}
/**
* search for parent that match the given contructor
*/
parentElement<T extends Component>( cls?: Constructor<T> ): T {
return Component.parentElement<T>( this.dom, cls );
}
/**
* search for parent that match the given contructor
*/
static parentElement<T extends Component>( dom: Node, cls?: Constructor<T> ): T {
while( dom.parentElement ) {
const cp = componentFromDOM( dom.parentElement );
if( !cls ) {
return cp as T;
}
if( cp && cp instanceof cls ) {
return cp;
}
dom = dom.parentElement;
}
return null;
}
/**
*
* @returns
*/
firstChild<T extends Component = Component>( ) : T {
const nxt = this.dom.firstElementChild;
return componentFromDOM<T>( nxt );
}
/**
*
* @returns
*/
lastChild<T extends Component = Component>( ) : T {
const nxt = this.dom.lastElementChild;
return componentFromDOM( nxt );
}
/**
* renvoie la liste des Composants enfants
*/
enumChildComponents( recursive: boolean ) {
const children: Component[] = [];
const nodes = this.enumChildNodes( recursive );
nodes.forEach( ( c: Node ) => {
const cc = componentFromDOM( c as HTMLElement );
if( cc ) {
children.push(cc);
}
} );
return children;
}
/**
* return children list of node (not all should be components)
*/
enumChildNodes( recursive: boolean ) {
const children: Node[] = Array.from( recursive ? this.dom.querySelectorAll( '*' ) : this.dom.children );
return children;
}
/**
*
*/
animate( keyframes: Keyframe[], duration: number ) {
this.dom.animate(keyframes,duration);
}
// :: TSX/REACT ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* called by the compiler when a jsx element is seen
*/
static createElement( clsOrTag: string | ComponentConstructor | symbol | CreateComponentCallBack, attrs: any, ...children: Component[] ): Component | Component[] {
let comp: Component;
// fragment
if( clsOrTag==this.createFragment || clsOrTag===FRAGMENT ) {
return children;
}
// class constructor, yes : dirty
if( clsOrTag instanceof Function ) {
attrs = attrs ?? {};
if( !attrs.children && children && children.length ) {
attrs.content = children;
}
comp = new (clsOrTag as any)( attrs ?? {} );
}
// basic tag
else {
comp = new Component( {
tag: clsOrTag,
content: children,
...attrs,
});
}
if( children && children.length ) {
//comp.setContent( children );
}
return comp;
}
/**
*
*/
static createFragment( ): Component[] {
return this.createElement( FRAGMENT, null ) as Component[];
}
// :: SPECIALS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* system interfaces:
* "form-element"
* "tab-handler"
*
* each app can create it's own interface
*/
queryInterface<T>( name: string ): T {
return null;
}
}
/**
*
*/
type ComponentConstructor = {
new(...params: any[]): Component;
};
/**
* get a component element from it's DOM counterpart
*/
export function componentFromDOM<T extends Component = Component>( node: Element ) {
return node ? (node as any)[COMPONENT] as T : null;
}
/**
* create a component from an existing DOM
*/
export function wrapDOM( el: HTMLElement ): Component {
const com = componentFromDOM(el);
if( com ) {
return com;
}
return new Component( { existingDOM: el } );
}
// :: Special components ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
// just a flexible element that push other
export class Flex extends Component {
constructor( ) {
super({})
}
}
// just a spacer element that push other
export class Space extends Component {
constructor( width?: number|string, cls?: string ) {
super( { width, cls } )
}
}
// :: HIGH LEVEL BASIC EVENTS ::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
/**
* Click Event
* click event do not have any additional parameters
*/
export interface EvClick extends ComponentEvent {
}
/**
* Change Event
* value is the the element value
*/
export interface EvChange extends ComponentEvent {
readonly value: any;
}
/**
* Focus event
*/
export interface EvFocus extends ComponentEvent {
readonly focus_out: boolean;
}
/**
* Selection Event
* value is the new selection or null
*/
interface ISelection {
}
export interface EvSelectionChange extends ComponentEvent {
readonly selection: ISelection;
readonly empty: boolean;
}
/**
* ContextMenu Event
*/
export interface EvContextMenu extends ComponentEvent {
uievent: UIEvent; // UI event that fire this event
}
/**
* Drag/Drop event
*/
export interface EvDrag extends ComponentEvent {
element: unknown;
data: any;
}
/**
* Errors
*/
export interface EvError extends ComponentEvent {
code: number;
message: string;
}
/**
* DblClick Event
*/
export interface EvDblClick extends ComponentEvent {
}