uicore-ts
Version:
UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha
547 lines (407 loc) • 16.1 kB
text/typescript
import { UIObject } from "./UIObject"
export interface UIColorDescriptor {
red: number;
green: number;
blue: number;
alpha?: number;
}
export type UIColorSemanticKey = string
export class UIColor extends UIObject {
// --- Semantic color registry ---
static _liveColors: WeakRef<UIColor>[] = []
static _registrationMap = new Map<string, UIColor>()
static _cssSubscriptions = new Map<UIColorSemanticKey, Set<() => void>>()
// --- Instance fields ---
stringValue: string
semanticKey?: UIColorSemanticKey
_semanticClass?: typeof UIColor
_elementRef?: HTMLElement
_styleProperty?: string
constructor(stringValue: string, semanticKey?: UIColorSemanticKey) {
super()
this.stringValue = stringValue
this.semanticKey = semanticKey
if (semanticKey) {
this._semanticClass = this.constructor as typeof UIColor
}
}
override toString() {
return this.stringValue
}
// --- Semantic apply ---
/**
* Re-resolves this instance's stringValue from its class's static getter
* matching the semanticKey, then writes the new value directly to the DOM
* via the stored element reference and style property.
* No-op if this instance has no semanticKey.
*/
apply() {
if (!this.semanticKey) { return }
const colorClass = this._semanticClass ?? this.constructor as typeof UIColor
const newColor = (colorClass as any)[this.semanticKey] as UIColor | undefined
if (!newColor) { return }
this.stringValue = newColor.stringValue
const element = this._elementRef
if (!element || !this._styleProperty) { return }
(element.style as any)[this._styleProperty] = this.stringValue
}
/**
* Assigns a semantic key and the class that owns it to this color instance.
* Intended for derived colors that should participate in theme switching,
* e.g. `BSColor._primaryBase.colorWithAlpha(0.5).withSemanticKey("primaryShadow", BSColor)`.
* Returns `this` for fluent chaining.
*/
withSemanticKey(semanticKey: UIColorSemanticKey, semanticClass: typeof UIColor): this {
this.semanticKey = semanticKey
this._semanticClass = semanticClass
return this
}
/**
* Iterates all live registered UIColor instances, calls apply() on each,
* compacts dead WeakRefs in the same pass, then fires any CSS subscriptions
* whose semantic key was affected.
*/
static applySemanticColors() {
const affectedKeys = new Set<UIColorSemanticKey>()
const live: WeakRef<UIColor>[] = []
for (const ref of UIColor._liveColors) {
const color = ref.deref()
if (!color) {
continue
}
live.push(ref)
if (color.semanticKey) {
affectedKeys.add(color.semanticKey)
}
color.apply()
}
UIColor._liveColors = live
for (const key of affectedKeys) {
UIColor._cssSubscriptions.get(key)?.forEach(callback => callback())
}
}
/**
* Updates the backing field for a semantic color and re-applies all live
* semantic colors. The backing field is always `_` + semanticKey, e.g.
* `BSColor.updateSemanticColor("primary", "#ff0000")` sets `BSColor._primary`.
* Called as a static method on the subclass that owns the color.
*/
static updateSemanticColor(semanticKey: UIColorSemanticKey, value: string) {
(this as any)["_" + semanticKey] = value
UIColor.applySemanticColors()
}
/**
* Registers a callback to be fired when applySemanticColors() affects
* the given semantic key. Intended for injected CSS blocks that cannot
* be tracked via the colorStyleProxy.
*/
static subscribe(semanticKey: UIColorSemanticKey, callback: () => void) {
if (!UIColor._cssSubscriptions.has(semanticKey)) {
UIColor._cssSubscriptions.set(semanticKey, new Set())
}
UIColor._cssSubscriptions.get(semanticKey)!.add(callback)
}
static unsubscribe(semanticKey: UIColorSemanticKey, callback: () => void) {
UIColor._cssSubscriptions.get(semanticKey)?.delete(callback)
}
// --- Named colors ---
static get redColor() {
return new UIColor("red")
}
static get blueColor() {
return new UIColor("blue")
}
static get greenColor() {
return new UIColor("green")
}
static get yellowColor() {
return new UIColor("yellow")
}
static get blackColor() {
return new UIColor("black")
}
static get brownColor() {
return new UIColor("brown")
}
static get whiteColor() {
return new UIColor("white")
}
static get greyColor() {
return new UIColor("grey")
}
static get lightGreyColor() {
return new UIColor("lightgrey")
}
static get transparentColor() {
return new UIColor("transparent")
}
static get clearColor() {
return new UIColor("transparent")
}
static get undefinedColor() {
return new UIColor("")
}
static get nilColor() {
return new UIColor("")
}
static nameToHex(name: string) {
return {
"aliceblue": "#f0f8ff",
"antiquewhite": "#faebd7",
"aqua": "#00ffff",
"aquamarine": "#7fffd4",
"azure": "#f0ffff",
"beige": "#f5f5dc",
"bisque": "#ffe4c4",
"black": "#000000",
"blanchedalmond": "#ffebcd",
"blue": "#0000ff",
"blueviolet": "#8a2be2",
"brown": "#a52a2a",
"burlywood": "#deb887",
"cadetblue": "#5f9ea0",
"chartreuse": "#7fff00",
"chocolate": "#d2691e",
"coral": "#ff7f50",
"cornflowerblue": "#6495ed",
"cornsilk": "#fff8dc",
"crimson": "#dc143c",
"cyan": "#00ffff",
"darkblue": "#00008b",
"darkcyan": "#008b8b",
"darkgoldenrod": "#b8860b",
"darkgray": "#a9a9a9",
"darkgreen": "#006400",
"darkkhaki": "#bdb76b",
"darkmagenta": "#8b008b",
"darkolivegreen": "#556b2f",
"darkorange": "#ff8c00",
"darkorchid": "#9932cc",
"darkred": "#8b0000",
"darksalmon": "#e9967a",
"darkseagreen": "#8fbc8f",
"darkslateblue": "#483d8b",
"darkslategray": "#2f4f4f",
"darkturquoise": "#00ced1",
"darkviolet": "#9400d3",
"deeppink": "#ff1493",
"deepskyblue": "#00bfff",
"dimgray": "#696969",
"dodgerblue": "#1e90ff",
"firebrick": "#b22222",
"floralwhite": "#fffaf0",
"forestgreen": "#228b22",
"fuchsia": "#ff00ff",
"gainsboro": "#dcdcdc",
"ghostwhite": "#f8f8ff",
"gold": "#ffd700",
"goldenrod": "#daa520",
"gray": "#808080",
"green": "#008000",
"greenyellow": "#adff2f",
"honeydew": "#f0fff0",
"hotpink": "#ff69b4",
"indianred ": "#cd5c5c",
"indigo": "#4b0082",
"ivory": "#fffff0",
"khaki": "#f0e68c",
"lavender": "#e6e6fa",
"lavenderblush": "#fff0f5",
"lawngreen": "#7cfc00",
"lemonchiffon": "#fffacd",
"lightblue": "#add8e6",
"lightcoral": "#f08080",
"lightcyan": "#e0ffff",
"lightgoldenrodyellow": "#fafad2",
"lightgrey": "#d3d3d3",
"lightgreen": "#90ee90",
"lightpink": "#ffb6c1",
"lightsalmon": "#ffa07a",
"lightseagreen": "#20b2aa",
"lightskyblue": "#87cefa",
"lightslategray": "#778899",
"lightsteelblue": "#b0c4de",
"lightyellow": "#ffffe0",
"lime": "#00ff00",
"limegreen": "#32cd32",
"linen": "#faf0e6",
"magenta": "#ff00ff",
"maroon": "#800000",
"mediumaquamarine": "#66cdaa",
"mediumblue": "#0000cd",
"mediumorchid": "#ba55d3",
"mediumpurple": "#9370d8",
"mediumseagreen": "#3cb371",
"mediumslateblue": "#7b68ee",
"mediumspringgreen": "#00fa9a",
"mediumturquoise": "#48d1cc",
"mediumvioletred": "#c71585",
"midnightblue": "#191970",
"mintcream": "#f5fffa",
"mistyrose": "#ffe4e1",
"moccasin": "#ffe4b5",
"navajowhite": "#ffdead",
"navy": "#000080",
"oldlace": "#fdf5e6",
"olive": "#808000",
"olivedrab": "#6b8e23",
"orange": "#ffa500",
"orangered": "#ff4500",
"orchid": "#da70d6",
"palegoldenrod": "#eee8aa",
"palegreen": "#98fb98",
"paleturquoise": "#afeeee",
"palevioletred": "#d87093",
"papayawhip": "#ffefd5",
"peachpuff": "#ffdab9",
"peru": "#cd853f",
"pink": "#ffc0cb",
"plum": "#dda0dd",
"powderblue": "#b0e0e6",
"purple": "#800080",
"red": "#ff0000",
"rosybrown": "#bc8f8f",
"royalblue": "#4169e1",
"saddlebrown": "#8b4513",
"salmon": "#fa8072",
"sandybrown": "#f4a460",
"seagreen": "#2e8b57",
"seashell": "#fff5ee",
"sienna": "#a0522d",
"silver": "#c0c0c0",
"skyblue": "#87ceeb",
"slateblue": "#6a5acd",
"slategray": "#708090",
"snow": "#fffafa",
"springgreen": "#00ff7f",
"steelblue": "#4682b4",
"tan": "#d2b48c",
"teal": "#008080",
"thistle": "#d8bfd8",
"tomato": "#ff6347",
"turquoise": "#40e0d0",
"violet": "#ee82ee",
"wheat": "#f5deb3",
"white": "#ffffff",
"whitesmoke": "#f5f5f5",
"yellow": "#ffff00",
"yellowgreen": "#9acd32"
}[name.toLowerCase()]
}
static hexToDescriptor(c: string): UIColorDescriptor {
if (c[0] === "#") {
c = c.substr(1)
}
const r = parseInt(c.slice(0, 2), 16)
const g = parseInt(c.slice(2, 4), 16)
const b = parseInt(c.slice(4, 6), 16)
const a = parseInt(c.slice(6, 8), 16)
const result = { "red": r, "green": g, "blue": b, "alpha": a }
return result
}
static rgbToDescriptor(colorString: string) {
if (colorString.startsWith("rgba(")) {
colorString = colorString.slice(5, colorString.length - 1)
}
if (colorString.startsWith("rgb(")) {
colorString = colorString.slice(4, colorString.length - 1) + ", 0"
}
const components = colorString.split(",")
const result = {
"red": Number(components[0]),
"green": Number(components[1]),
"blue": Number(components[2]),
"alpha": Number(components[3])
}
return result
}
get colorDescriptor(): UIColorDescriptor {
var descriptor
const colorHEXFromName = UIColor.nameToHex(this.stringValue)
if (this.stringValue.startsWith("rgb")) {
descriptor = UIColor.rgbToDescriptor(this.stringValue)
}
else if (colorHEXFromName) {
descriptor = UIColor.hexToDescriptor(colorHEXFromName)
}
else {
descriptor = UIColor.hexToDescriptor(this.stringValue)
}
return descriptor
}
colorWithRed(red: number) {
const descriptor = this.colorDescriptor
return new UIColor(
"rgba(" + red + "," + descriptor.green + "," + descriptor.blue + "," + descriptor.alpha + ")"
)
}
colorWithGreen(green: number) {
const descriptor = this.colorDescriptor
return new UIColor(
"rgba(" + descriptor.red + "," + green + "," + descriptor.blue + "," + descriptor.alpha + ")"
)
}
colorWithBlue(blue: number) {
const descriptor = this.colorDescriptor
return new UIColor(
"rgba(" + descriptor.red + "," + descriptor.green + "," + blue + "," + descriptor.alpha + ")"
)
}
colorWithAlpha(alpha: number) {
const descriptor = this.colorDescriptor
return new UIColor(
"rgba(" + descriptor.red + "," + descriptor.green + "," + descriptor.blue + "," + alpha + ")"
)
}
static colorWithRGBA(red: number, green: number, blue: number, alpha: number = 1) {
const result = new UIColor("rgba(" + red + "," + green + "," + blue + "," + alpha + ")")
return result
}
static colorWithDescriptor(descriptor: UIColorDescriptor) {
const red = Math.min(255, Math.max(0, descriptor.red)).toFixed(0)
const green = Math.min(255, Math.max(0, descriptor.green)).toFixed(0)
const blue = Math.min(255, Math.max(0, descriptor.blue)).toFixed(0)
const alpha = this.defaultAlphaToOne(descriptor.alpha)
return new UIColor("rgba(" + red + "," + green + "," + blue + "," + alpha + ")")
}
private static defaultAlphaToOne(value = 1) {
if (value != value) {
value = 1
}
return value
}
colorByMultiplyingRGB(multiplier: number) {
const descriptor = this.colorDescriptor
descriptor.red = descriptor.red * multiplier
descriptor.green = descriptor.green * multiplier
descriptor.blue = descriptor.blue * multiplier
return UIColor.colorWithDescriptor(descriptor)
}
/**
* Returns the perceptual lightness (L*) of this color in the range [0, 1],
* using the CIELAB formula. 0 = absolute black, 1 = absolute white.
* Unlike raw relative luminance, this scale is perceptually uniform —
* 0.5 is the genuine visual midpoint between black and white.
*/
get perceivedLightness(): number {
const descriptor = this.colorDescriptor
const linearise = (channel: number) => {
const normalised = channel / 255
if (normalised <= 0.04045) {
return normalised / 12.92
}
return Math.pow((normalised + 0.055) / 1.055, 2.4)
}
const luminance = 0.2126 * linearise(descriptor.red)
+ 0.7152 * linearise(descriptor.green)
+ 0.0722 * linearise(descriptor.blue)
const epsilon = Math.pow(6 / 29, 3) // ~0.00886
const f = luminance > epsilon
? Math.pow(luminance, 1 / 3)
: (Math.pow(29 / 6, 2) / 3) * luminance + 4 / 29
return (116 * f - 16) / 100
}
get isLight(): boolean {
return this.perceivedLightness >= 0.5
}
}