@morlay/dsh-client-ui-primitives 0.0.2-alpha.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,137 @@
1
+ import type { CSSProps } from "./css.ts";
2
+ import { Token } from "./token.ts";
3
+ import { isNull, isPlainObject, isUndefined, toMerged } from "./toolkit.ts";
4
+
5
+ enum RuleType {
6
+ global,
7
+ keyframes,
8
+ scoped,
9
+ scopedClass,
10
+ }
11
+
12
+ interface Sheet {
13
+ id: string;
14
+ contents: string[];
15
+ selector: string | undefined;
16
+ }
17
+
18
+ export class Styling {
19
+ static create(): Styling {
20
+ return new Styling();
21
+ }
22
+
23
+ static toRule(sheet: Sheet): string {
24
+ return sheet.selector === undefined
25
+ ? sheet.contents.join("")
26
+ : `${sheet.selector} {${sheet.contents.join("")} }`;
27
+ }
28
+
29
+ #token = new Token();
30
+ #sheets = new Map<string, Sheet>();
31
+ #injected = new Set<string>();
32
+
33
+ props = (...styles: (CSSProps | false | null | undefined)[]): Record<string, string> => {
34
+ const merged = mergeStyles(styles);
35
+ if (Object.keys(merged).length === 0) return {};
36
+ const sheet = this.#sheet(RuleType.scoped, this.#rules(merged));
37
+ return sheet === undefined ? {} : { [`data-css-${sheet.id}`]: "" };
38
+ };
39
+
40
+ keyframes = (frames: Record<string, CSSProps> = {}): string => {
41
+ const sheet = this.#sheet(RuleType.keyframes, this.#rules(frames as unknown as CSSProps));
42
+ return sheet?.id ?? "anim-none";
43
+ };
44
+
45
+ injectGlobals = (styles: Record<string, CSSProps> = {}): void => {
46
+ for (const [name, style] of Object.entries(styles)) {
47
+ this.#sheet(RuleType.global, this.#rules(style), name);
48
+ }
49
+ };
50
+
51
+ className = (...styles: (CSSProps | false | null | undefined)[]): string => {
52
+ const merged = mergeStyles(styles);
53
+ if (Object.keys(merged).length === 0) return "";
54
+ const sheet = this.#sheet(RuleType.scopedClass, this.#rules(merged));
55
+ return sheet === undefined ? "" : `cls-${sheet.id}`;
56
+ };
57
+
58
+ sheets = (): string[] => [...this.#sheets.values()].map((sheet) => Styling.toRule(sheet));
59
+
60
+ #sheet(type: RuleType, rules: Generator<string>, selector?: string): Sheet | undefined {
61
+ let hash = 0;
62
+ const contents: string[] = [];
63
+ for (const rule of rules) {
64
+ contents.push(rule);
65
+ hash = incrementalHash(rule, hash);
66
+ }
67
+ if (selector !== undefined) hash = incrementalHash(selector, hash);
68
+ if (hash === 0) return undefined;
69
+
70
+ let id = selector ?? "";
71
+ if (type === RuleType.scoped) id = `s${hash.toString(36)}`;
72
+ if (type === RuleType.scopedClass) id = `c${hash.toString(36)}`;
73
+ if (type === RuleType.global) id = `g${hash.toString(36)}`;
74
+ if (type === RuleType.keyframes) id = `a${hash.toString(36)}`;
75
+
76
+ let resolvedSelector = selector;
77
+ if (resolvedSelector === undefined) {
78
+ if (type === RuleType.keyframes) resolvedSelector = `@keyframes ${id}`;
79
+ if (type === RuleType.scoped) resolvedSelector = `[data-css-${id}]`;
80
+ if (type === RuleType.scopedClass) resolvedSelector = `.cls-${id}`;
81
+ }
82
+
83
+ const sheet: Sheet = { id, contents, selector: resolvedSelector };
84
+ const existing = this.#sheets.get(id);
85
+ if (existing !== undefined && existing.contents.join("") === contents.join("")) return existing;
86
+ this.#sheets.set(id, sheet);
87
+ this.#inject(sheet);
88
+ return sheet;
89
+ }
90
+
91
+ #inject(sheet: Sheet): void {
92
+ if (this.#injected.has(sheet.id)) return;
93
+ this.#injected.add(sheet.id);
94
+ if (typeof document === "undefined") return;
95
+ const element = document.createElement("style");
96
+ element.setAttribute("data-css", sheet.id);
97
+ element.textContent = Styling.toRule(sheet);
98
+ document.head.appendChild(element);
99
+ }
100
+
101
+ *#rules(styles: CSSProps): Generator<string> {
102
+ const nested = new Map<string, CSSProps>();
103
+
104
+ for (const [key, value] of Object.entries(styles)) {
105
+ if (isNull(value) || isUndefined(value)) continue;
106
+ if (isPlainObject(value)) {
107
+ nested.set(key, value as unknown as CSSProps);
108
+ continue;
109
+ }
110
+ yield ` ${this.#token.prop(key)}: ${this.#token.normalize(value)};`;
111
+ }
112
+
113
+ for (const [key, value] of nested.entries()) {
114
+ yield ` ${key} {`;
115
+ yield* this.#rules(value);
116
+ yield " }";
117
+ }
118
+ }
119
+ }
120
+
121
+ function mergeStyles(styles: readonly (CSSProps | false | null | undefined)[]): CSSProps {
122
+ return styles.reduce<CSSProps>(
123
+ (accumulated, style) => (style ? (toMerged(accumulated, style) as CSSProps) : accumulated),
124
+ {},
125
+ );
126
+ }
127
+
128
+ function incrementalHash(value: string, previous = 0): number {
129
+ let hash = previous;
130
+ for (let index = 0; index < value.length; index += 1) {
131
+ hash = (hash << 5) - hash + value.charCodeAt(index);
132
+ hash &= 0x7fffffff;
133
+ }
134
+ return hash;
135
+ }
136
+
137
+ export const styling = Styling.create();
@@ -0,0 +1,275 @@
1
+ import type { CSSProps, CSSValue } from "./css.ts";
2
+ import {
3
+ isFunction,
4
+ isNull,
5
+ isPlainObject,
6
+ isString,
7
+ isUndefined,
8
+ mapKeys,
9
+ toMerged,
10
+ } from "./toolkit.ts";
11
+
12
+ const cssVarSymbol = Symbol("cssVar");
13
+ const tokensSymbol = Symbol("tokens");
14
+ const tokenInstSymbol = Symbol("tokenInst");
15
+
16
+ export interface CSSVarRef {
17
+ (): Iterable<string>;
18
+ toString(): string;
19
+ [Symbol.toPrimitive](hint: string): string;
20
+ }
21
+
22
+ export type TokenVars<T> = (T extends string | true
23
+ ? CSSVarRef
24
+ : { readonly [K in keyof T]: TokenVars<T[K]> }) &
25
+ CSSVarRef;
26
+
27
+ export type Tokens = Record<string, unknown>;
28
+
29
+ const SELF = "$";
30
+
31
+ function tokenVars<T extends Tokens>(token: Token, tokens: T, path: string[]): TokenVars<T> {
32
+ const reserved = new Set(["constructor", "toJSON", "__proto__"]);
33
+
34
+ return new Proxy(() => {}, {
35
+ apply() {
36
+ return Token.fallbackVar(token.cssVar(path));
37
+ },
38
+ get(_target, property) {
39
+ if (
40
+ property === Symbol.toPrimitive ||
41
+ property === Symbol.toStringTag ||
42
+ property === "toString"
43
+ ) {
44
+ return () => `var(${token.cssVar(path)})`;
45
+ }
46
+ if (property === "valueOf") return () => Token.fallbackVar(token.cssVar(path));
47
+ if (property === tokenInstSymbol) return token;
48
+ if (property === tokensSymbol) return tokens;
49
+ if (property === cssVarSymbol) return token.cssVar(path);
50
+ if (!isString(property) || reserved.has(property)) return undefined;
51
+ const next = (tokens as Record<string, unknown>)[property];
52
+ if (isUndefined(next)) return undefined;
53
+ return tokenVars(token, next as Tokens, [...path, property]);
54
+ },
55
+ }) as unknown as TokenVars<T>;
56
+ }
57
+
58
+ export class Token {
59
+ static vars<T extends Tokens>(tokens: T, options: { prefix?: string } = {}): TokenVars<T> {
60
+ return tokenVars<T>(new Token(options.prefix ?? ""), tokens, []);
61
+ }
62
+
63
+ static extendsVars<T extends Tokens, E extends Tokens>(
64
+ vars: TokenVars<T>,
65
+ extra: E,
66
+ ): TokenVars<T & E> {
67
+ const tokens = (vars as unknown as Record<symbol, unknown>)[tokensSymbol] ?? {};
68
+ return Token.vars(toMerged(tokens, extra) as T & E);
69
+ }
70
+
71
+ static assignVars<T extends Tokens>(
72
+ vars: TokenVars<T>,
73
+ overwrites?: Partial<T>,
74
+ ): Record<`--${string}`, CSSValue> {
75
+ const tokens = (overwrites ??
76
+ (vars as unknown as Record<symbol, unknown>)[tokensSymbol] ??
77
+ {}) as T;
78
+ const token =
79
+ ((vars as unknown as Record<symbol, unknown>)[tokenInstSymbol] as Token | undefined) ??
80
+ new Token("");
81
+
82
+ const result: Record<string, CSSValue> = {};
83
+ for (const [name, value] of flattenTokens(token, tokens, []))
84
+ result[name] = token.normalize(value);
85
+ return result as Record<`--${string}`, CSSValue>;
86
+ }
87
+
88
+ static variants(variants: Record<string, CSSProps>): Record<string, CSSProps> {
89
+ return mapKeys(variants, (key) => `&[data-variant="${key}"]`) as Record<string, CSSProps>;
90
+ }
91
+
92
+ static val<T extends string | number = string>(
93
+ strings: TemplateStringsArray,
94
+ ...values: CSSValue<T>[]
95
+ ): () => Iterable<T> {
96
+ return function* (): Iterable<T> {
97
+ for (let index = 0; index < strings.length; index += 1) {
98
+ const literal = strings[index];
99
+ if (literal) yield literal as T;
100
+ if (index >= values.length) continue;
101
+ const value = values[index];
102
+ yield* isFunction(value) ? value() : [value as T];
103
+ }
104
+ };
105
+ }
106
+
107
+ static calc<T extends string | number = string>(
108
+ strings: TemplateStringsArray,
109
+ ...values: CSSValue<T>[]
110
+ ): () => Iterable<T> {
111
+ return combinator("calc(", ")", strings, values);
112
+ }
113
+
114
+ static min<T extends string | number = string>(
115
+ strings: TemplateStringsArray,
116
+ ...values: CSSValue<T>[]
117
+ ): () => Iterable<T> {
118
+ return combinator("min(", ")", strings, values);
119
+ }
120
+
121
+ static max<T extends string | number = string>(
122
+ strings: TemplateStringsArray,
123
+ ...values: CSSValue<T>[]
124
+ ): () => Iterable<T> {
125
+ return combinator("max(", ")", strings, values);
126
+ }
127
+
128
+ static url<T extends string | number = string>(
129
+ strings: TemplateStringsArray,
130
+ ...values: CSSValue<T>[]
131
+ ): () => Iterable<T> {
132
+ return combinator("url(", ")", strings, values);
133
+ }
134
+
135
+ static colorMix<T extends string | number = string>(
136
+ strings: TemplateStringsArray,
137
+ ...values: CSSValue<T>[]
138
+ ): () => Iterable<T> {
139
+ return combinator("color-mix(", ")", strings, values);
140
+ }
141
+
142
+ static colorScale(
143
+ color: CSSValue<string>,
144
+ options: {
145
+ alpha?: CSSValue<number>;
146
+ lightness?: CSSValue<number>;
147
+ saturation?: CSSValue<number>;
148
+ whiteness?: CSSValue<number>;
149
+ blackness?: CSSValue<number>;
150
+ } = {},
151
+ ): CSSValue<string> {
152
+ const channel = (
153
+ name: string,
154
+ amount: CSSValue<number>,
155
+ max: number,
156
+ ): (() => Iterable<string>) =>
157
+ Token.calc<string>`${name as unknown as string} + (${String(max)} - ${name as unknown as string}) * max(${amount as unknown as CSSValue<string>}, 0) + ${name as unknown as string} * min(${amount as unknown as CSSValue<string>}, 0)`;
158
+ const mix = (
159
+ from: CSSValue<string>,
160
+ to: CSSValue<string>,
161
+ amount: CSSValue<number>,
162
+ ): (() => Iterable<string>) =>
163
+ Token.colorMix<string>`in srgb, ${from}, ${to} ${Token.calc<string>`${amount as unknown as CSSValue<string>} * 100%`}`;
164
+
165
+ let result: CSSValue<string> = color;
166
+ if (
167
+ !isUndefined(options.alpha) ||
168
+ !isUndefined(options.lightness) ||
169
+ !isUndefined(options.saturation)
170
+ ) {
171
+ result = Token.val<string>`hsl(from ${result} h ${
172
+ isUndefined(options.saturation)
173
+ ? ("s" as unknown as CSSValue<string>)
174
+ : channel("s", options.saturation, 100)
175
+ } ${isUndefined(options.lightness) ? ("l" as unknown as CSSValue<string>) : channel("l", options.lightness, 100)} / ${
176
+ isUndefined(options.alpha)
177
+ ? ("alpha" as unknown as CSSValue<string>)
178
+ : channel("alpha", options.alpha, 1)
179
+ })`;
180
+ }
181
+ if (!isUndefined(options.whiteness)) result = mix(result, "white", options.whiteness);
182
+ if (!isUndefined(options.blackness)) result = mix(result, "black", options.blackness);
183
+ return result;
184
+ }
185
+
186
+ static fallbackVar<T extends string | number = string>(
187
+ value: CSSValue<T>,
188
+ ...fallbacks: CSSValue<T>[]
189
+ ): CSSValue<T> {
190
+ return Object.assign(
191
+ function* (): Iterable<T> {
192
+ yield "var(" as T;
193
+ for (const [index, item] of [value, ...fallbacks].entries()) {
194
+ if (index > 0) yield ", " as T;
195
+ yield* asIterable(item);
196
+ }
197
+ yield ")" as T;
198
+ },
199
+ { [cssVarSymbol]: value },
200
+ );
201
+ }
202
+
203
+ static collect<T extends string | number>(value: Iterable<T> | (() => Iterable<T>)): string {
204
+ const iterable = isFunction(value) ? value() : value;
205
+ let result = "";
206
+ for (const item of iterable) result += `${item}`;
207
+ return result;
208
+ }
209
+
210
+ constructor(public readonly prefix: string = "") {}
211
+
212
+ prop = (key: string): string => (key.startsWith("--") ? key : kebabCase(key));
213
+
214
+ normalize = (input: unknown): string => {
215
+ const value = isFunction(input) ? input() : input;
216
+ if (typeof value === "number") return `${value}`;
217
+ if (typeof value === "string") return value;
218
+ if (isUndefined(value) || isNull(value)) return "";
219
+
220
+ if (isFunction(value)) return Token.collect(value as () => Iterable<string | number>);
221
+ if (isPlainObject(value) && Symbol.iterator in value)
222
+ return Token.collect(value as Iterable<string | number>);
223
+ return "";
224
+ };
225
+
226
+ cssVar = (paths: readonly string[]): string =>
227
+ `--${[this.prefix, ...paths].filter(Boolean).join("-")}`;
228
+ }
229
+
230
+ function combinator<T extends string | number>(
231
+ open: string,
232
+ close: string,
233
+ strings: TemplateStringsArray,
234
+ values: CSSValue<T>[],
235
+ ): () => Iterable<T> {
236
+ return function* (): Iterable<T> {
237
+ yield open as T;
238
+ yield* Token.val<T>(strings, ...values)();
239
+ yield close as T;
240
+ };
241
+ }
242
+
243
+ function* asIterable<T extends string | number>(value: CSSValue<T>): Iterable<T> {
244
+ const resolved: unknown = isFunction(value) ? (value as () => unknown)() : value;
245
+
246
+ const variable = (resolved as Record<symbol, unknown> | undefined)?.[cssVarSymbol];
247
+ if (!isUndefined(variable)) {
248
+ yield variable as T;
249
+ return;
250
+ }
251
+ if (typeof resolved === "object" && resolved !== null && Symbol.iterator in resolved) {
252
+ yield* resolved as Iterable<T>;
253
+ return;
254
+ }
255
+ yield resolved as T;
256
+ }
257
+
258
+ function* flattenTokens(
259
+ token: Token,
260
+ value: Tokens,
261
+ parents: string[],
262
+ ): Generator<[string, unknown]> {
263
+ for (const [property, child] of Object.entries(value)) {
264
+ if (property === SELF) continue;
265
+ if (isPlainObject(child)) {
266
+ yield* flattenTokens(token, child, [...parents, property]);
267
+ continue;
268
+ }
269
+ yield [token.cssVar([...parents, property]), child];
270
+ }
271
+ }
272
+
273
+ function kebabCase(value: string): string {
274
+ return value.replace(/([A-Z])/g, "-$1").toLowerCase();
275
+ }
@@ -0,0 +1,37 @@
1
+ export function isUndefined(value: unknown): value is undefined {
2
+ return value === undefined;
3
+ }
4
+
5
+ export function isNull(value: unknown): value is null {
6
+ return value === null;
7
+ }
8
+
9
+ export function isString(value: unknown): value is string {
10
+ return typeof value === "string";
11
+ }
12
+
13
+ export function isFunction(value: unknown): value is (...args: never[]) => unknown {
14
+ return typeof value === "function";
15
+ }
16
+
17
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ export function toMerged<T>(target: T, source: unknown): T {
22
+ if (!isPlainObject(target) || !isPlainObject(source)) return source as T;
23
+ const result: Record<string, unknown> = { ...target };
24
+ for (const [key, value] of Object.entries(source)) {
25
+ result[key] = key in result ? toMerged(result[key], value) : value;
26
+ }
27
+ return result as T;
28
+ }
29
+
30
+ export function mapKeys<T, K extends string>(
31
+ source: Record<string, T>,
32
+ map: (key: string, value: T) => K,
33
+ ): Record<K, T> {
34
+ const result = {} as Record<K, T>;
35
+ for (const [key, value] of Object.entries(source)) result[map(key, value)] = value;
36
+ return result;
37
+ }