Skip to content

Instantly share code, notes, and snippets.

@fabd
Last active January 14, 2020 09:23
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fabd/8b5e4ea110842d2c1923e2e711d93f02 to your computer and use it in GitHub Desktop.
Save fabd/8b5e4ea110842d2c1923e2e711d93f02 to your computer and use it in GitHub Desktop.
(TypeScript exercise) A small jquery-like utility library
/**
* A tiny jQuery inspired library for common DOM manipulation.
*
* BROWSER SUPPORT
*
* Recent versions of Edge, Chrome, Safari, iOS Safari, Chrome for Android (as per caniuse.com)
* NOT supported: IE <= 9, Opera Mini.
*
*
* NOTES
*
* Chaining a method is allowed *only once* after the constructor. To call more
* than one method, just assign the result (using `$` prefix as a convention
* to distinguish the actual element from the DomJS constructor):
*
* const $app = $('#app')
* $app.css('background', 'powderblue')
* $app.on('click', evt => { console.log('clicked %o', evt.target) })
*
*
* EXAMPLES
*
* Import:
*
* import $ from 'dom'
*
* Use with selector (use `[0]` or `el()` to get the actual element):
*
* let $el = $('.box')[0]
* let $el = $('.box').el()
*
* Use with element:
*
* let parent = $('.box').closest('.container') => returns actual element
* $(parent).on(...)
*
* Convention: use a $ for instances of DomJS to distinguish from actual Element:
*
* let $box = Dom('.box')
* $box.css('width') = '20px'
* $box.on('click', (ev) => { console.log("clicked el %o", ev.target) })
*
*
* METHODS
*
* el(i = 0) ... return the underlying DOM element (index defaults to 0)
*
* closest(selector) ... find the first ancestor matching selector
*
* css(prop) ... get/set inline styles
* css(prop, value)
* css({ prop1: value, ...})
*
* each(callback) ... callback arguments: element, index (similar to
* forEach)
*
* on(event, callback)
* on([event1, event2 ...], fn)
*
* off(event) ... unbind event(s) or listener
* off([event1, event2 ...])
* off(fn)
*
* once(event, callback) ... call handler once, then remove it
*
* remove(node)
*
* IMPORT METHODS
*
* getStyle()
* insertAfter(newNode, refNode) ... insert newNode as next sibling of refNode
*
*/
import Lang from "./lang";
// types
type StringOrStringArray = string | string[];
type StringHash = { [key: string]: string };
// aliases
const document = window.document;
const documentElement = document.documentElement;
// helpers
const inDocument = (el: Node | null) => documentElement.contains(el);
//
type $Event = {
el: DOMJS.ElementOrWindow;
type: string;
fn: EventListener;
};
let $Events: $Event[] = [];
declare interface DomJSInterface {
/**
* Retrieve one of the elements matched by the selector.
*
* NOTE! Unlike jQuery's "get", el() by default returns the first
* element, for convenience:
*
* if (Dom('.box').el()) {
* // element is found...
* }
*
*/
el(i?: number): DOMJS.ElementOrWindow;
/**
* Returns first ancestor matching selector, or null.
*
* NOTE! Unlike Element.closest() method, this function does NOT
* return the original element.
*
* const ul = $('.TodoList-item').closest('ul') as HTMLUListElement
*/
closest(selector: string): Element | null;
/**
* Get / set inline style(s).
*
* Usage:
*
* css('prop') Get inline style
* css('prop', value) Set one inline style
* css({ prop1: value, ...}) Set multiple inline styles
*
*/
css(props: string | object, value?: any): any;
/**
* Iterate over collection returned by the constructor.
*
* Return explicit `false` to end the loop.
*
* Example:
*
* $('#todolist li').each( (el, index) => { console.log(el, index) })
*/
each(callback: {(element: Element, index: number): false | void}): void;
/**
* Bind one or more event types to a listener, for a SINGLE element
*
* @param events One or more event types, eg. ['click', 'focus']
* @param callback
*/
on(events: string | string[], callback: EventListener): void;
/**
* Detach event listeners.
*
* @param events One or more event types, OR the listener to detach (one argument)
* @param callback (optional) Detach only events matching this callback
*/
off(events: string | string[], callback?: EventListener): void;
/**
* Fire an event only once, then remove said event.
*
* Example:
*
* Dom(el).once('transitionend', fn)
*
*/
once(event: string, fn: EventListener): void;
/**
* Removes the node from the tree it belongs to.
*
* @return Returns removed node, or null
*/
remove(): Node | null;
}
type DomJSSelector = string | Window | Node;
export namespace DOMJS {
export type ElementOrWindow = Element | Window;
}
declare type Foo = string;
class DomJS implements DomJSInterface {
// ArrayLike
length: number;
[n: number]: DOMJS.ElementOrWindow;
constructor(selector: DomJSSelector, context: Element) {
let nodes: ArrayLike<DOMJS.ElementOrWindow>;
if (Lang.isString(selector)) {
nodes = (context || document).querySelectorAll(selector);
}
// window is not instanceof Node, has "length", but doesn't behave like an array
else if (Lang.isWindow(selector)) {
nodes = [window];
}
// assume it's a Node
else {
this[0] = selector as Element;
this.length = 1;
return this;
}
for (let i = 0, l = (this.length = nodes.length); i < l; i++) {
this[i] = nodes[i];
}
}
el(i?: number) {
return this[i || 0];
}
closest(selector: string): Element | null {
console.assert(Lang.isString(selector), "closest() : selector is invalid");
let el = this[0] as Element;
console.assert(Lang.isNode(el), "closest() : el is invalid");
if (!inDocument(el)) {
return null;
}
let matchesSelector = (el: Element) => el.matches(selector);
while ((el = el.parentElement || (el.parentNode as Element))) {
if (matchesSelector(el)) return el
}
return null;
}
each(callback: {(element: Element, index: number): false | void}): void {
for (let i = 0, l = this.length; i < l; i++) {
if (false === callback(this[i] as Element, i)) break;
}
}
on(events: string | string[], callback: EventListener) {
let el = this[0];
console.assert(el === window || Lang.isNode(el), "on() el is invalid");
if (Lang.isString(events)) {
events = [events];
}
events.forEach(event => {
el.addEventListener(event, callback, false);
$Events.push({ el: el, type: event, fn: callback });
});
}
off(events: string | string[] | EventListener | null) {
let callback: EventListener;
const el = this[0];
console.assert(el === window || Lang.isNode(el), "off() : el is invalid");
// .off('click')
if (Lang.isString(events)) {
events = [events];
}
// .off(callback)
else if (Lang.isFunction(events)) {
callback = events as EventListener;
events = null;
}
// .off()
else {
console.assert(arguments.length === 0, "off(): invalid arguments");
}
$Events = $Events.filter(e => {
if (
e.el === el &&
(!callback || callback === e.fn) &&
(!events || (events as string[]).indexOf(e.type) > -1)
) {
e.el.removeEventListener(e.type, e.fn);
return false;
}
return true;
});
}
once(event: string, fn: EventListener) {
let that = this;
let el = this[0];
console.assert(Lang.isFunction(fn), "once() : fn is not a function");
console.assert(el === window || Lang.isNode(el), "once() : el is invalid");
let listener = function(this: any) {
// console.log('called once just now');
(fn as Function).apply(this, arguments);
that.off(listener);
};
this.on(event, listener);
}
css(props: string | StringHash, value?: any): any {
let element = this[0] as HTMLElement;
let styles: StringHash;
if (Lang.isString(props)) {
let prop = props;
// css('prop')
if (arguments.length === 1) {
console.assert(prop in element.style, "invalid property name");
return element.style.getPropertyValue(prop);
}
// css('prop', value)
console.assert(!Lang.isUndefined(arguments[1]));
styles = { [prop]: value };
} else {
styles = props;
}
// set one or more styles
for (let prop in styles) {
element.style.setProperty(prop, styles[prop]);
}
}
remove(): Node | null {
const node = this[0] as Node;
return (node.parentNode && node.parentNode.removeChild(node)) || null;
}
}
const factory = (selector: DomJSSelector, context?: any) => {
return new DomJS(selector, context);
};
/**
* USAGE
*
* add(el, 'foo')
* add(el, 'foo bar')
* add(el, ['foo', 'bar'])
*
* remove(el, 'foo')
* remove(el, 'foo bar')
* remove(el, ['foo', 'bar'])
*
* toggle(el, name)
* toggle(el, name, force)
*
*
* COMPATIBILITY
*
* - IE10/11 : does not support add/remove of multiple classes (only the 1st one)
*
*/
export const classList = {
_set(el: Node, names: StringOrStringArray, add: boolean) {
console.assert(Lang.isNode(el), "classList.add/remove : invalid node");
console.assert(
Lang.isString(names) || Lang.isArray(names),
"classList : class must be a String or Array"
);
if (!el) return;
let classes: string[] = Lang.isString(names)
? names.split(" ")
: /* assumed Array */ names;
// FIXME? IE10/11 does not support multiple classes for add/remove (loop?)
(el as HTMLElement).classList[add ? "add" : "remove"](...classes);
},
add(el: Node, names: StringOrStringArray) {
this._set(el, names, true);
},
remove(el: Node, names: StringOrStringArray) {
this._set(el, names, false);
},
toggle(el: Node, name: string, force: boolean) {
// NOTE: doing it this way supports IE10/11 lack of support for "force"
if (arguments.length > 2) {
this._set(el, [name], !!force);
} else {
(el as HTMLElement).classList.toggle(name);
}
}
};
export { factory as default };
// language utils
const isNode = (el: any) => {
return el instanceof Node;
};
const isNodeList = (el: any) => {
return (
el instanceof NodeList ||
el instanceof HTMLCollection ||
el instanceof Array
);
};
const isArray = (o: any): boolean => Array.isArray(o);
const isBoolean = (o: any): o is boolean => typeof o === "boolean";
const isFunction = (f: any): f is Function => typeof f === "function";
const isNumber = (s: any): s is number => typeof s === "number";
const isString = (s: any): s is string => typeof s === "string";
const isUndefined = (o: any): o is undefined => typeof o === "undefined";
const isWindow = (o: any): o is Window => o === window;
export default {
isArray,
isBoolean,
isFunction,
isNode,
isNodeList,
isNumber,
isString,
isUndefined,
isWindow
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment