@stanko/ctrls 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ import BezierEasing from "bezier-easing";
2
+ import { BooleanCtrl } from "./ctrl-boolean";
3
+ import { DualRangeCtrl, type DualRangeValue } from "./ctrl-dual-range";
4
+ import { EasingCtrl, type Easing } from "./ctrl-easing";
5
+ import { RadioCtrl } from "./ctrl-radio";
6
+ import { RangeCtrl } from "./ctrl-range";
7
+ import { SeedCtrl } from "./ctrl-seed";
8
+ export interface PRNG {
9
+ (): number;
10
+ }
11
+ export type CtrlType = "boolean" | "range" | "radio" | "seed" | "easing" | "dual-range";
12
+ export type CtrlChangeHandler<T> = (name: string, value: T) => void;
13
+ export type CtrlConfig<T = unknown> = {
14
+ type: CtrlType;
15
+ name: string;
16
+ label?: string;
17
+ defaultValue?: T;
18
+ isRandomizationDisabled?: boolean;
19
+ };
20
+ export interface Ctrl<T> {
21
+ name: string;
22
+ label: string;
23
+ type: CtrlType;
24
+ isRandomizationDisabled: boolean;
25
+ onChange: CtrlChangeHandler<T>;
26
+ parse: (value: string) => T;
27
+ getRandomValue: () => T;
28
+ getDefaultValue: () => T;
29
+ buildUI: () => unknown;
30
+ valueToString: (value?: T) => string;
31
+ update: (value: T) => void;
32
+ element: HTMLElement;
33
+ }
34
+ export type CtrlComponent = BooleanCtrl | RangeCtrl | RadioCtrl | SeedCtrl | EasingCtrl | DualRangeCtrl;
35
+ export type ControlConstructor<T> = new (...args: any[]) => T;
36
+ export interface CtrlTypeRegistry {
37
+ boolean: {
38
+ config: CtrlConfig<boolean>;
39
+ instance: BooleanCtrl;
40
+ value: boolean;
41
+ };
42
+ range: {
43
+ config: CtrlConfig<number> & {
44
+ min: number;
45
+ max: number;
46
+ step?: number;
47
+ };
48
+ instance: RangeCtrl;
49
+ value: number;
50
+ };
51
+ radio: {
52
+ config: CtrlConfig<string> & {
53
+ items: Record<string, string>;
54
+ columns?: 1 | 2 | 3 | 4 | 5;
55
+ };
56
+ instance: RadioCtrl;
57
+ value: string;
58
+ };
59
+ seed: {
60
+ config: CtrlConfig<string>;
61
+ instance: SeedCtrl;
62
+ value: string;
63
+ };
64
+ easing: {
65
+ config: CtrlConfig<Easing> & {
66
+ presets: Record<string, Easing>;
67
+ };
68
+ instance: EasingCtrl;
69
+ value: Easing;
70
+ };
71
+ "dual-range": {
72
+ config: CtrlConfig<DualRangeValue> & {
73
+ min: number;
74
+ max: number;
75
+ step?: number;
76
+ };
77
+ instance: DualRangeCtrl;
78
+ value: DualRangeValue;
79
+ };
80
+ }
81
+ export type TypedControlConfig = CtrlTypeRegistry[keyof CtrlTypeRegistry]["config"];
82
+ type OptionsMap<Configs extends readonly TypedControlConfig[]> = {
83
+ [C in Configs[number] as C["name"]]: CtrlTypeRegistry[C["type"]]["value"];
84
+ } & {
85
+ [C in Extract<Configs[number], {
86
+ type: "easing";
87
+ }> as `${C["name"]}Easing`]: ReturnType<typeof BezierEasing>;
88
+ } & {
89
+ [C in Extract<Configs[number], {
90
+ type: "seed";
91
+ }> as `${C["name"]}Rng`]: PRNG;
92
+ };
93
+ type ControlsOptions = {
94
+ showRandomizeButton?: boolean;
95
+ storage?: "hash" | "none";
96
+ theme?: "system" | "light" | "dark";
97
+ };
98
+ export declare class Ctrls<Configs extends readonly TypedControlConfig[]> {
99
+ options: ControlsOptions;
100
+ controls: CtrlTypeRegistry[keyof CtrlTypeRegistry]["instance"][];
101
+ controlsMap: Record<string, CtrlTypeRegistry[keyof CtrlTypeRegistry]["instance"]>;
102
+ element: HTMLDivElement;
103
+ onChange?: (updatedValues: Partial<ReturnType<typeof this.getValues>>) => void;
104
+ onInput?: (updatedValues: Partial<ReturnType<typeof this.getValues>>) => void;
105
+ constructor(controls: Configs, options?: ControlsOptions);
106
+ buildUI: () => HTMLDivElement;
107
+ addHashListeners: () => void;
108
+ getHash: () => string;
109
+ setHash: () => void;
110
+ updateFromHash: () => void;
111
+ getValues(): OptionsMap<Configs>;
112
+ randomize: () => void;
113
+ }
114
+ export {};
@@ -0,0 +1,158 @@
1
+ import BezierEasing from "bezier-easing";
2
+ import { toCamelCase, toKebabCase, toSpaceCase } from "../utils/string-utils";
3
+ import { BooleanCtrl } from "./ctrl-boolean";
4
+ import { DualRangeCtrl } from "./ctrl-dual-range";
5
+ import { EasingCtrl } from "./ctrl-easing";
6
+ import { RadioCtrl } from "./ctrl-radio";
7
+ import { RangeCtrl } from "./ctrl-range";
8
+ import { SeedCtrl } from "./ctrl-seed";
9
+ import Alea from "../utils/alea";
10
+ import { diceIcon } from "../utils/icons";
11
+ const controlMap = {
12
+ boolean: BooleanCtrl,
13
+ range: RangeCtrl,
14
+ radio: RadioCtrl,
15
+ seed: SeedCtrl,
16
+ easing: EasingCtrl,
17
+ "dual-range": DualRangeCtrl,
18
+ };
19
+ export class Ctrls {
20
+ constructor(controls, options) {
21
+ this.controlsMap = {};
22
+ this.buildUI = () => {
23
+ const element = document.createElement("div");
24
+ element.classList.add("ctrls");
25
+ element.classList.add(`ctrls--${this.options.theme}-theme`);
26
+ this.controls.forEach((control) => {
27
+ element.appendChild(control.element);
28
+ });
29
+ if (this.options.showRandomizeButton) {
30
+ const randomizeButton = document.createElement("button");
31
+ randomizeButton.classList.add("ctrls__randomize", "ctrls__btn", "ctrls__btn--lg");
32
+ randomizeButton.innerHTML = `Randomize ${diceIcon}`;
33
+ randomizeButton.addEventListener("click", this.randomize);
34
+ element.appendChild(randomizeButton);
35
+ }
36
+ return element;
37
+ };
38
+ this.addHashListeners = () => {
39
+ window.addEventListener("hashchange", this.updateFromHash);
40
+ // Update all inputs using initial values from the hash
41
+ this.updateFromHash();
42
+ // Update the hash to make sure all values are reflected in the URL
43
+ // TODO this might not be mandatory, but I think it is a nicer UX
44
+ this.setHash();
45
+ };
46
+ this.getHash = () => {
47
+ const values = this.controls
48
+ .map((control) => {
49
+ return `${toKebabCase(control.name)}:${control.valueToString()}`;
50
+ })
51
+ .join("/");
52
+ return `#/${values}`;
53
+ };
54
+ this.setHash = () => {
55
+ window.location.hash = this.getHash();
56
+ };
57
+ this.updateFromHash = () => {
58
+ const hash = window.location.hash.slice(2); // Remove the leading '#/'
59
+ const pairs = hash.split("/");
60
+ const items = [];
61
+ pairs.forEach((pair) => {
62
+ const [kebabCaseName, value] = pair.split(":");
63
+ const name = toCamelCase(kebabCaseName);
64
+ const control = this.controlsMap[name];
65
+ if (control) {
66
+ const parsed = control.parse(value);
67
+ items.push({
68
+ name,
69
+ value: parsed,
70
+ });
71
+ }
72
+ });
73
+ const updatedValues = {};
74
+ items.forEach((item) => {
75
+ const { name, value } = item;
76
+ const control = this.controlsMap[name];
77
+ if (control && JSON.stringify(value) !== JSON.stringify(control.value)) {
78
+ updatedValues[name] = value;
79
+ control.update(value);
80
+ }
81
+ });
82
+ if (Object.keys(updatedValues).length > 0) {
83
+ this.onChange?.(updatedValues);
84
+ this.onInput?.(updatedValues);
85
+ }
86
+ };
87
+ this.randomize = () => {
88
+ const updatedValues = {};
89
+ this.controls.forEach((control) => {
90
+ if (control.isRandomizationDisabled) {
91
+ return;
92
+ }
93
+ control.value = control.getRandomValue();
94
+ control.update(control.value);
95
+ updatedValues[control.name] = control.value;
96
+ });
97
+ if (Object.keys(updatedValues).length > 0) {
98
+ this.onChange?.(updatedValues);
99
+ this.onInput?.(updatedValues);
100
+ }
101
+ if (this.options.storage === "hash") {
102
+ this.setHash();
103
+ }
104
+ };
105
+ this.options = {
106
+ showRandomizeButton: true,
107
+ storage: "hash",
108
+ theme: "system",
109
+ ...options,
110
+ };
111
+ const onChangeControlHandler = (name, value) => {
112
+ this.onChange?.({ [name]: value });
113
+ if (this.options.storage === "hash") {
114
+ this.setHash();
115
+ }
116
+ };
117
+ const onInputControlHandler = (name, value) => {
118
+ this.onInput?.({ [name]: value });
119
+ };
120
+ this.controls = controls.map((config) => {
121
+ // TODO
122
+ // Document this behaviour
123
+ // This might counter-intuitive for some people,
124
+ // but it is my personal preference to have properties named in camel case
125
+ // when using them in code
126
+ //
127
+ // However, they are going to be converted to kebab case when used in the hash,
128
+ // because it is nicer that URL be all lowercase
129
+ config.name = toCamelCase(config.name);
130
+ // TODO
131
+ // Again, document as it is my personal preference
132
+ if (!config.label) {
133
+ config.label = toSpaceCase(config.name);
134
+ }
135
+ const ControlComponent = controlMap[config.type];
136
+ const control = new ControlComponent(config, onChangeControlHandler, onInputControlHandler);
137
+ this.controlsMap[control.name] = control;
138
+ return control;
139
+ });
140
+ this.element = this.buildUI();
141
+ if (this.options.storage === "hash") {
142
+ this.addHashListeners();
143
+ }
144
+ }
145
+ getValues() {
146
+ const options = {};
147
+ this.controls.forEach((control) => {
148
+ options[control.name] = control.value;
149
+ if (control.type === "easing") {
150
+ options[control.name + "Easing"] = BezierEasing(...control.value);
151
+ }
152
+ else if (control.type === "seed") {
153
+ options[control.name + "Rng"] = Alea(control.value);
154
+ }
155
+ });
156
+ return options;
157
+ }
158
+ }