mirror of
https://github.com/janishutz/fundamentals-of-webengineering.git
synced 2025-11-25 05:44:24 +00:00
++ Glue
This commit is contained in:
34
task_2_ts/ts/glue/index.ts
Normal file
34
task_2_ts/ts/glue/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
/*
|
||||
* fundamentals-of-webengineering - framework.ts
|
||||
*
|
||||
* Created by Janis Hutz 10/20/2025, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
// Yes, I could not be arsed to keep track of state manually, so wrote a framework real quick that
|
||||
// does that for me. I am well aware that this is well over engineered, but it was a lot of fun
|
||||
// and no, this is *NOT* AI generated (I know Claude likes to hallucinate that kinda stuff)
|
||||
// I will be trying to somewhat follow Vue naming here, as that is what I am familiar with
|
||||
// (The only thing that is AI generated is the name of the little framework)
|
||||
//
|
||||
// It was also a nice exercise to get familiar with Generics in TypeScript, something I haven't
|
||||
// really used before
|
||||
|
||||
import {
|
||||
listRef
|
||||
} from './list';
|
||||
import {
|
||||
ref
|
||||
} from './primitives';
|
||||
|
||||
export {
|
||||
listRef,
|
||||
ref
|
||||
};
|
||||
|
||||
export default {
|
||||
ref,
|
||||
listRef
|
||||
};
|
||||
44
task_2_ts/ts/glue/list-renderer.ts
Normal file
44
task_2_ts/ts/glue/list-renderer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
RenderTemplate,
|
||||
StringIndexedObject
|
||||
} from './rendering';
|
||||
|
||||
const renderList = <T extends StringIndexedObject>(
|
||||
data: T,
|
||||
template: RenderTemplate,
|
||||
name: string, id: number
|
||||
): HTMLElement => {
|
||||
const parent = renderer( data, template );
|
||||
|
||||
parent.id = `${ name }--${ id }`;
|
||||
|
||||
return parent;
|
||||
};
|
||||
|
||||
const renderer = <T extends StringIndexedObject>( data: T, template: RenderTemplate ): HTMLElement => {
|
||||
const parent = document.createElement( template.type );
|
||||
|
||||
for ( let i = 0; i < template.cssClasses.length; i++ ) {
|
||||
parent.classList.add( template.cssClasses[i]! );
|
||||
}
|
||||
|
||||
for ( let i = 0; i < template.children.length; i++ ) {
|
||||
const element = template.children[i]!;
|
||||
|
||||
parent.appendChild( renderer( data, element ) );
|
||||
}
|
||||
|
||||
if ( template.children.length === 0 ) {
|
||||
if ( template.attribute ) {
|
||||
parent.textContent = String( data[ template.attribute ] );
|
||||
} else {
|
||||
parent.textContent = String( data );
|
||||
}
|
||||
}
|
||||
|
||||
return parent;
|
||||
};
|
||||
|
||||
export default {
|
||||
renderList
|
||||
};
|
||||
141
task_2_ts/ts/glue/list.ts
Normal file
141
task_2_ts/ts/glue/list.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
ListRef, RenderTemplate
|
||||
} from './rendering';
|
||||
import listRenderer from './list-renderer';
|
||||
|
||||
export const listRef = <T>( parent: HTMLElement, data: T[], name: string, template: RenderTemplate ): ListRef<T> => {
|
||||
if ( parent === null ) throw new Error( 'Parent is null!' );
|
||||
|
||||
let list: T[] = data; // contains all values passed in
|
||||
|
||||
const nodes: HTMLElement[] = [];
|
||||
const rendered: boolean[] = []; // Mask for rendering
|
||||
const onChangeFunctions: ( () => Promise<void> )[] = [];
|
||||
|
||||
/**
|
||||
* @returns All currently rendered elements
|
||||
*/
|
||||
const get = (): T[] => {
|
||||
return list.filter( ( _, index ) => {
|
||||
return rendered[ index ];
|
||||
} );
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Deletes all child nodes and recreates them based on the new data.
|
||||
* @param data - The new data to set
|
||||
*/
|
||||
const set = ( data: T[] ): void => {
|
||||
// Yes, I know, really bad performance, etc, but it's not needed for any other use case
|
||||
// here, other than a full replace of the data (no dynamic updates)
|
||||
list = data;
|
||||
parent.textContent = '';
|
||||
|
||||
// Render the list based on template
|
||||
for ( let i = 0; i < data.length; i++ ) {
|
||||
const element = data[i]!;
|
||||
|
||||
// Render list
|
||||
nodes[ i ] = listRenderer.renderList(
|
||||
element, template, name, i
|
||||
);
|
||||
rendered[ i ] = true;
|
||||
parent.appendChild( nodes[ i ]! );
|
||||
}
|
||||
};
|
||||
|
||||
const setTemplate = ( newTemplate: RenderTemplate ): void => {
|
||||
template = newTemplate;
|
||||
set( data );
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Sort function, a wrapper for native JS's sort on arrays.
|
||||
* Will be more performant than doing a re-render by sorting and setting,
|
||||
* as it will sort in-place, instead of regenerating.
|
||||
* @param compare - The comparison function to use
|
||||
*/
|
||||
const sort = ( compare: ( a: T, b: T ) => number ): void => {
|
||||
// Re-render based on compare function
|
||||
const children = [ ...parent.children ];
|
||||
|
||||
children.sort( ( elA, elB ) => {
|
||||
// Need array index somehow on the element to make comparison easier for consumer
|
||||
const a = parseInt( elA.id.split( '--' )[1]! );
|
||||
const b = parseInt( elB.id.split( '--' )[1]! );
|
||||
|
||||
// Coaxing the TypeScript compiler into believing this value will exist
|
||||
return compare( list[a]!, list[b]! );
|
||||
} );
|
||||
|
||||
children.forEach( el => {
|
||||
parent.appendChild( el );
|
||||
} );
|
||||
};
|
||||
|
||||
/** Reset the sorting */
|
||||
const resetSort = (): void => {
|
||||
const children = [ ...parent.children ];
|
||||
|
||||
children.sort( ( elA, elB ) => {
|
||||
const a = parseInt( elA.id.split( '--' )[1]! );
|
||||
const b = parseInt( elB.id.split( '--' )[1]! );
|
||||
|
||||
return a - b;
|
||||
} );
|
||||
children.forEach( el => {
|
||||
parent.appendChild( el );
|
||||
} );
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Filter elements. More performant than doing it with set operation, as it is cheaper to reverse.
|
||||
* It also does not touch the nodes that are going to remain in DOM
|
||||
* @param predicate - Filtering predicate
|
||||
*/
|
||||
const filter = ( predicate: ( value: T ) => boolean ): void => {
|
||||
let currentIndexInChildrenList = 0;
|
||||
|
||||
const children = [ ...parent.children ];
|
||||
|
||||
list.forEach( ( val, index ) => {
|
||||
const evaluation = predicate( val );
|
||||
|
||||
if ( !evaluation && rendered[ index ] ) {
|
||||
// can use ! here, as semantics of program tell us that this index will exist
|
||||
nodes[ index ]!.remove();
|
||||
rendered[ index ] = false;
|
||||
} else if ( evaluation && !rendered[ index ] ) {
|
||||
currentIndexInChildrenList++;
|
||||
parent.insertBefore( nodes[ index ]!, children[ currentIndexInChildrenList ]! );
|
||||
} else {
|
||||
currentIndexInChildrenList++;
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Connect to change event
|
||||
* @param callback - The callback that is executed each time the value is updated
|
||||
*/
|
||||
const onChange = ( callback: () => void ) => {
|
||||
const asyncWrapper = async () => callback();
|
||||
|
||||
onChangeFunctions.push( asyncWrapper );
|
||||
};
|
||||
|
||||
set( data );
|
||||
|
||||
return {
|
||||
get,
|
||||
set,
|
||||
sort,
|
||||
resetSort,
|
||||
filter,
|
||||
setTemplate,
|
||||
onChange
|
||||
};
|
||||
};
|
||||
20
task_2_ts/ts/glue/package.json
Normal file
20
task_2_ts/ts/glue/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gluejs",
|
||||
"version": "1.0.0",
|
||||
"description": "It's not Vue, it's Glue! Because that's what's holding it together.",
|
||||
"homepage": "https://github.com/janishutz/fundamentals-of-webengineering#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/janishutz/fundamentals-of-webengineering/issues"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/janishutz/fundamentals-of-webengineering.git"
|
||||
},
|
||||
"license": "ISC",
|
||||
"author": "Janis Hutz <development@janishutz.com>",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
}
|
||||
}
|
||||
168
task_2_ts/ts/glue/primitives.ts
Normal file
168
task_2_ts/ts/glue/primitives.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import {
|
||||
Ref
|
||||
} from './rendering';
|
||||
|
||||
interface ConditionalElement<T> {
|
||||
'element': HTMLElement;
|
||||
'predicate': ( value: T ) => boolean;
|
||||
}
|
||||
|
||||
interface ConditionalClass<T> {
|
||||
'element': HTMLElement,
|
||||
'onTrue': string[],
|
||||
'onFalse': string[],
|
||||
'predicate': ( value: T ) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Responsive data (similar behaviour as in Vue.js)
|
||||
* @template T - The data type you wish to use (as long as you don't want it to be a list)
|
||||
* @param data - The data stored in this ref
|
||||
* @param elements - The elements to bind to
|
||||
*/
|
||||
export const ref = <T>( elements: HTMLElement[], data: T ): Ref<T> => {
|
||||
let value: T = data;
|
||||
|
||||
const onChangeFunctions: ( () => Promise<void> )[] = [];
|
||||
const boundElements: HTMLInputElement[] = [];
|
||||
|
||||
let conditionalElements: ConditionalElement<T>[] = [];
|
||||
let conditionalClasses: ConditionalClass<T>[] = [];
|
||||
|
||||
|
||||
/**
|
||||
* Bind to a further element (DOM text is updated for it)
|
||||
* @param element - The element to add
|
||||
*/
|
||||
const addAdditionalElement = ( element: HTMLElement ) => {
|
||||
elements.push( element );
|
||||
};
|
||||
|
||||
/**
|
||||
* @returns The value the ref currently holds
|
||||
*/
|
||||
const get = (): T => {
|
||||
return value;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param data - The new value of the
|
||||
*/
|
||||
const set = ( data: T ): void => {
|
||||
if ( value !== data ) {
|
||||
value = data;
|
||||
// Update normal ref elements
|
||||
elements.forEach( el => {
|
||||
el.textContent = String( data );
|
||||
} );
|
||||
|
||||
// Update conditional elements
|
||||
conditionalElements.forEach( el => {
|
||||
// convert to boolean (explicitly)
|
||||
el.element.hidden = !el.predicate( data );
|
||||
} );
|
||||
|
||||
conditionalClasses.forEach( el => {
|
||||
if ( el.predicate( data ) ) {
|
||||
el.element.classList.remove( ...el.onFalse );
|
||||
el.element.classList.add( ...el.onTrue );
|
||||
} else {
|
||||
el.element.classList.remove( ...el.onTrue );
|
||||
el.element.classList.add( ...el.onFalse );
|
||||
}
|
||||
} );
|
||||
|
||||
// Update boundElements
|
||||
boundElements.forEach( el => {
|
||||
el.value = String( value );
|
||||
} );
|
||||
|
||||
for ( let i = 0; i < onChangeFunctions.length; i++ ) {
|
||||
onChangeFunctions[ i ]!();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Bind to input change of an HTMLInputElement (two way bind)
|
||||
* @param element - The element to bind to (i.e. add a two-way bind to)
|
||||
* @param castFunction - Function used for type casting from string to T
|
||||
*/
|
||||
const bind = ( element: HTMLInputElement, castFunction: ( val: string ) => T ) => {
|
||||
element.addEventListener( 'change', () => {
|
||||
set( castFunction( element.value ) );
|
||||
} );
|
||||
element.value = String( value );
|
||||
boundElements.push( element );
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Add elements to be rendered conditionally on this ref. Treats type as booleanish
|
||||
* @param element - The element that will be affected by predicate
|
||||
* @param predicate - The predicate to evaluate when value is changed
|
||||
*/
|
||||
const addConditionalElementBind = ( element: HTMLElement, predicate: ( value: T ) => boolean ): void => {
|
||||
conditionalElements.push( {
|
||||
'element': element,
|
||||
'predicate': predicate
|
||||
} );
|
||||
element.hidden = !predicate( value );
|
||||
};
|
||||
|
||||
const resetConditionalElementBinds = () => {
|
||||
conditionalElements = [];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* @param element - The element to do the operation on
|
||||
* @param predicate - The predicate to evaluate when value is changed
|
||||
* @param onTrue - The classes (as strings) to set if true(ish)
|
||||
* @param onFalse - The classes to set on false(ish)
|
||||
*/
|
||||
const addConditionalClasses = (
|
||||
element: HTMLElement,
|
||||
predicate: ( value: T ) => boolean,
|
||||
onTrue: string[],
|
||||
onFalse: string[]
|
||||
) => {
|
||||
conditionalClasses.push( {
|
||||
'element': element,
|
||||
'onTrue': onTrue,
|
||||
'onFalse': onFalse,
|
||||
'predicate': predicate
|
||||
} );
|
||||
};
|
||||
|
||||
const resetConditionalClasses = () => {
|
||||
conditionalClasses = [];
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Connect to change event
|
||||
* @param callback - The callback that is executed each time the value is updated
|
||||
*/
|
||||
const onChange = ( callback: () => void ) => {
|
||||
const asyncWrapper = async () => callback();
|
||||
|
||||
onChangeFunctions.push( asyncWrapper );
|
||||
};
|
||||
|
||||
set( data );
|
||||
|
||||
return {
|
||||
set,
|
||||
get,
|
||||
addAdditionalElement,
|
||||
addConditionalElementBind,
|
||||
resetConditionalElementBinds,
|
||||
addConditionalClasses,
|
||||
resetConditionalClasses,
|
||||
bind,
|
||||
onChange
|
||||
};
|
||||
};
|
||||
53
task_2_ts/ts/glue/rendering.d.ts
vendored
Normal file
53
task_2_ts/ts/glue/rendering.d.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
export interface Ref<T> {
|
||||
'set': ( data: T ) => void;
|
||||
'get': () => T;
|
||||
'addAdditionalElement': ( elements: HTMLElement, predicate: ( value: T ) => boolean ) => void;
|
||||
'addConditionalElementBind': ( elements: HTMLElement, predicate: ( value: T ) => boolean ) => void;
|
||||
'addConditionalClasses': (
|
||||
element: HTMLElement, predicate: ( value: T ) => boolean, onTrue: string[], onFalse: string[] ) => void;
|
||||
'resetConditionalClasses': () => void;
|
||||
'resetConditionalElementBinds': () => void;
|
||||
'bind': ( element: HTMLInputElement, castFunction: ( val: string ) => T ) => void;
|
||||
'onChange': ( callback: () => void ) => void;
|
||||
}
|
||||
|
||||
export interface ListRef<T> {
|
||||
'set': ( data: T[] ) => void;
|
||||
'get': () => T[];
|
||||
'sort': ( compare: ( a: T, b: T ) => number ) => void;
|
||||
'resetSort': () => void;
|
||||
'filter': ( predicate: ( value: T ) => boolean ) => void;
|
||||
'setTemplate': ( newTemplate: RenderTemplate ) => void;
|
||||
'onChange': ( callback: () => void ) => void;
|
||||
}
|
||||
|
||||
export type HTMLTagNames = keyof HTMLElementTagNameMap;
|
||||
|
||||
export interface RenderTemplate {
|
||||
/**
|
||||
* What kind of element to render. Not all HTML elements supported (couldn't be arsed to do it)
|
||||
*/
|
||||
'type': HTMLTagNames;
|
||||
|
||||
/**
|
||||
* The attribute of the element to render. Leave blank if type is not object
|
||||
* Will be ignored if you also set children. If no children or attribute set,
|
||||
* will simply treat type as string and render accordingly
|
||||
*/
|
||||
'attribute'?: string;
|
||||
|
||||
/**
|
||||
* Children to render. Can be used to nest
|
||||
*/
|
||||
'children': RenderTemplate[];
|
||||
|
||||
/**
|
||||
* CSS classes to append to the element
|
||||
*/
|
||||
'cssClasses': string[];
|
||||
}
|
||||
|
||||
|
||||
export interface StringIndexedObject {
|
||||
[key: string]: unknown
|
||||
}
|
||||
Reference in New Issue
Block a user