Web re-rewrite

This commit is contained in:
2023-03-08 11:10:33 -06:00
parent 7d31c55391
commit 17277bb354
78 changed files with 9571 additions and 9496 deletions
+249
View File
@@ -0,0 +1,249 @@
let register = Toolkit => Toolkit.App =
// Root application container and localization manager
class App extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(options) {
super(null, Object.assign({
tabIndex: -1
}, options));
// Configure instance fields
this.components = new Set();
this.dragElement = null;
this.lastFocus = null;
this.locale = null;
this.locales = new Map();
// Configure event handlers
this.addEventListener("focusin", e=>this.onFocus(e));
this.addEventListener("keydown", e=>this.onKey (e));
this.addEventListener("keyup" , e=>this.onKey (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child element focus gained
onFocus(e) {
// Error checking
if (e.target != document.activeElement)
return;
// Target is self
if (e.target == this.element)
return this.restoreFocus();
// Ensure the child is not contained in a MenuBar
for (let elm = e.target; elm != this.element; elm = elm.parentNode) {
if (elm.getAttribute("role") == "menubar")
return;
}
// Track the (non-menu) element as the most recent focused component
this.lastFocus = e.target;
}
// Key press, key release
onKey(e) {
if (this.dragElement == null || e.rerouted)
return;
this.dragElement.dispatchEvent(Object.assign(new Event(e.type), {
altKey : e.altKey,
ctrlKey : e.ctrlKey,
key : e.key,
rerouted: true,
shiftKey: e.shiftKey
}));
}
///////////////////////////// Public Methods //////////////////////////////
// Install a locale from URL
async addLocale(url) {
let data;
// Load the file as JSON, using UTF-8 with or without a BOM
try { data = JSON.parse(new TextDecoder().decode(
await (await fetch(url)).arrayBuffer() )); }
catch { return null; }
// Error checking
if (!data.id || !data.name)
return null;
// Flatten the object to keys
let locale = new Map();
let entries = Object.entries(data);
let stack = [];
while (entries.length != 0) {
let entry = entries.shift();
// The value is a non-array object
if (entry[1] instanceof Object && !Array.isArray(entry[1])) {
entries = entries.concat(Object.entries(entry[1])
.map(e=>[ entry[0] + "." + e[0], e[1] ]));
}
// The value is a primitive or array
else locale.set(entry[0].toLowerCase(), entry[1]);
}
this.locales.set(data.id, locale);
return data.id;
}
// Specify a localization dictionary
setLocale(id) {
if (!this.locales.has(id))
return false;
this.locale = this.locales.get(id);
for (let comp of this.components)
comp.localize();
}
///////////////////////////// Package Methods /////////////////////////////
// Begin dragging on an element
get drag() { return this.dragElement; }
set drag(event) {
// Begin dragging
if (event) {
this.dragElement = event.currentTarget;
this.dragPointer = event.pointerId;
this.dragElement.setPointerCapture(event.pointerId);
}
// End dragging
else {
if (this.dragElement)
this.dragElement.releasePointerCapture(this.dragPointer);
this.dragElement = null;
this.dragPointer = null;
}
}
// Configure components for automatic localization, or localize a message
localize(a, b) {
return a instanceof Object ? this.localizeComponents(a, b) :
this.localizeMessage(a, b);
}
// Return focus to the most recent focused element
restoreFocus() {
// Error checking
if (!this.lastFocus)
return false;
// Unable to restore focus
if (!this.isVisible(this.lastFocus))
return false;
// Transfer focus to the most recent element
this.lastFocus.focus({ preventScroll: true });
return true;
}
///////////////////////////// Private Methods /////////////////////////////
// Configure components for automatic localization
localizeComponents(comps, add) {
// Process all components
for (let comp of (Array.isArray(comps) ? comps : [comps])) {
// Error checking
if (
!(comp instanceof Toolkit.Component) ||
!(comp.localize instanceof Function)
) continue;
// Update the collection and component text
this.components[add ? "add" : "delete"](comp);
comp.localize();
}
}
// Localize a message
localizeMessage(message, substs, circle = new Set()) {
let parts = [];
// Separate the substitution keys from the literal text
for (let x = 0;;) {
// Locate the start of the next substitution key
let y = message.indexOf("{", x);
let z = y == -1 ? -1 : message.indexOf("}", y + 1);
// No substitution key or malformed substitution expression
if (z == -1) {
parts.push(message.substring(z == -1 ? x : y));
break;
}
// Append the literal text and the substitution key
parts.push(message.substring(x, y), message.substring(y + 1, z));
x = z + 1;
}
// Process all substitutions
for (let x = 1; x < parts.length; x += 2) {
let key = parts[x].toLowerCase();
let value;
// The substitution key is already in the recursion chain
if (circle.has(key)) {
parts[x] = "{\u21ba" + key.toUpperCase() + "}";
continue;
}
// Resolve the substitution key from the argument
if (substs && substs.has(key)) {
value = substs.get(key);
// Do not recurse for this substitution
if (!value[1]) {
parts[x] = value[0];
continue;
}
// Substitution text
value = value[0];
}
// Resolve the substitution from the current locale
else if (this.locale && this.locale.has(key))
value = this.locale.get(key);
// A matching substitution key was not found
else {
parts[x] = "{\u00d7" + key.toUpperCase() + "}";
continue;
}
// Perform recursive substitution
circle.add(key);
parts[x] = this.localizeMessage(value, substs, circle);
circle.delete(key);
}
return parts.join("");
}
};
export { register };
+119
View File
@@ -0,0 +1,119 @@
let register = Toolkit => Toolkit.Button =
// Push button
class Button extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk button",
role : "button",
tabIndex: "0"
}, options));
// Configure options
if ("disabled" in options)
this.disabled = options.disabled;
this.doNotFocus = !("doNotFocus" in options) || options.doNotFocus;
// Display text
this.content = new Toolkit.Label(app);
this.add(this.content);
// Event handlers
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (
!(e.altKey || e.ctrlKey || e.shiftKey || this.disabled) &&
(e.key == " " || e.key == "Enter")
) this.activate();
}
// Pointer down
onPointerDown(e) {
// Gain focus
if (
!this.doNotFocus &&
this.isFocusable() &&
this.element != document.activeElement
) this.element.focus();
else e.preventDefault();
// Do not drag
if (
e.button != 0 ||
this.disabled ||
this.element.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("pushed");
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not drag
if (!this.element.hasPointerCapture(e.pointerId))
return;
// Process dragging
this.element.classList[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not activate
if (e.button != 0 || !this.element.hasPointerCapture(e.pointerId))
return;
// End dragging
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
Toolkit.handle(e);
// Activate the button if applicable
if (this.isWithin(e))
this.activate();
}
///////////////////////////// Public Methods //////////////////////////////
// Simulate a click on the button
activate() {
if (!this.disabled)
this.element.dispatchEvent(new Event("action"));
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.localizeText(this.content);
this.localizeLabel();
this.localizeTitle();
}
}
export { register };
+144
View File
@@ -0,0 +1,144 @@
let register = Toolkit => Toolkit.Checkbox =
// Check box
class Checkbox extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk checkbox",
role : "checkbox",
tabIndex: "0"
}, options, { style: Object.assign({
alignItems : "center",
display : "inline-grid",
gridTemplateColumns: "max-content auto"
}, options.style || {}) }));
// Configure element
this.element.setAttribute("aria-checked", "false");
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
// Icon area
this.box = document.createElement("div");
this.box.className = "tk box";
this.append(this.box);
// Display text
this.uiLabel = new Toolkit.Label(app);
this.add(this.uiLabel);
// Configure options
this.checked = options.checked;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (
!(e.altKey || e.ctrlKey || e.shiftKey || this.disabled) &&
(e.key == " " || e.key == "Enter")
) this.setChecked(!this.checked);
}
// Pointer down
onPointerDown(e) {
// Gain focus
if (!this.disabled)
this.element.focus();
else e.preventDefault();
// Do not drag
if (
e.button != 0 ||
this.disabled ||
this.element.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("pushed");
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not drag
if (!this.element.hasPointerCapture(e.pointerId))
return;
// Process dragging
this.element.classList[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not activate
if (e.button != 0 || !this.element.hasPointerCapture(e.pointerId))
return;
// End dragging
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
Toolkit.handle(e);
// Activate the check box if applicable
if (this.isWithin(e))
this.setChecked(!this.checked);
}
///////////////////////////// Public Methods //////////////////////////////
// The check box is checked
get checked() { return this.element.getAttribute("aria-checked")=="true"; }
set checked(checked) {
checked = !!checked;
if (checked == this.checked)
return;
this.element.setAttribute("aria-checked", checked);
}
// Specify the display text
setText(text, localize) {
this.uiLabel.setText(text, localize);
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.uiLabel.localize();
this.localizeTitle();
}
///////////////////////////// Private Methods /////////////////////////////
// Specify the checked state
setChecked(checked) {
checked = !!checked;
if (checked == this.checked)
return;
this.checked = checked;
this.element.dispatchEvent(new Event("input"));
}
}
export { register };
+473
View File
@@ -0,0 +1,473 @@
let register = Toolkit => Toolkit.Component =
// Base class from which all toolkit components are derived
class Component {
//////////////////////////////// Constants ////////////////////////////////
// Non-attributes
static NON_ATTRIBUTES = new Set([
"checked", "disabled", "doNotFocus", "group", "hover", "max", "min",
"name", "orientation", "overflowX", "overflowY", "tag", "text",
"value", "view", "visibility", "visible"
]);
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
// Configure element
this.element = document.createElement(options.tag || "div");
this.element.component = this;
for (let entry of Object.entries(options)) {
if (
Toolkit.Component.NON_ATTRIBUTES.has(entry[0]) ||
entry[0] == "type" && options.tag != "input"
) continue;
if (entry[0] == "style" && entry[1] instanceof Object)
Object.assign(this.element.style, entry[1]);
else this.element.setAttribute(entry[0], entry[1]);
}
// Configure instance fields
this._isLocalized = false;
this.app = app;
this.display = options.style && options.style.display;
this.style = this.element.style;
this.text = null;
this.visibility = !!options.visibility;
this.visible = !("visible" in options) || options.visible;
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
// Error checking
if (
!(comp instanceof Toolkit.Component) ||
comp instanceof Toolkit.App ||
comp.app != (this.app || this)
) return false;
// No components have been added yet
if (!this.children)
this.children = [];
// The child already has a parent: remove it
if (comp.parent) {
comp.parent.children.splice(
comp.parent.children.indexOf(comp), 1);
}
// Add the component to self
this.children.push(comp);
this.append(comp.element);
comp.parent = this;
return true;
}
// Register an event listener on the element
addEventListener(type, listener) {
// No event listeners have been registered yet
if (!this.listeners)
this.listeners = new Map();
if (!this.listeners.has(type))
this.listeners.set(type, []);
// The listener has already been registered for this event
let listeners = this.listeners.get(type);
if (listeners.indexOf(listener) != -1)
return listener;
// Resize events are implemented by a ResizeObserver
if (type == "resize") {
if (!this.resizeObserver) {
this.resizeObserver = new ResizeObserver(()=>
this.element.dispatchEvent(new Event("resize")));
this.resizeObserver.observe(this.element);
}
}
// Visibility events are implemented by an IntersectionObserver
else if (type == "visibility") {
if (!this.visibilityObserver) {
this.visibilityObserver = new IntersectionObserver(
()=>this.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: this.isVisible() }
)),
{ root: document.body }
);
this.visibilityObserver.observe(this.element);
}
}
// Register the listener with the element
listeners.push(listener);
this.element.addEventListener(type, listener);
return listener;
}
// Component cannot be interacted with
get disabled() { return this.element.hasAttribute("disabled"); }
set disabled(disabled) { this.setDisabled(disabled); }
// Move focus into the component
focus() {
this.element.focus({ preventScroll: true });
}
// Specify whether the component is localized
get isLocalized() { return this._isLocalized; }
set isLocalized(isLocalized) {
if (isLocalized == this._isLocalized)
return;
this._isLocalized = isLocalized;
(this instanceof Toolkit.App ? this : this.app)
.localize(this, isLocalized);
}
// Determine whether an element is actually visible
isVisible(element = this.element) {
if (!document.body.contains(element))
return false;
for (; element instanceof Element; element = element.parentNode) {
let style = getComputedStyle(element);
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
}
// Produce an ordered list of registered event listeners for an event type
listEventListeners(type) {
return this.listeners && this.listeners.has(type) &&
this.listeners.get(type).list.slice() || [];
}
// Remove a child component
remove(comp) {
if (comp.parent != this || !this.children)
return false;
let index = this.children.indexOf(comp);
if (index == -1)
return false;
this.children.splice(index, 1);
comp.element.remove();
comp.parent = null;
return true;
}
// Unregister an event listener from the element
removeEventListener(type, listener) {
// Not listening to events of the specified type
if (!this.listeners || !this.listeners.has(type))
return listener;
// Listener is not registered
let listeners = this.listeners.get(type);
let index = listeners.indexOf(listener);
if (index == -1)
return listener;
// Unregister the listener
this.element.removeEventListener(listener);
listeners.splice(index, 1);
// Delete the ResizeObserver
if (
type == "resize" &&
listeners.list.length == 0 &&
this.resizeObserver
) {
this.resizeObserver.disconnect();
delete this.resizeObserver;
}
// Delete the IntersectionObserver
else if (
type == "visibility" &&
listeners.list.length == 0 &&
this.visibilityObserver
) {
this.visibilityObserver.disconnect();
delete this.visibilityObserver;
}
return listener;
}
// Specify accessible name
setLabel(text, localize) {
// Label is another component
if (
text instanceof Toolkit.Component ||
text instanceof HTMLElement
) {
this.element.setAttribute("aria-labelledby",
(text.element || text).id);
this.setString("label", null, false);
}
// Label is the given text
else {
this.element.removeAttribute("aria-labelledby");
this.setString("label", text, localize);
}
}
// Specify role description text
setRoleDescription(text, localize) {
this.setString("roleDescription", text, localize);
}
// Specify inner text
setText(text, localize) {
this.setString("text", text, localize);
}
// Specify tooltip text
setTitle(text, localize) {
this.setString("title", text, localize);
}
// Specify substitution text
substitute(key, text = null, recurse = false) {
if (text === null) {
if (this.substitutions.has(key))
this.substitutions.delete(key);
} else this.substitutions.set(key, [ text, recurse ]);
if (this.localize instanceof Function)
this.localize();
}
// Determine whether the element wants to be visible
get visible() {
let style = this.element.style;
return style.display != "none" && style.visibility != "hidden";
}
// Specify whether the element is visible
set visible(visible) {
visible = !!visible;
// Visibility is not changing
if (visible == this.visible)
return;
let comps = [ this ].concat(
Array.from(this.element.querySelectorAll("*"))
.map(c=>c.component)
).filter(c=>
c instanceof Toolkit.Component &&
c.listeners &&
c.listeners.has("visibility")
)
;
let prevs = comps.map(c=>c.isVisible());
// Allow the component to be shown
if (visible) {
if (!this.visibility) {
if (this.display)
this.element.style.display = this.display;
else this.element.style.removeProperty("display");
} else this.element.style.removeProperty("visibility");
}
// Prevent the component from being shown
else {
this.element.style.setProperty(
this.visibility ? "visibility" : "display",
this.visibility ? "hidden" : "none"
);
}
for (let x = 0; x < comps.length; x++) {
let comp = comps[x];
visible = comp.isVisible();
if (visible == prevs[x])
continue;
comp.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: visible }
));
}
}
///////////////////////////// Package Methods /////////////////////////////
// Add a child component to the primary client region of this component
append(element) {
this.element.append(element instanceof Toolkit.Component ?
element.element : element);
}
// Determine whether a component or element is a child of this component
contains(child) {
return this.element.contains(child instanceof Toolkit.Component ?
child.element : child);
}
// Generate a list of focusable descendant elements
getFocusable(element = this.element) {
let cache;
return Array.from(element.querySelectorAll(
"*:is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0']):not([disabled])"
)).filter(e=>{
for (; e instanceof Element; e = e.parentNode) {
let style =
(cache || (cache = new Map())).get(e) ||
cache.set(e, getComputedStyle(e)).get(e)
;
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
});
}
// Specify the inner text of the primary client region of this component
get innerText() { return this.element.textContent; }
set innerText(text) { this.element.innerText = text; }
// Determine whether an element is focusable
isFocusable(element = this.element) {
return element.matches(
":is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0'],[tabindex='-1']):not([disabled])"
);
}
// Determine whether a pointer event is within the element
isWithin(e, element = this.element) {
let bounds = element.getBoundingClientRect();
return (
e.clientX >= bounds.left && e.clientX < bounds.right &&
e.clientY >= bounds.top && e.clientY < bounds.bottom
);
}
// Common processing for localizing the accessible name
localizeLabel(element = this.element) {
// There is no label or the label is another element
if (!this.label || element.hasAttribute("aria-labelledby")) {
element.removeAttribute("aria-label");
return;
}
// Localize the label
let text = this.label;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-label", text);
}
// Common processing for localizing the accessible role description
localizeRoleDescription(element = this.element) {
// There is no role description
if (!this.roleDescription) {
element.removeAttribute("aria-roledescription");
return;
}
// Localize the role description
let text = this.roleDescription;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-roledescription", text);
}
// Common processing for localizing inner text
localizeText(element = this.element) {
// There is no title
if (!this.text) {
element.innerText = "";
return;
}
// Localize the text
let text = this.text;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.innerText = text;
}
// Common processing for localizing the tooltip text
localizeTitle(element = this.element) {
// There is no title
if (!this.title) {
element.removeAttribute("title");
return;
}
// Localize the title
let text = this.title;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("title", text);
}
// Common handler for configuring whether the component is disabled
setDisabled(disabled, element = this.element) {
element[disabled ? "setAttribute" : "removeAttribute"]
("disabled", "");
element.setAttribute("aria-disabled", disabled ? "true" : "false");
}
// Specify display text
setString(key, value, localize = true) {
// There is no method to update the display text
if (!(this.localize instanceof Function))
return;
// Working variables
let app = this instanceof Toolkit.App ? this : this.app;
// Remove the string
if (value === null) {
if (app && this[key] != null && this[key][1])
app.localize(this, false);
this[key] = null;
}
// Set or replace the string
else {
if (app && localize && (this[key] == null || !this[key][1]))
app.localize(this, true);
this[key] = [ value, localize ];
}
// Update the display text
this.localize();
}
// Retrieve the substitutions map
get substitutions() {
if (!this._substitutions)
this._substitutions = new Map();
return this._substitutions;
}
};
export { register };
+86
View File
@@ -0,0 +1,86 @@
let register = Toolkit => Toolkit.Desktop =
// Layered window manager
class Desktop extends Toolkit.Component {
//////////////////////////////// Constants ////////////////////////////////
// Comparator for ordering child windows
static CHILD_ORDER(a, b) {
return b.element.style.zIndex - a.element.style.zIndex;
}
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class: "tk desktop"
}, options, { style: Object.assign({
position: "relative",
zIndex : "0"
}, options.style || {})} ));
// Configure event listeners
this.addEventListener("resize", e=>this.onResize());
}
///////////////////////////// Event Handlers //////////////////////////////
// Element resized
onResize() {
// The element is hidden: its size is indeterminate
if (!this.isVisible())
return;
// Don't allow children to be out-of-frame
if (this.children != null) {
for (let child of this.children) {
child.left = child.left;
child.top = child.top;
}
}
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
super.add(comp);
this.bringToFront(comp);
}
// Retrieve the foremost visible window
getActiveWindow() {
if (this.children != null) {
for (let child of this.children) {
if (child.isVisible())
return child;
}
}
return null;
}
///////////////////////////// Package Methods /////////////////////////////
// Reorder children so that a particular child is in front
bringToFront(child) {
this.children.splice(this.children.indexOf(child), 1);
this.children.push(child);
let z = 1 - this.children.length;
for (let child of this.children)
child.element.style.zIndex = z++;
}
}
export { register };
+154
View File
@@ -0,0 +1,154 @@
let register = Toolkit => Toolkit.DropDown =
class DropDown extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class: "tk drop-down",
tag : "select"
}, options));
// Configure instance fields
this.items = [];
}
///////////////////////////// Public Methods //////////////////////////////
// Add an item
add(text, localize, value) {
// Record the item data
this.items.push([ text, localize, value ]);
// Add an <option> element
let option = document.createElement("option");
this.element.append(option);
option.innerText = !localize ? text :
this.app.localize(text, this.substitutions);
}
// Remove all items
clear() {
this.items.splice(0);
this.element.replaceChildren();
this.element.selectedIndex = -1;
}
// Retrieve an item
get(index) {
// Error checking
if (index < 0 || index >= this.items.length)
return null;
// Return the item as an item with properties
let item = this.items[item];
return {
localize: item[1],
text : item[0],
value : item[2]
};
}
// Number of items in the list
get length() { return this.items.length; }
set length(v) { }
// Remove an item
remove(index) {
// Error checking
if (index < 0 || index >= this.length)
return;
// Determine what selectedIndex will be after the operation
let newIndex = index;
if (
newIndex <= this.selectedIndex ||
newIndex == this.length - 1
) newIndex--;
if (
newIndex == -1 &&
this.length != 0
) newIndex = 0;
// Remove the item
this.items.splice(index, 1);
this.element.options[index].remove();
this.element.selectedIndex = newIndex;
}
// Index of the currently selected item
get selectedIndex() { return this.element.selectedIndex; }
set selectedIndex(index) {
if (index < -1 || index >= this.items.length)
return this.element.selectedIndex;
return this.element.selectedIndex = index;
}
// Update an item
set(index, text, localize) {
// Error checking
if (index < 0 || index >= this.items.length)
return;
// Replace the item data
this.items[index] = [ text, localize ];
// Configure the <option> element
this.element.options[index].innerText = !localize ? text :
this.app.localize(text, this.substitutions);
}
// Update the selectedIndex property, firing an event
setSelectedIndex(index) {
// Error checking
if (index < -1 || index >= this.items.length)
return this.selectedIndex;
// Update the element and fire an event
this.element.selectedIndex = index;
this.element.dispatchEvent(new Event("input"));
return index;
}
// Currently selected value
get value() {
return this.selectedIndex == -1 ? null :
this.items[this.selectedIndex][2];
}
set value(value) {
let index = this.items.findIndex(i=>i[2] == value);
if (index != -1)
this.selectedIndex = index;
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
// Label and title
this.localizeLabel();
this.localizeTitle();
// Items
for (let x = 0; x < this.items.length; x++) {
let item = this.items[x];
this.element.options[x].innerText = !item[1] ? item[0] :
this.app.localize(item[0], this.substitutions);
}
}
}
export { register };
+46
View File
@@ -0,0 +1,46 @@
let register = Toolkit => Toolkit.Label =
// Presentational text
class Label extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}, autoId = false) {
super(app, Object.assign({
class: "tk label"
}, options, { style: Object.assign({
cursor : "default",
userSelect: "none",
whiteSpace: "nowrap"
}, options.style || {}) }));
// Configure instance fields
if (autoId)
this.id = Toolkit.id();
}
///////////////////////////// Public Methods //////////////////////////////
// Specify the display text
setText(text, localize) {
this.setString("text", text, localize);
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
if (this.text != null) {
let text = this.text;
this.element.innerText = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
}
}
}
export { register };
+92
View File
@@ -0,0 +1,92 @@
let register = Toolkit => Toolkit.Menu =
// Pop-up menu container
class Menu extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class : "tk menu",
id : Toolkit.id(),
role : "menu",
visibility: true,
visible : false
}, options, { style: Object.assign({
display : "inline-flex",
flexDirection: "column",
position : "absolute",
zIndex : "1"
}, options.style || {}) }));
// Configure instance fields
this.parent = null;
// Configure event handlers
this.addEventListener("focusout", e=>this.onBlur(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child blur
onBlur(e) {
if (this.parent instanceof Toolkit.MenuItem)
this.parent.onBlur(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child menu item
add(item) {
if (!(item instanceof Toolkit.MenuItem) || !super.add(item))
return false;
this.detectIcons();
return true;
}
// Add a sepearator
addSeparator() {
let item = new Toolkit.Component(this.app, { role: "separator" });
super.add(item);
return item;
}
// Remove the menu from its parent menu item or remove a child menu item
remove() {
// Remove child
if (arguments.length != 0) {
if (!super.remove.apply(this, arguments))
return false;
this.detectIcons();
return true;
}
// Remove from parent
this.parent = null;
this.element.remove();
this.element.removeAttribute("aria-labelledby");
this.setVisible(false);
return null;
}
///////////////////////////// Package Methods /////////////////////////////
// Show or hide the icons column
detectIcons() {
this.element.classList[
this.children && this.children.find(i=>
i.visible && i.type == "checkbox") ?
"add" : "remove"
]("icons");
}
};
export { register };
+106
View File
@@ -0,0 +1,106 @@
let register = Toolkit => Toolkit.MenuBar =
// Application menu bar
class MenuBar extends Toolkit.Menu {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
class : "tk menu-bar",
role : "menubar",
visibility: false,
visible : true
}, options, { style: Object.assign({
display : "flex",
flexDirection: "row",
minWidth : "0",
position : "inline"
}, options.style || {}) }));
// Configure event handlers
this.addEventListener("focusout", e=>this.onBlur (e));
this.addEventListener("focusin" , e=>this.onFocus(e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Child blur
onBlur(e) {
if (
this.contains(e.relatedTarget) ||
!this.children || this.children.length == 0
) return;
this.children.forEach(i=>i.expanded = false);
this.children[0].element.setAttribute("tabindex", "0");
}
// Child focus
onFocus(e) {
if (!this.children || this.children.length == 0)
return;
this.children[0].element.setAttribute("tabindex", "-1");
}
///////////////////////////// Public Methods //////////////////////////////
// Add a menu item
add(item) {
super.add(item);
if (item.menu)
this.element.append(item.menu.element);
this.children[0].element.setAttribute("tabindex", "0");
}
// Move focus into the component
focus() {
if (this.children.length != 0)
this.children[0].focus();
}
// Remove a menu item
remove(item) {
// Remove the menu item
if (item.parent == this && item.menu)
item.menu.remove();
super.remove(item);
// Configure focusability
if (this.children && !this.contains(document.activeElement)) {
for (let x = 0; x < this.children.length; x++) {
this.children[x].element
.setAttribute("tabindex", x == 0 ? "0" : "-1");
}
}
}
///////////////////////////// Package Methods /////////////////////////////
// Return focus to the application
blur() {
if (this.children) {
for (let item of this.children) {
item.expanded = false;
item.element.blur();
}
}
if (!this.parent || !this.parent.restoreFocus())
this.focus();
}
// Update localization strings
localize() {
this.localizeLabel(this.element);
}
}
export { register };
+455
View File
@@ -0,0 +1,455 @@
let register = Toolkit => Toolkit.MenuItem =
// Selection within a MenuBar or Menu
class MenuItem extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk menu-item",
id : Toolkit.id(),
role : "menuitem",
tabIndex: "-1"
}, options));
// Configure instance fields
this._expanded = false;
this._menu = null;
// Element
this.content = document.createElement("div");
this.element.append(this.content);
this.disabled = options.disabled;
// Icon column
this.icon = document.createElement("div");
this.icon.className = "icon";
this.content.append(this.icon);
// Label column
this.label = document.createElement("div");
this.label.className = "label";
this.content.append(this.label);
// Control type
switch (options.type) {
case "checkbox":
this.type = "checkbox";
this.checked = !!options.checked;
break;
default:
this.type = "normal";
}
// Event handlers
this.addEventListener("focusout" , e=>this.onBlur (e));
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus lost
onBlur(e) {
if (this.menu && !this.contains(e.relatedTarget))
this.expanded = false;
}
// Key press
onKeyDown(e) {
// Do not process the event
if (e.altKey || e.ctrlKey || e.shiftKey)
return;
// Working variables
let isBar = this.parent && this.parent instanceof Toolkit.MenuBar;
let next = isBar ? "ArrowRight": "ArrowDown";
let prev = isBar ? "ArrowLeft" : "ArrowUp";
let siblings = this.parent && this.parent.children ?
this.parent.children
.filter(i=>i instanceof Toolkit.MenuItem && i.visible) :
[ this ];
let index = siblings.indexOf(this);
let handled = false;
// Process by relative key code
switch (e.key) {
// Select the next sibling
case next:
index = index == -1 ? 0 :
(index + 1) % siblings.length;
if (index < siblings.length) {
let sibling = siblings[index];
if (isBar && sibling.menu && this.expanded)
sibling.expanded = true;
sibling.focus();
}
handled = true;
break;
// Select the previous sibling
case prev:
index = index == -1 ? 0 :
(index + siblings.length - 1) % siblings.length;
if (index < siblings.length) {
let sibling = siblings[index];
if (isBar && sibling.menu && this.expanded)
sibling.expanded = true;
sibling.focus();
}
handled = true;
break;
}
// Process by absolute key code
if (!handled) switch (e.key) {
// Activate the menu item with handling for checks and radios
case " ":
this.activate(false);
break;
// Activate the menu item if in a MenuBar
case "ArrowDown":
if (isBar && this.menu)
this.activate();
break;
// Cycle through menu items in a MenuBar
case "ArrowLeft":
case "ArrowRight": {
let root = this.getRoot();
if (!(root instanceof Toolkit.MenuBar))
break;
let top = root.children.find(i=>i.expanded);
if (top)
return top.onKeyDown(e);
break;
}
// Select the last sibling
case "End":
if (siblings.length != 0)
siblings[siblings.length - 1].focus();
break;
// Activate the menu item
case "Enter":
this.activate();
break;
// Deactivate the menu and return to the parent menu item
case "Escape": {
if (this.expanded)
this.expanded = false;
else if (this.parent &&
this.parent.parent instanceof Toolkit.MenuItem) {
this.parent.parent.expanded = false;
this.parent.parent.focus();
} else if (this.parent instanceof Toolkit.MenuBar)
this.parent.blur();
break;
}
// Select the first sibling
case "Home":
if (siblings.length != 0)
siblings[0].focus();
break;
// Select the next menu item that begins with the typed character
default: {
if (e.key.length != 1)
return;
let key = e.key.toLowerCase();
for (let x = 0; x < siblings.length; x++) {
let sibling = siblings[(index + x + 1) % siblings.length];
if (
(sibling.content.textContent || " ")[0]
.toLowerCase() == key
) {
if (sibling.menu)
sibling.expanded = true;
if (sibling.menu && sibling.menu.children &&
sibling.menu.children[0])
sibling.menu.children[0].focus();
else sibling.focus();
handled = true;
break;
}
}
if (!handled)
return;
}
}
Toolkit.handle(e);
}
// Pointer down
onPointerDown(e) {
this.focus();
// Do not process the event
if (e.button != 0 || this.element.hasPointerCapture(e.pointerId))
return;
if (this.disabled)
return Toolkit.handle(e);
// Does not contain a menu
if (!this.menu) {
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("pushed");
}
// Does contain a menu
else this.expanded = !this.expanded;
Toolkit.handle(e);
}
// Pointer move
onPointerMove(e) {
// Do not process the event
if (this.disabled)
return Toolkit.handle(e);
// Not dragging within element
if (!this.element.hasPointerCapture(e.pointerId)) {
let other = this.parent&&this.parent.children.find(i=>i.expanded);
if (other && other != this) {
this.expanded = true;
this.focus();
}
}
// Dragging within element
else this.element.classList
[this.isWithin(e) ? "add" : "remove"]("pushed");
Toolkit.handle(e);
}
// Pointer up
onPointerUp(e) {
// Do not process the event
if (e.button != 0)
return;
if (this.disabled)
return Toolkit.handle(e);
// Stop dragging
if (this.element.hasPointerCapture(e.pointerId)) {
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("pushed");
}
// Activate the menu item
if (!this.menu && this.isWithin(e))
this.activate();
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Specify whether or not the checkbox is checked
get checked() { return this._checked; }
set checked(checked) {
checked = !!checked;
if (checked === this._checked || this._type != "checkbox")
return;
this._checked = checked;
this.element.setAttribute("aria-checked", checked ? "true" : "false");
}
// Determine whether a component or element is a child of this component
contains(child) {
return super.contains(child) || this.menu && this.menu.contains(child);
}
// Enable or disable the control
get disabled() { return this._disabled; }
set disabled(disabled) {
disabled = !!disabled;
// Error checking
if (disabled === this._disabled)
return;
// Enable or disable the control
this._disabled = disabled;
this.element[disabled ? "setAttribute" : "removeAttribute"]
("aria-disabled", "true");
if (disabled)
this.expanded = false;
}
// Expand or collapse the menu
get expanded() { return this._expanded; }
set expanded(expanded) {
expanded = !!expanded;
// Error checking
if (this._expanded == expanded || !this.menu)
return;
// Configure instance fields
this._expanded = expanded;
// Expand the menu
if (expanded) {
let bounds = this.element.getBoundingClientRect();
Object.assign(this.menu.element.style, {
left: bounds.left + "px",
top : bounds.bottom + "px"
});
this.menu.visible = true;
this.element.setAttribute("aria-expanded", "true");
}
// Collapse the menu and all child sub-menus
else {
this.menu.visible = false;
this.element.setAttribute("aria-expanded", "false");
if (this.children)
this.children.forEach(i=>i.expanded = false);
}
}
// Specify a new menu
get menu() { return this._menu; }
set menu(menu) {
// Error checking
if (menu == this._menu || menu && menu.parent && menu.parent != this)
return;
// Remove the current menu
if (this._menu) {
this.expanded = false;
this._menu.remove();
}
// Configure as regular menu item
if (!menu) {
this.element.removeAttribute("aria-expanded");
this.element.removeAttribute("aria-haspopup");
return;
}
// Associate the menu with the item
this._menu = menu;
menu.parent = this;
if (this.parent)
this.element.after(menu.element);
menu.element.setAttribute("aria-labelledby", this.element.id);
}
// Specify display text
setText(text, localize) {
this.setString("text", text, localize);
}
// Specify the menu item type
get type() { return this._type; }
set type(type) {
switch (type) {
case "checkbox":
if (this._type == "checkbox")
break;
this._type = "checkbox";
this.checked = null;
this.element.classList.add("checkbox");
break;
default:
if (this._type == "normal")
break;
this._type = "normal";
this._checked = false;
this.element.classList.remove("checkbox");
this.element.removeAttribute("aria-checked");
}
}
// Specify whether the element is visible
get visible() { return super.visible; }
set visible(visible) {
super.visible = visible = !!visible;
if (this.parent instanceof Toolkit.Menu)
this.parent.detectIcons();
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
if (this.text != null) {
let text = this.text;
this.label.innerText = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
} else this.label.innerText = "";
}
///////////////////////////// Private Methods /////////////////////////////
// Activate the menu item
activate(blur = true) {
// Error checking
if (this.disabled)
return;
// Expand the sub-menu
if (this.menu) {
this.expanded = true;
if (this.menu.children && this.menu.children[0])
this.menu.children[0].focus();
else this.focus();
}
// Activate the menu item
else {
this.element.dispatchEvent(Toolkit.event("action"));
if (this.type == "normal" || blur) {
let root = this.getRoot();
if (root instanceof Toolkit.MenuBar)
root.blur();
}
}
}
// Locate the root Menu component
getRoot() {
for (let comp = this.parent; comp != this.app; comp = comp.parent) {
if (
comp instanceof Toolkit.Menu &&
!(comp.parent instanceof Toolkit.MenuItem)
) return comp;
}
return null;
}
};
export { register };
+113
View File
@@ -0,0 +1,113 @@
let register = Toolkit => Toolkit.Radio =
// Radio button group manager
class Radio extends Toolkit.Checkbox {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options) {
super(app, options = Object.assign({
class: "tk radio",
role : "radio"
}, options || {}));
// Configure instance fields
this._group = null;
// Configure options
if ("group" in options)
this.group = options.group;
if ("checked" in options) {
this.checked = false;
this.checked = options.checked;
}
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Processing by key
let item = null;
switch (e.key) {
// Activate the radio button
case " ":
case "Enter":
this.checked = true;
break;
// Focus the next button in the group
case "ArrowDown":
case "ArrowRight":
if (this.group == null)
return;
item = this.group.next(this);
break;
// Focus the previous button in the group
case "ArrowLeft":
case "ArrowUp":
if (this.group == null)
return;
item = this.group.previous(this);
break;
default: return;
}
// Select and focus another item in the group
if (item != null && item != this) {
this.group.active = item;
item.focus();
item.element.dispatchEvent(new Event("input"));
}
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// The check box is checked
get checked() { return super.checked; }
set checked(checked) {
super.checked = checked;
if (this.group != null && this != this.group.active && checked)
this.group.active = this;
}
// Managing radio button group
get group() { return this._group; }
set group(group) {
if (group == this.group)
return;
if (group)
group.add(this);
this._group = group ? group : null;
}
///////////////////////////// Private Methods /////////////////////////////
// Specify the checked state
setChecked(checked) {
if (!checked || this.checked)
return;
this.checked = true;
this.element.dispatchEvent(new Event("input"));
}
}
export { register };
+109
View File
@@ -0,0 +1,109 @@
let register = Toolkit => Toolkit.RadioGroup =
// Radio button group manager
class RadioGroup {
///////////////////////// Initialization Methods //////////////////////////
constructor() {
this._active = null;
this.items = [];
}
///////////////////////////// Public Methods //////////////////////////////
// The current active radio button
get active() { return this._active; }
set active(item) {
// Error checking
if (
item == this.active ||
item != null && this.items.indexOf(item) == -1
) return;
// De-select the current active item
if (this.active != null) {
this.active.checked = false;
this.active.element.setAttribute("tabindex", "-1");
}
// Select the new active item
this._active = item ? item : null;
if (item == null)
return;
if (!item.checked)
item.checked = true;
item.element.setAttribute("tabindex", "0");
}
// Add a radio button item
add(item) {
// Error checking
if (
item == null ||
this.items.indexOf(item) != -1
) return;
// Remove the item from its current group
if (item.group != null)
item.group.remove(item);
// Add the item to this group
this.items.push(item);
item.group = this;
item.element.setAttribute("tabindex",
this.items.length == 0 ? "0" : "-1");
return item;
}
// Check whether an element is contained in the radio group
contains(element) {
if (element instanceof Toolkit.Component)
element = element.element;
return !!this.items.find(i=>i.element.contains(element));
}
// Remove a radio button item
remove(item) {
let index = this.items.indexOf(item);
// The item is not in the group
if (index == -1)
return null;
// Remove the item from the group
this.items.splice(index, 1);
item.element.setAttribute("tabindex", "0");
if (this.active == item) {
this.active = null;
if (this.items.length != 0)
this.items[0].element.setAttribute("tabindex", "0");
}
return item;
}
///////////////////////////// Package Methods /////////////////////////////
// Determine the next radio button in the group
next(item) {
let index = this.items.indexOf(item);
return index == -1 ? null :
this.items[(index + 1) % this.items.length];
}
// Determine the previous radio button in the group
previous(item) {
let index = this.items.indexOf(item);
return index == -1 ? null :
this.items[(index + this.items.length - 1) % this.items.length];
}
}
export { register };
+380
View File
@@ -0,0 +1,380 @@
let register = Toolkit => Toolkit.ScrollBar =
// Scrolling control
class ScrollBar extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk scroll-bar",
role : "scrollbar",
tabIndex: "0"
}, options, { style: Object.assign({
display : "inline-grid",
overflow: "hidden"
}, options.style || {}) }));
// Configure instance fields
this._blockIncrement = 50;
this._controls = null;
this._unitIncrement = 1;
// Unit decrement button
this.unitLess = new Toolkit.Button(app,
{ class: "unit-less", role: "", tabIndex: "" });
this.unitLess.addEventListener("action",
e=>this.value -= this.unitIncrement);
this.add(this.unitLess);
// Component
this.addEventListener("keydown" , e=>this.onKeyDown(e));
this.addEventListener("pointerdown", e=>e.preventDefault());
// Track
this.track = new Toolkit.Component(app, {
class: "track",
style: {
display : "grid",
overflow: "hidden"
}
});
this.track.addEventListener("resize", e=>this.onResize());
this.add(this.track);
// Unit increment button
this.unitMore = new Toolkit.Button(app,
{ class: "unit-more", role: "", tabIndex: "" });
this.unitMore.addEventListener("action",
e=>this.value += this.unitIncrement);
this.add(this.unitMore);
// Block decrement track
this.blockLess = new Toolkit.Button(app,
{ class: "block-less", role: "", tabIndex: "" });
this.blockLess.addEventListener("action",
e=>this.value -= this.blockIncrement);
this.track.add(this.blockLess);
// Scroll box
this.thumb = document.createElement("div");
this.thumb.className = "thumb";
this.thumb.addEventListener("pointerdown", e=>this.onThumbDown(e));
this.thumb.addEventListener("pointermove", e=>this.onThumbMove(e));
this.thumb.addEventListener("pointerUp" , e=>this.onThumbUp (e));
this.track.append(this.thumb);
// Block increment track
this.blockMore = new Toolkit.Button(app,
{ class: "block-more", role: "", tabIndex: "" });
this.blockMore.addEventListener("action",
e=>this.value += this.blockIncrement);
this.track.add(this.blockMore);
// Configure options
this.blockIncrement = !("blockIncrement" in options) ?
this._blockIncrement : options.blockIncrement;
this.orientation = !("orientation" in options) ?
"horizontal" : options.orientation;
this.unitIncrement = !("unitIncrement" in options) ?
this._unitIncrement : options.unitIncrement;
// Configure min, max and value
let min = "min" in options ? options.min : 0;
let max = Math.max(min, "max" in options ? options.max : 100);
let value = Math.max(min, Math.min(max,
"value" in options ? options.value : 0));
this.element.setAttribute("aria-valuemax", max);
this.element.setAttribute("aria-valuemin", min);
this.element.setAttribute("aria-valuenow", value);
// Display the element
this.track.element.dispatchEvent(new Event("resize"));
this.onResize();
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Take no action
if (e.altKey || e.ctrlKey || e.shiftKey)
return;
// Processing by key
switch (e.key) {
case "ArrowDown":
if (this.orientation != "vertical")
return;
this.value += this.unitIncrement;
break;
case "ArrowLeft":
if (this.orientation != "horizontal")
return;
this.value -= this.unitIncrement;
break;
case "ArrowRight":
if (this.orientation != "horizontal")
return;
this.value += this.unitIncrement;
break;
case "ArrowUp":
if (this.orientation != "vertical")
return;
this.value -= this.unitIncrement;
break;
case "PageDown":
this.value += this.blockIncrement;
break;
case "PageUp":
this.value -= this.blockIncrement;
break;
default: return;
}
Toolkit.handle(e);
}
// Track resized
onResize() {
let metrics = this.metrics();
let add = metrics.horz ? "width" : "height";
let remove = metrics.horz ? "height" : "width" ;
// Resize the widget elements
this.blockLess.style.removeProperty(remove);
this.blockLess.style.setProperty (add, metrics.pos + "px");
this.thumb .style.removeProperty(remove);
this.thumb .style.setProperty (add, metrics.thumb + "px");
// Indicate whether the entire view is visible
this.element.classList
[metrics.unneeded ? "add" : "remove"]("unneeded");
}
// Thumb pointer down
onThumbDown(e) {
// Prevent the user agent from focusing the element
e.preventDefault();
// Error checking
if (
e.button != 0 ||
this.disabled ||
this.unneeded ||
e.target.hasPointerCapture(e.pointerId)
)
return;
// Begin dragging
this.drag = this.metrics();
this.drag.start = this.drag.horz ? e.screenX : e.screenY;
e.target.setPointerCapture(e.pointerId);
Toolkit.handle(e);
}
// Thumb pointer move
onThumbMove(e) {
// Error checking
if (!e.target.hasPointerCapture(e.pointerId))
return;
// Working variables
let add = this.drag.horz ? "width" : "height";
let remove = this.drag.horz ? "height" : "width" ;
let delta = (this.drag.horz?e.screenX:e.screenY) - this.drag.start;
let max = this.drag.track - this.drag.thumb;
let pos = Math.max(0, Math.min(max, this.drag.pos + delta));
let value = Math.round(this.min + (this.max - this.min) * pos / (this.drag.track || 1));
let scroll = value != this.value;
// Drag the thumb
this.blockLess.style.removeProperty(remove);
this.blockLess.style.setProperty (add, pos + "px");
this.element.setAttribute("aria-valuenow", value);
Toolkit.handle(e);
// Raise a scroll event
if (scroll)
this.event();
}
// Thumb pointer up
onThumbUp(e) {
// Error checking
if (e.button != 0 || !e.target.hasPointerCapture(e))
return;
// Stop dragging
this.drag = null;
e.target.releasePointerCapture(e.pointerId);
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Page adjustment amount
get blockIncrement() { return this._blockIncrement; }
set blockIncrement(amount) {
amount = Math.max(1, amount);
if (amount == this.blockIncrement)
return;
this._blockIncrement = amount;
this.onResize();
}
// Controlling target
get controls() { return this._controls; }
set controls(target) {
if (target) {
this.element.setAttribute("aria-controls",
(target.element || target).id);
} else this.element.removeAttribute("aria-controls");
this._controls = target;
}
// Component cannot be interacted with
get disabled() { return super.disabled; }
set disabled(disabled) {
super .disabled = disabled;
this.unitLess .disabled = disabled;
this.blockLess.disabled = disabled;
this.blockMore.disabled = disabled;
this.unitMore .disabled = disabled;
}
// Maximum value
get max() {
return this.blockIncrement +
parseInt(this.element.getAttribute("aria-valuemax"));
}
set max(max) {
if (max == this.max)
return;
if (max < this.min)
this.element.setAttribute("aria-valuemin", max);
if (max < this.value)
this.element.setAttribute("aria-valuenow", max);
this.element.setAttribute("aria-valuemax", max - this.blockIncrement);
this.onResize();
}
// Minimum value
get min() { return parseInt(this.element.getAttribute("aria-valuemin")); }
set min(min) {
if (min == this.min)
return;
if (min > this.max)
this.element.setAttribute("aria-valuemax",min-this.blockIncrement);
if (min > this.value)
this.element.setAttribute("aria-valuenow", min);
this.element.setAttribute("aria-valuemin", min);
this.onResize();
}
// Layout direction
get orientation() { return this.element.getAttribute("aria-orientation"); }
set orientation(orientation) {
// Orientation is not changing
if (orientation == this.orientation)
return;
// Select which CSS properties to modify
let add, remove;
switch (orientation) {
case "horizontal":
add = "grid-template-columns";
remove = "grid-template-rows";
break;
case "vertical":
add = "grid-template-rows";
remove = "grid-template-columns";
break;
default: return; // Invalid orientation
}
// Configure the element
this.element.style.removeProperty(remove);
this.element.style.setProperty(add, "max-content auto max-content");
this.element.setAttribute("aria-orientation", orientation);
this.track .style.removeProperty(remove);
this.track .style.setProperty(add, "max-content max-content auto");
}
// Line adjustment amount
get unitIncrement() { return this._unitIncrement; }
set unitIncrement(amount) {
amount = Math.max(1, amount);
if (amount == this.unitIncrement)
return;
this._unitIncrement = amount;
this.onResize();
}
// The scroll bar is not needed
get unneeded() { return this.element.classList.contains("unneeded"); }
set unneeded(x) { }
// Current value
get value() {return parseInt(this.element.getAttribute("aria-valuenow"));}
set value(value) {
value = Math.min(this.max - this.blockIncrement,
Math.max(this.min, value));
if (value == this.value)
return;
this.element.setAttribute("aria-valuenow", value);
this.onResize();
this.event();
}
///////////////////////////// Private Methods /////////////////////////////
// Raise a scroll event
event() {
this.element.dispatchEvent(
Object.assign(new Event("scroll"), { scroll: this.value }));
}
// Compute pixel dimensions of inner elements
metrics() {
let horz = this.orientation == "horizontal";
let track = this.track.element.getBoundingClientRect()
[horz ? "width" : "height"];
let block = this.blockIncrement;
let range = this.max - this.min || 1;
let thumb = block >= range ? track :
Math.min(track, Math.max(4, Math.round(block * track / range)));
let pos = Math.round((this.value - this.min) * track / range);
return {
block : block,
horz : horz,
pos : pos,
range : range,
thumb : thumb,
track : track,
unneeded: block >= range
};
}
}
export { register };
+317
View File
@@ -0,0 +1,317 @@
let register = Toolkit => Toolkit.ScrollPane =
// Scrolling container for larger internal elements
class ScrollPane extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class: "tk scroll-pane"
}, options, { style: Object.assign({
display : "inline-grid",
gridAutoRows: "auto max-content",
overflow : "hidden",
position : "relative"
}, options.style || {}) }));
// Configure options
this._overflowX = "auto";
this._overflowY = "auto";
if ("overflowX" in options)
this.overflowX = options.overflowX;
if ("overflowY" in options)
this.overflowY = options.overflowY;
// Component
this.addEventListener("wheel", e=>this.onWheel(e));
// Viewport
this.viewport = new Toolkit.Component(app, {
class: "viewport",
style: {
overflow: "hidden"
}
});
this.viewport.element.id = Toolkit.id();
this.viewport.addEventListener("keydown", e=>this.onKeyDown (e));
this.viewport.addEventListener("resize" , e=>this.onResize ( ));
this.viewport.addEventListener("scroll" , e=>this.onInnerScroll( ));
this.viewport.addEventListener("visibility",
e=>{ if (e.visible) this.onResize(); });
this.add(this.viewport);
// View resize manager
this.viewResizer = new ResizeObserver(()=>this.onResize());
// Vertical scroll bar
this.vscroll = new Toolkit.ScrollBar(app, {
orientation: "vertical",
visibility : true
});
this.vscroll.controls = this.viewport;
this.vscroll.addEventListener("scroll",
e=>this.onOuterScroll(e, true));
this.add(this.vscroll);
// Horizontal scroll bar
this.hscroll = new Toolkit.ScrollBar(app, {
orientation: "horizontal",
visibility : true
});
this.hscroll.controls = this.viewport;
this.hscroll.addEventListener("scroll",
e=>this.onOuterScroll(e, false));
this.add(this.hscroll);
// Corner mask (for when both scroll bars are visible)
this.corner = new Toolkit.Component(app, { class: "corner" });
this.add(this.corner);
// Configure view
this.view = options.view || null;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Processing by key
switch(e.key) {
case "ArrowDown":
this.viewport.element.scrollTop +=
this.vscroll.unitIncrement;
break;
case "ArrowLeft":
this.viewport.element.scrollLeft -=
this.hscroll.unitIncrement;
break;
case "ArrowRight":
this.viewport.element.scrollLeft +=
this.hscroll.unitIncrement;
break;
case "ArrowUp":
this.viewport.element.scrollTop -=
this.vscroll.unitIncrement;
break;
case "PageDown":
this.viewport.element.scrollTop +=
this.vscroll.blockIncrement;
break;
case "PageUp":
this.viewport.element.scrollTop -=
this.vscroll.blockIncrement;
break;
default: return;
}
Toolkit.handle(e);
}
// Component resized
onResize() {
// Error checking
if (!this.viewport)
return;
// Working variables
let viewport = this.viewport.element.getBoundingClientRect();
let scrollHeight =
this.hscroll.element.getBoundingClientRect().height;
let scrollWidth =
this.vscroll.element.getBoundingClientRect().width;
let viewHeight = this.viewHeight;
let viewWidth = this.viewWidth;
let fullHeight =
viewport.height + (this.hscroll.visible ? scrollHeight : 0);
let fullWidth =
viewport.width + (this.vscroll.visible ? scrollWidth : 0);
// Configure scroll bars
this.hscroll.max = viewWidth;
this.hscroll.blockIncrement = viewport.width;
this.hscroll.value = this.viewport.element.scrollLeft;
this.vscroll.max = viewHeight;
this.vscroll.blockIncrement = viewport.height;
this.vscroll.value = this.viewport.element.scrollTop;
// Determine whether the vertical scroll bar is visible
let vert = false;
if (
this.overflowY == "scroll" ||
this.overflowY == "auto" &&
viewHeight > fullHeight &&
scrollWidth <= viewWidth
) {
fullWidth -= scrollWidth;
vert = true;
}
// Determine whether the horizontal scroll bar is visible
let horz = false;
if (
this.overflowX == "scroll" ||
this.overflowX == "auto" &&
viewWidth > fullWidth &&
scrollHeight <= viewHeight
) {
fullHeight -= scrollHeight;
horz = true;
}
// The horizontal scroll bar necessitates the vertical scroll bar
vert = vert ||
this.overflowY == "auto" &&
viewHeight > fullHeight &&
scrollWidth < viewWidth
;
// Configure scroll bar visibility
this.setScrollBars(horz, vert);
}
// View scrolled
onInnerScroll() {
this.hscroll.value = this.viewport.element.scrollLeft;
this.vscroll.value = this.viewport.element.scrollTop;
}
// Scroll bar scrolled
onOuterScroll(e, vertical) {
this.viewport.element[vertical?"scrollTop":"scrollLeft"] = e.scroll;
}
// Mouse wheel scrolled
onWheel(e) {
let delta = e.deltaY;
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || delta == 0)
return;
// Scrolling by pixel
if (e.deltaMode == WheelEvent.DOM_DELTA_PIXEL) {
let max = this.vscroll.unitIncrement * 3;
delta = Math.min(max, Math.max(-max, delta));
}
// Scrolling by line
else if (e.deltaMode == WheelEvent.DOM_DELTA_LINE) {
delta = Math[delta < 0 ? "floor" : "ceil"](delta) *
this.vscroll.unitIncrement;
}
// Scrolling by page
else if (e.deltaMode == WheelEvent.DOM_DELTA_PAGE) {
delta = Math[delta < 0 ? "floor" : "ceil"](delta) *
this.vscroll.blockIncrement;
}
this.viewport.element.scrollTop += delta;
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Horizontal scroll bar policy
get overflowX() { return this._overflowX; }
set overflowX(policy) {
switch (policy) {
case "auto":
case "hidden":
case "scroll":
this._overflowX = policy;
this.onResize();
}
}
// Vertical scroll bar policy
get overflowY() { return this._overflowY; }
set overflowY(policy) {
switch (policy) {
case "auto":
case "hidden":
case "scroll":
this._overflowY = policy;
this.onResize();
}
}
// Horizontal scrolling position
get scrollLeft() { return this.viewport.element.scrollLeft; }
set scrollLeft(left) { this.viewport.element.scrollLeft = left; }
// Vertical scrolling position
get scrollTop() { return this.viewport.element.scrollTop; }
set scrollTop(top) { this.viewport.element.scrollTop = top; }
// Represented scrollable region
get view() {
let ret = this.viewport.element.querySelector("*");
return ret && ret.component || ret || null;
}
set view(view) {
view = view instanceof Toolkit.Component ? view.element : view;
if (view == null) {
view = this.viewport.element.querySelector("*");
if (view)
this.viewResizer.unobserve(view);
this.viewport.element.replaceChildren();
} else {
this.viewport.element.replaceChildren(view);
this.viewResizer.observe(view);
}
}
// Height of the view element
set viewHeight(x) { }
get viewHeight() {
let view = this.view && this.view.element || this.view;
return Math.min(
this.viewport.element.scrollHeight,
view ? view.clientHeight : 0
);
}
// width of the view element
set viewWidth(x) { }
get viewWidth() {
let view = this.view && this.view.element || this.view;
return Math.min(
this.viewport.element.scrollWidth,
view ? view.clientWidth : 0
);
}
///////////////////////////// Private Methods /////////////////////////////
// Configure scroll bar visibility
setScrollBars(horz, vert) {
this.element.style.gridTemplateColumns =
"auto" + (vert ? " max-content" : "");
this.hscroll.visible = horz;
this.hscroll.element.style[horz ? "removeProperty" : "setProperty"]
("position", "absolute");
this.vscroll.visible = vert;
this.vscroll.element.style[vert ? "removeProperty" : "setProperty"]
("position", "absolute");
this.corner.visible = vert && horz;
this.element.classList[horz ? "add" : "remove"]("horizontal");
this.element.classList[vert ? "add" : "remove"]("vertical");
}
}
export { register };
+363
View File
@@ -0,0 +1,363 @@
let register = Toolkit => Toolkit.SplitPane =
// Presentational text
class SplitPane extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class: "tk split-pane"
}, options, { style: Object.assign({
display : "grid",
gridTemplateColumns: "max-content max-content auto",
overflow : "hidden"
}, options.style || {}) }));
// Configure instance fields
this.drag = null;
this._orientation = "left";
this._primary = null;
this.resizer = new ResizeObserver(()=>this.priResize());
this.restore = null;
this._secondary = null;
// Primary placeholder
this.noPrimary = document.createElement("div");
this.noPrimary.id = Toolkit.id();
// Separator widget
this.splitter = document.createElement("div");
this.splitter.setAttribute("aria-controls", this.noPrimary.id);
this.splitter.setAttribute("aria-valuemin", "0");
this.splitter.setAttribute("role", "separator");
this.splitter.setAttribute("tabindex", "0");
this.splitter.addEventListener("keydown",
e=>this.splitKeyDown (e));
this.splitter.addEventListener("pointerdown",
e=>this.splitPointerDown(e));
this.splitter.addEventListener("pointermove",
e=>this.splitPointerMove(e));
this.splitter.addEventListener("pointerup" ,
e=>this.splitPointerUp (e));
// Secondary placeholder
this.noSecondary = document.createElement("div");
// Configure options
if ("orientation" in options)
this.orientation = options.orientation;
if ("primary" in options)
this.primary = options.primary;
if ("secondary" in options)
this.secondary = options.secondary;
this.revalidate();
}
///////////////////////////// Event Handlers //////////////////////////////
// Primary pane resized
priResize() {
let metrics = this.measure();
this.splitter.setAttribute("aria-valuemax", metrics.max);
this.splitter.setAttribute("aria-valuenow", metrics.value);
}
// Splitter key press
splitKeyDown(e) {
// Error checking
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
// Drag is in progress
if (this.drag) {
if (e.key != "Escape")
return;
this.splitter.releasePointerCapture(this.drag.pointerId);
this.value = this.drag.size;
this.drag = null;
Toolkit.handle(e);
return;
}
// Processing by key
let edge = this.orientation;
let horz = edge == "left" || edge == "right";
switch (e.key) {
case "ArrowDown":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? +10 : -10;
break;
case "ArrowLeft":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? -10 : +10;
break;
case "ArrowRight":
if (!horz)
return;
this.restore = null;
this.value += edge == "left" ? +10 : -10;
break;
case "ArrowUp":
if (horz)
return;
this.restore = null;
this.value += edge == "top" ? -10 : +10;
break;
case "End":
this.restore = null;
this.value = this.element.getBoundingClientRect()
[horz ? "width" : "height"];
break;
case "Enter":
if (this.restore !== null) {
this.value = this.restore;
this.restore = null;
} else {
this.restore = this.value;
this.value = 0;
}
break;
case "Home":
this.restore = null;
this.value = 0;
break;
default: return;
}
Toolkit.handle(e);
}
// Splitter pointer down
splitPointerDown(e) {
// Do not obtain focus automatically
e.preventDefault();
// Error checking
if (
e.altKey || e.ctrlKey || e.shiftKey || e.button != 0 ||
this.disabled || this.splitter.hasPointerCapture(e.pointerId)
) return;
// Begin dragging
this.splitter.setPointerCapture(e.poinerId);
let horz = this.orientation == "left" || this.orientation == "right";
let prop = horz ? "width" : "height";
this.drag = {
pointerId: e.pointerId,
size : (this._primary || this.noPrimary)
.getBoundingClientRect()[prop],
start : e[horz ? "clientX" : "clientY" ]
};
Toolkit.handle(e);
}
// Splitter pointer move
splitPointerMove(e) {
// Error checking
if (!this.splitter.hasPointerCapture(e.pointerId))
return;
// Working variables
let horz = this.orientation == "left" || this.orientation == "right";
let delta = e[horz ? "clientX" : "clientY"] - this.drag.start;
let scale = this.orientation == "bottom" ||
this.orientation == "right" ? -1 : 1;
// Resize the primary component
this.restore = null;
this.value = Math.round(this.drag.size + scale * delta);
Toolkit.handle(e);
}
// Splitter pointer up
splitPointerUp(e) {
// Error checking
if (e.button != 0 || !this.splitter.hasPointerCapture(e.pointerId))
return;
// End dragging
this.splitter.releasePointerCapture(e.pointerId);
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Edge containing the primary pane
get orientation() { return this._orientation; }
set orientation(orientation) {
switch (orientation) {
case "bottom":
case "left":
case "right":
case "top":
break;
default: return;
}
if (orientation == this.orientation)
return;
this._orientation = orientation;
this.revalidate();
}
// Primary content pane
get primary() {
return !this._primary ? null :
this._primary.component || this._primary;
}
set primary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this.resizer.unobserve(this._primary || this.noPrimary);
this._primary = element || null;
this.resizer.observe(element || this.noPrimary);
this.splitter.setAttribute("aria-controls",
(element || this.noPrimary).id);
this.revalidate();
}
// Secondary content pane
get secondary() {
return !this._secondary ? null :
this._secondary.component || this._secondary;
}
set secondary(element) {
if (element instanceof Toolkit.Component)
element = element.element;
if (!(element instanceof HTMLElement) || element == this.element)
return;
this._secondary = element || null;
this.revalidate();
}
// Current splitter position
get value() {
return Math.ceil(
(this._primary || this.primary).getBoundingClientRect()
[this.orientation == "left" || this.orientation == "right" ?
"width" : "height"]
);
}
set value(value) {
value = Math.round(value);
// Error checking
if (value == this.value)
return;
// Working variables
let pri = this._primary || this.noPrimary;
let sec = this._secondary || this.noSecondary;
let prop = this.orientation == "left" || this.orientation == "right" ?
"width" : "height";
// Resize the primary component
pri.style[prop] = Math.max(0, value) + "px";
// Ensure the pane didn't become too large due to margin styles
let propPri = pri .getBoundingClientRect()[prop];
let propSec = sec .getBoundingClientRect()[prop];
let propSplit = this.splitter.getBoundingClientRect()[prop];
let propThis = this.element .getBoundingClientRect()[prop];
if (propPri + propSec + propSplit > propThis) {
pri.style[prop] = Math.max(0, Math.floor(
propThis - propSec - propSplit)) + "px";
}
}
///////////////////////////// Private Methods /////////////////////////////
// Measure the current bounds of the child elements
measure() {
let prop = this.orientation == "top" || this.orientation == "bottom" ?
"height" : "width";
let bndThis = this.element .getBoundingClientRect();
let bndSplit = this.splitter.getBoundingClientRect();
let bndPri = (this._primary || this.noPrimary)
.getBoundingClientRect();
return {
max : bndThis[prop],
property: prop,
value : bndPri[prop]
}
}
// Arrange child components
revalidate() {
let horz = true;
let children = [
this._primary || this.noPrimary,
this.splitter,
this._secondary || this.noSecondary
];
// Select styles by orientation
switch (this.orientation) {
case "bottom":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "auto max-content max-content"
});
horz = false;
children.reverse();
break;
case "left":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "max-content max-content auto"
});
break;
case "right":
Object.assign(this.element.style, {
gridAutoRows : "100%",
gridTemplateColumns: "auto max-content max-content"
});
children.reverse();
break;
case "top":
Object.assign(this.element.style, {
gridAutoColumns : "100%",
gridTemplateRows: "max-content max-content auto"
});
horz = false;
break;
}
// Update element
if (horz) {
this.element.style.removeProperty("grid-auto-columns");
this.element.style.removeProperty("grid-template-rows");
this.splitter.className = "tk horizontal";
this.splitter.style.cursor = "ew-resize";
} else {
this.element.style.removeProperty("grid-auto-rows");
this.element.style.removeProperty("grid-template-columns");
this.splitter.className = "tk vertical";
this.splitter.style.cursor = "ns-resize";
}
this.element.replaceChildren(... children);
this.priResize();
}
}
export { register };
+66
View File
@@ -0,0 +1,66 @@
let register = Toolkit => Toolkit.TextBox =
// Check box
class TextBox extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, options = Object.assign({
class : "tk text-box",
tag : "input",
type : "text"
}, options));
this.element.addEventListener("focusout", e=>this.commit ( ));
this.element.addEventListener("keydown" , e=>this.onKeyDown(e));
this.value = options.value || null;
}
///////////////////////////// Event Handlers //////////////////////////////
// Key press
onKeyDown(e) {
if (e.altKey || e.ctrlKey || e.shiftKey || this.disabled)
return;
e.stopPropagation();
if (e.key == "Enter")
this.commit();
}
///////////////////////////// Public Methods //////////////////////////////
// Contained text
get value() { return this.element.value; }
set value(value) {
value = value === undefined || value === null ? "" : value.toString();
if (value == this.value)
return;
this.element.value = value;
}
///////////////////////////// Package Methods /////////////////////////////
// Update localization strings
localize() {
this.localizeLabel();
this.localizeTitle();
}
///////////////////////////// Private Methods /////////////////////////////
// Complete editing
commit() {
this.element.dispatchEvent(new Event("action"));
}
}
export { register };
+51
View File
@@ -0,0 +1,51 @@
// Namespace container for toolkit classes
class Toolkit {
// Next numeric ID
static nextId = 0;
///////////////////////////// Static Methods //////////////////////////////
// Produce a synthetic Event object
static event(type, properties, bubbles = true) {
return Object.assign(
new Event(type, { bubbles: bubbles }),
properties
);
}
// Finalize an event
static handle(event) {
event.stopPropagation();
event.preventDefault();
}
// Generate a unique DOM element ID
static id() {
return "tk" + this.nextId++;
}
}
// Register component classes
(await import(/**/"./Component.js" )).register(Toolkit);
(await import(/**/"./App.js" )).register(Toolkit);
(await import(/**/"./Button.js" )).register(Toolkit);
(await import(/**/"./Checkbox.js" )).register(Toolkit);
(await import(/**/"./Desktop.js" )).register(Toolkit);
(await import(/**/"./DropDown.js" )).register(Toolkit);
(await import(/**/"./Label.js" )).register(Toolkit);
(await import(/**/"./Menu.js" )).register(Toolkit);
(await import(/**/"./MenuBar.js" )).register(Toolkit);
(await import(/**/"./MenuItem.js" )).register(Toolkit);
(await import(/**/"./ScrollBar.js" )).register(Toolkit);
(await import(/**/"./ScrollPane.js")).register(Toolkit);
(await import(/**/"./Radio.js" )).register(Toolkit);
(await import(/**/"./RadioGroup.js")).register(Toolkit);
(await import(/**/"./SplitPane.js" )).register(Toolkit);
(await import(/**/"./TextBox.js" )).register(Toolkit);
(await import(/**/"./Window.js" )).register(Toolkit);
export { Toolkit };
+479
View File
@@ -0,0 +1,479 @@
let register = Toolkit => Toolkit.Window =
class Window extends Toolkit.Component {
//////////////////////////////// Constants ////////////////////////////////
// Resize directions by dragging edge
static RESIZES = {
"nw": { left : true, top : true },
"n" : { top : true },
"ne": { right: true, top : true },
"w" : { left : true },
"e" : { right: true },
"sw": { left : true, bottom: true },
"s" : { bottom: true },
"se": { right: true, bottom: true }
};
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
super(app, Object.assign({
"aria-modal": "false",
class : "tk window",
id : Toolkit.id(),
role : "dialog",
tabIndex : -1,
visibility : true,
visible : false
}, options, { style: Object.assign({
boxSizing : "border-box",
display : "grid",
gridTemplateRows: "max-content auto",
position : "absolute"
}, options.style || {})} ));
// Configure instance fields
this.lastFocus = null;
this.style = getComputedStyle(this.element);
this.text = null;
// Configure event listeners
this.addEventListener("focusin", e=>this.onFocus (e));
this.addEventListener("keydown", e=>this.onKeyDown(e));
// Working variables
let onBorderDown = e=>this.onBorderDown(e);
let onBorderMove = e=>this.onBorderMove(e);
let onBorderUp = e=>this.onBorderUp (e);
let onDragKey = e=>this.onDragKey (e);
// Resizing borders
for (let edge of [ "nw1", "nw2", "n", "ne1", "ne2",
"w", "e", "sw1", "sw2", "s", "se1", "se2"]) {
let border = document.createElement("div");
border.className = edge;
border.edge = edge.replace(/[12]/g, "");
Object.assign(border.style, {
cursor : border.edge + "-resize",
position: "absolute"
});
border.addEventListener("keydown" , onDragKey );
border.addEventListener("pointerdown", onBorderDown);
border.addEventListener("pointermove", onBorderMove);
border.addEventListener("pointerup" , onBorderUp );
this.element.append(border);
}
// Title bar
this.titleBar = document.createElement("div");
this.titleBar.className = "title";
this.titleBar.id = Toolkit.id();
Object.assign(this.titleBar.style, {
display : "grid",
gridTemplateColumns: "auto max-content",
minWidth : "0",
overflow : "hidden"
});
this.element.append(this.titleBar);
this.titleBar.addEventListener("keydown" , e=>this.onDragKey (e));
this.titleBar.addEventListener("pointerdown", e=>this.onTitleDown(e));
this.titleBar.addEventListener("pointermove", e=>this.onTitleMove(e));
this.titleBar.addEventListener("pointerup" , e=>this.onTitleUp (e));
// Title text
this.title = document.createElement("div");
this.title.className = "text";
this.title.id = Toolkit.id();
this.title.innerText = "\u00a0";
this.titleBar.append(this.title);
this.element.setAttribute("aria-labelledby", this.title.id);
// Close button
this.close = new Toolkit.Button(app, {
class : "close-button",
doNotFocus: true
});
this.close.addEventListener("action",
e=>this.element.dispatchEvent(new Event("close")));
this.close.setLabel("{window.close}", true);
this.close.setTitle("{window.close}", true);
this.titleBar.append(this.close.element);
// Client area
this.client = document.createElement("div");
this.client.className = "client";
Object.assign(this.client.style, {
minHeight: "0",
minWidth : "0",
overflow : "hidden"
});
this.element.append(this.client);
}
///////////////////////////// Event Handlers //////////////////////////////
// Border pointer down
onBorderDown(e) {
// Do not drag
if (e.button != 0 || this.app.drag != null)
return;
// Initiate dragging
this.drag = {
height: this.outerHeight,
left : this.left,
top : this.top,
width : this.outerWidth,
x : e.clientX,
y : e.clientY
};
this.drag.bottom = this.drag.top + this.drag.height;
this.drag.right = this.drag.left + this.drag.width;
// Configure event
this.focus();
this.app.drag = e;
Toolkit.handle(e);
}
// Border pointer move
onBorderMove(e) {
// Not dragging
if (this.app.drag != e.target)
return;
// Working variables
let resize = Toolkit.Window.RESIZES[e.target.edge];
let dx = e.clientX - this.drag.x;
let dy = e.clientY - this.drag.y;
let style = getComputedStyle(this.element);
let minHeight =
this.client .getBoundingClientRect().top -
this.titleBar.getBoundingClientRect().top +
parseFloat(style.borderTop ) +
parseFloat(style.borderBottom)
;
// Output bounds
let height = this.drag.height;
let left = this.drag.left;
let top = this.drag.top;
let width = this.drag.width;
// Dragging left
if (resize.left) {
let bParent = this.parent.element.getBoundingClientRect();
left += dx;
left = Math.min(left, this.drag.right - 32);
left = Math.min(left, bParent.width - 16);
width = this.drag.width + this.drag.left - left;
}
// Dragging top
if (resize.top) {
let bParent = this.parent.element.getBoundingClientRect();
top += dy;
top = Math.max(top, 0);
top = Math.min(top, this.drag.bottom - minHeight);
top = Math.min(top, bParent.height - minHeight);
height = this.drag.height + this.drag.top - top;
}
// Dragging right
if (resize.right) {
width += dx;
width = Math.max(width, 32);
width = Math.max(width, 16 - this.drag.left);
}
// Dragging bottom
if (resize.bottom) {
height += dy;
height = Math.max(height, minHeight);
}
// Apply bounds
this.element.style.height = height + "px";
this.element.style.left = left + "px";
this.element.style.top = top + "px";
this.element.style.width = width + "px";
// Configure event
Toolkit.handle(e);
}
// Border pointer up
onBorderUp(e, id) {
// Not dragging
if (e.button != 0 || this.app.drag != e.target)
return;
// Configure instance fields
this.drag = null;
// Configure event
this.app.drag = null;
Toolkit.handle(e);
}
// Key down while dragging
onDragKey(e) {
if (
this.drag != null && e.key == "Escape" &&
!e.ctrlKey && !e.altKey && !e.shiftKey
) this.cancelDrag();
}
// Focus gained
onFocus(e) {
// The element receiving focus is self, or Close button from external
if (
e.target == this.element ||
e.target == this.close.element && !this.contains(e.relatedTarget)
) {
let elm = this.lastFocus;
if (!elm) {
elm = this.getFocusable();
elm = elm[Math.min(1, elm.length - 1)];
}
elm.focus({ preventScroll: true });
}
// The element receiving focus is not self
else if (e.target != this.close.element)
this.lastFocus = e.target;
// Bring the window to the front among its siblings
if (this.parent instanceof Toolkit.Desktop)
this.parent.bringToFront(this);
}
// Window key press
onKeyDown(e) {
// Take no action
if (e.altKey || e.ctrlKey || e.key != "Tab")
return;
// Move focus to the next element in the sequence
let focuses = this.getFocusable();
let nowIndex = focuses.indexOf(document.activeElement) || 0;
let nextIndex = nowIndex + focuses.length + (e.shiftKey ? -1 : 1);
let target = focuses[nextIndex % focuses.length];
Toolkit.handle(e);
target.focus();
}
// Title bar pointer down
onTitleDown(e) {
// Do not drag
if (e.button != 0 || this.app.drag != null)
return;
// Initiate dragging
this.drag = {
height: this.outerHeight,
left : this.left,
top : this.top,
width : this.outerWidth,
x : e.clientX,
y : e.clientY
};
// Configure event
this.focus();
this.app.drag = e;
Toolkit.handle(e);
}
// Title bar pointer move
onTitleMove(e) {
// Not dragging
if (this.app.drag != e.target)
return;
// Working variables
let bParent = this.parent.element.getBoundingClientRect();
// Move horizontally
let left = this.drag.left + e.clientX - this.drag.x;
left = Math.min(left, bParent.width - 16);
left = Math.max(left, 16 - this.drag.width);
this.element.style.left = left + "px";
// Move vertically
let top = this.drag.top + e.clientY - this.drag.y;
top = Math.min(top, bParent.height - this.minHeight);
top = Math.max(top, 0);
this.element.style.top = top + "px";
// Configure event
Toolkit.handle(e);
}
// Title bar pointer up
onTitleUp(e) {
// Not dragging
if (e.button != 0 || this.app.drag != e.target)
return;
// Configure instance fields
this.drag = null;
// Configure event
this.app.drag = null;
Toolkit.handle(e);
}
///////////////////////////// Public Methods //////////////////////////////
// Bring the window to the front among its siblings
bringToFront() {
if (this.parent != null)
this.parent.bringToFront(this);
}
// Set focus on the component
focus() {
if (!this.contains(document.activeElement))
(this.lastFocus || this.element).focus({ preventScroll: true });
if (this.parent instanceof Toolkit.Desktop)
this.parent.bringToFront(this);
}
// Height of client
get height() { return this.client.getBoundingClientRect().height; }
set height(height) {
this.element.style.height =
this.outerHeight - this.height + Math.max(height, 0) + "px";
}
// Position of window left edge
get left() {
return this.element.getBoundingClientRect().left - (
this.parent == null ? 0 :
this.parent.element.getBoundingClientRect().left
);
}
set left(left) {
if (this.parent != null) {
left = Math.min(left,
this.parent.element.getBoundingClientRect().width - 16);
}
left = Math.max(left, 16 - this.outerWidth);
this.element.style.left = left + "px";
}
// Height of entire window
get outerHeight() { return this.element.getBoundingClientRect().height; }
set outerHeight(height) {
height = Math.max(height, this.minHeight);
this.element.style.height = height + "px";
}
// Width of entire window
get outerWidth() { return this.element.getBoundingClientRect().width; }
set outerWidth(width) {
width = Math.max(width, 32);
this.element.style.width = width + "px";
let left = this.left;
if (left + width < 16)
this.element.style.left = 16 - width + "px";
}
// Specify the window title
setTitle(title, localize) {
this.setString("text", title, localize);
}
// Position of window top edge
get top() {
return this.element.getBoundingClientRect().top - (
this.parent == null ? 0 :
this.parent.element.getBoundingClientRect().top
);
}
set top(top) {
if (this.parent != null) {
top = Math.min(top, -this.minHeight +
this.parent.element.getBoundingClientRect().height);
}
top = Math.max(top, 0);
this.element.style.top = top + "px";
}
// Specify whether the element is visible
get visible() { return super.visible; }
set visible(visible) {
let prevSetting = super.visible;
let prevActual = this.isVisible();
super.visible = visible;
let nowActual = this.isVisible();
if (!nowActual && this.contains(document.activeElement))
document.activeElement.blur();
}
// Width of client
get width() { return this.client.getBoundingClientRect().width; }
set width(width) {
this.outerWidth = this.outerWidth - this.width + Math.max(width, 32);
}
///////////////////////////// Package Methods /////////////////////////////
// Add a child component to the primary client region of this component
append(element) {
this.client.append(element);
}
// Update localization strings
localize() {
this.localizeText(this.title);
if ((this.title.textContent || "") == "")
this.title.innerText = "\u00a0"; // &nbsp;
this.close.localize();
}
///////////////////////////// Private Methods /////////////////////////////
// Cancel a move or resize dragging operaiton
cancelDrag() {
this.app.drag = null;
this.element.style.height = this.drag.height + "px";
this.element.style.left = this.drag.left + "px";
this.element.style.top = this.drag.top + "px";
this.element.style.width = this.drag.width + "px";
this.drag = null;
}
// Minimum height of window
get minHeight() {
return (
this.client .getBoundingClientRect().top -
this.element.getBoundingClientRect().top +
parseFloat(this.style.borderBottomWidth)
);
}
}
export { register };