purse-styles 0.0.1

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.
package/src/purse.tsx ADDED
@@ -0,0 +1,576 @@
1
+ import CSS from "csstype"
2
+ import { entries, isObject, mapValues } from "lodash-es"
3
+ import React, {
4
+ DependencyList,
5
+ createContext,
6
+ useInsertionEffect,
7
+ useLayoutEffect,
8
+ useMemo,
9
+ } from "react"
10
+ import { clsx } from "./clsx"
11
+ import { CSSVar } from "./cssVar"
12
+ import { Destructor, joinDestructors } from "./destructors"
13
+ import { hashObject } from "./hashObject"
14
+ import { hyphenateStyleName } from "./hyphenateStyleName"
15
+ import { useRequiredContext } from "./useRequiredContext"
16
+
17
+ const UNITLESS_NUMBER_PROPS = [
18
+ "animation-iteration-count",
19
+ "border-image-outset",
20
+ "border-image-slice",
21
+ "border-image-width",
22
+ "box-flex",
23
+ "box-flex-group",
24
+ "box-ordinal-group",
25
+ "column-count",
26
+ "columns",
27
+ "flex",
28
+ "flex-grow",
29
+ "flex-positive",
30
+ "flex-shrink",
31
+ "flex-negative",
32
+ "flex-order",
33
+ "grid-row",
34
+ "grid-row-end",
35
+ "grid-row-span",
36
+ "grid-row-start",
37
+ "grid-column",
38
+ "grid-column-end",
39
+ "grid-column-span",
40
+ "grid-column-start",
41
+ "font-weight",
42
+ "line-clamp",
43
+ "line-height",
44
+ "opacity",
45
+ "order",
46
+ "orphans",
47
+ "tabSize",
48
+ "widows",
49
+ "z-index",
50
+ "zoom",
51
+
52
+ // SVG-related properties
53
+ "fill-opacity",
54
+ "flood-opacity",
55
+ "stop-opacity",
56
+ "stroke-dasharray",
57
+ "stroke-dashoffset",
58
+ "stroke-miterlimit",
59
+ "stroke-opacity",
60
+ "stroke-width",
61
+ ]
62
+
63
+ type BaseCSSProperties = CSS.Properties<number | string, string & {}>
64
+ type CSSVarDeclarations = Record<CSSVar, string>
65
+
66
+ type NestedRuleKey =
67
+ | `&${CSS.SimplePseudos}`
68
+ | `${CSS.AtRules}${string}`
69
+ | `.${string}`
70
+ | `&${string}`
71
+
72
+ export type CSSProperties = {
73
+ [Key in keyof BaseCSSProperties]?: BaseCSSProperties[Key]
74
+ } & {
75
+ [Key in NestedRuleKey]?: BaseCSSProperties
76
+ }
77
+
78
+ const PurseContext = createContext<StyleApi | undefined>(undefined)
79
+
80
+ export type StyleApi = {
81
+ addStyleElement: (styleElement: StyleElement) => Destructor
82
+ addGlobalStyle: (styleRule: string) => Destructor
83
+ }
84
+
85
+ const __style__ = Symbol("style-element")
86
+
87
+ type StyleRule = string
88
+
89
+ export type StyleElement = {
90
+ __style__: typeof __style__
91
+ composed: StyleElement[]
92
+ owned?: {
93
+ styleRules: StyleRule[]
94
+ className: string
95
+ }
96
+ className: string
97
+ }
98
+
99
+ function isStyleElement(o: any): o is StyleElement {
100
+ return "__style__" in o && o["__style__"] === __style__
101
+ }
102
+
103
+ const DEV = process.env.NODE_ENV === "development"
104
+
105
+ export type InMemoryStyleApi = {
106
+ styleRulesRef: { current: string[] }
107
+ addStyleElement: StyleApi["addStyleElement"]
108
+ addGlobalStyle: StyleApi["addGlobalStyle"]
109
+ }
110
+
111
+ export function createInMemoryStyleApi(): InMemoryStyleApi {
112
+ let styleRules: string[] = []
113
+
114
+ type InsertedStyleElement = {
115
+ refCount: number
116
+ destructor: Destructor
117
+ }
118
+
119
+ const insertedStyleElementClassNames = new Map<string, InsertedStyleElement>()
120
+
121
+ function addStyleRule(rule: StyleRule): Destructor {
122
+ styleRules.push(rule)
123
+
124
+ return () => {
125
+ const ruleToRemove = rule
126
+ styleRules = styleRules.filter((rule) => rule !== ruleToRemove)
127
+ }
128
+ }
129
+
130
+ function removeStyleElement(styleElement: StyleElement) {
131
+ for (const composed of styleElement.composed) {
132
+ removeStyleElement(composed)
133
+ }
134
+
135
+ if (styleElement.owned) {
136
+ const { className } = styleElement.owned
137
+
138
+ const maybeInsertedStyleElement =
139
+ insertedStyleElementClassNames.get(className)
140
+ if (!maybeInsertedStyleElement) return
141
+
142
+ const { refCount, destructor } = maybeInsertedStyleElement
143
+ const newRefCount = refCount - 1
144
+
145
+ if (newRefCount <= 0) {
146
+ insertedStyleElementClassNames.delete(className)
147
+ destructor()
148
+ } else {
149
+ insertedStyleElementClassNames.set(className, {
150
+ refCount: newRefCount,
151
+ destructor,
152
+ })
153
+ }
154
+ }
155
+ }
156
+
157
+ function addStyleElement(styleElement: StyleElement): Destructor {
158
+ for (const composed of styleElement.composed) {
159
+ addStyleElement(composed)
160
+ }
161
+
162
+ if (styleElement.owned) {
163
+ const { className, styleRules } = styleElement.owned
164
+ const maybeInsertedStyleElement =
165
+ insertedStyleElementClassNames.get(className)
166
+
167
+ if (maybeInsertedStyleElement) {
168
+ const { destructor, refCount } = maybeInsertedStyleElement
169
+ insertedStyleElementClassNames.set(className, {
170
+ destructor,
171
+ refCount: refCount + 1,
172
+ })
173
+ } else {
174
+ const destructors: Destructor[] = styleRules.map(addStyleRule)
175
+ const destructor = joinDestructors(destructors)
176
+
177
+ insertedStyleElementClassNames.set(className, {
178
+ destructor,
179
+ refCount: 1,
180
+ })
181
+ }
182
+ }
183
+
184
+ return () => removeStyleElement(styleElement)
185
+ }
186
+
187
+ return {
188
+ addStyleElement,
189
+ addGlobalStyle: addStyleRule,
190
+ styleRulesRef: {
191
+ get current() {
192
+ return styleRules
193
+ },
194
+ },
195
+ }
196
+ }
197
+
198
+ export function PurseProvider(props: { children?: React.ReactNode }) {
199
+ const htmlStyleElement = useMemo(() => document.createElement("style"), [])
200
+ const atRuleHtmlStyleElement = useMemo(
201
+ () => document.createElement("style"),
202
+ [],
203
+ )
204
+
205
+ useInsertionEffect(() => {
206
+ document.head.appendChild(htmlStyleElement)
207
+ document.head.appendChild(atRuleHtmlStyleElement)
208
+
209
+ return () => {
210
+ document.head.removeChild(htmlStyleElement)
211
+ document.head.removeChild(atRuleHtmlStyleElement)
212
+ }
213
+ }, [htmlStyleElement, atRuleHtmlStyleElement])
214
+
215
+ const styleApi: StyleApi = useMemo(() => {
216
+ type InsertedStyleElement = {
217
+ refCount: number
218
+ destructor: Destructor
219
+ }
220
+
221
+ const insertedStyleElementClassNames = new Map<
222
+ string,
223
+ InsertedStyleElement
224
+ >()
225
+
226
+ function addStyleRule(rule: StyleRule): Destructor {
227
+ const styleSheet = htmlStyleElement.sheet
228
+ if (!styleSheet)
229
+ throw new Error(`Could not get style sheet of style element`)
230
+
231
+ try {
232
+ // TODO: do these in dev
233
+ // styleSheet.insertRule(styleRule, insertPosition)
234
+
235
+ const isAtRule = rule.startsWith("@")
236
+ const styleText = new Text(rule)
237
+
238
+ if (isAtRule) {
239
+ // atRuleStyleElement.sheet?.insertRule(rule)
240
+
241
+ // We add as Text here because otherwise, the styles don't show up in chrome dev tools
242
+ atRuleHtmlStyleElement.appendChild(styleText)
243
+
244
+ return () => atRuleHtmlStyleElement.removeChild(styleText)
245
+ } else {
246
+ // styleSheet?.insertRule(rule)
247
+ htmlStyleElement.appendChild(styleText)
248
+
249
+ return () => {
250
+ htmlStyleElement.removeChild(styleText)
251
+ }
252
+ }
253
+ } catch (error) {
254
+ if (DEV) {
255
+ throw new Error(`Could not add style rule ${rule}`)
256
+ } else {
257
+ console.warn(`Could not add style rule ${rule}`)
258
+ return () => {}
259
+ }
260
+ }
261
+ }
262
+
263
+ function removeStyleElement(styleElement: StyleElement) {
264
+ for (const composed of styleElement.composed) {
265
+ removeStyleElement(composed)
266
+ }
267
+
268
+ if (styleElement.owned) {
269
+ const { className } = styleElement.owned
270
+
271
+ const maybeInsertedStyleElement =
272
+ insertedStyleElementClassNames.get(className)
273
+ if (!maybeInsertedStyleElement) return
274
+
275
+ const { refCount, destructor } = maybeInsertedStyleElement
276
+ const newRefCount = refCount - 1
277
+
278
+ if (newRefCount <= 0) {
279
+ insertedStyleElementClassNames.delete(className)
280
+ destructor()
281
+ } else {
282
+ insertedStyleElementClassNames.set(className, {
283
+ refCount: newRefCount,
284
+ destructor,
285
+ })
286
+ }
287
+ }
288
+ }
289
+
290
+ function addStyleElement(styleElement: StyleElement): Destructor {
291
+ for (const composed of styleElement.composed) {
292
+ addStyleElement(composed)
293
+ }
294
+
295
+ if (styleElement.owned) {
296
+ const { className, styleRules } = styleElement.owned
297
+ const maybeInsertedStyleElement =
298
+ insertedStyleElementClassNames.get(className)
299
+
300
+ if (maybeInsertedStyleElement) {
301
+ const { destructor, refCount } = maybeInsertedStyleElement
302
+ insertedStyleElementClassNames.set(className, {
303
+ destructor,
304
+ refCount: refCount + 1,
305
+ })
306
+ } else {
307
+ const destructors: Destructor[] = styleRules.map(addStyleRule)
308
+ const destructor = joinDestructors(destructors)
309
+
310
+ insertedStyleElementClassNames.set(className, {
311
+ destructor,
312
+ refCount: 1,
313
+ })
314
+ }
315
+ }
316
+
317
+ return () => removeStyleElement(styleElement)
318
+ }
319
+
320
+ return { addStyleElement, addGlobalStyle: addStyleRule }
321
+ }, [])
322
+
323
+ return (
324
+ <PurseContext.Provider value={styleApi}>
325
+ {props.children}
326
+ </PurseContext.Provider>
327
+ )
328
+ }
329
+
330
+ function compileDeclarations(declarations: BaseCSSProperties) {
331
+ // return entries(prefix(declarations))
332
+ return entries(declarations)
333
+ .map(([camelCaseProperty, value]) => {
334
+ const kebabProperty = hyphenateStyleName(camelCaseProperty)
335
+ const needToAddPixelsUnit =
336
+ typeof value === "number" &&
337
+ !UNITLESS_NUMBER_PROPS.includes(kebabProperty)
338
+
339
+ if (needToAddPixelsUnit) {
340
+ return `${kebabProperty}:${value}px;`
341
+ } else {
342
+ return `${kebabProperty}:${value};`
343
+ }
344
+ })
345
+ .join("")
346
+ }
347
+
348
+ function groupEntriesBy<K extends string | number | symbol, V>(
349
+ obj: Record<K, V>,
350
+ predicate: (key: K, value: V) => string,
351
+ ): Record<string, Record<K, V>> {
352
+ const groupedEntries: Record<string, any> = {}
353
+
354
+ for (const key in obj) {
355
+ const value = obj[key]
356
+ const group = predicate(key, value)
357
+
358
+ if (group in groupedEntries) {
359
+ groupedEntries[group][key] = value
360
+ } else {
361
+ groupedEntries[group] = { [key]: value }
362
+ }
363
+ }
364
+
365
+ return groupedEntries
366
+ }
367
+
368
+ function isObjectEmpty(obj: {}): boolean {
369
+ return Object.keys(obj).length === 0
370
+ }
371
+
372
+ type CSSPropertiesGroup = {
373
+ "": BaseCSSProperties
374
+ } & {
375
+ [Key in NestedRuleKey]?: BaseCSSProperties
376
+ }
377
+
378
+ function mergeCSSProperties(...groups: CSSProperties[]): CSSProperties {
379
+ const merged: CSSProperties = {}
380
+
381
+ for (const group of groups) {
382
+ for (const _propertyOrSelector in group) {
383
+ const propertyOrSelector = _propertyOrSelector as keyof typeof group
384
+ const isNestedRule = isObject(group[propertyOrSelector])
385
+
386
+ if (isNestedRule) {
387
+ // Type-casting to a key that only has nested rules
388
+ const selector = propertyOrSelector as NestedRuleKey
389
+ const existingStyles = merged[selector] || {}
390
+
391
+ merged[selector] = {
392
+ ...existingStyles,
393
+ ...group[selector],
394
+ }
395
+ } else {
396
+ const property = propertyOrSelector as keyof BaseCSSProperties
397
+
398
+ ;(merged as any)[property] = group[property]
399
+ }
400
+ }
401
+ }
402
+
403
+ return merged
404
+ }
405
+
406
+ function groupCSSProperties(properties: CSSProperties): CSSPropertiesGroup {
407
+ const styleDeclarationsBySelector: Record<string, any> = { "": {} }
408
+
409
+ for (const _propertyOrSelector in properties) {
410
+ const propertyOrSelector = _propertyOrSelector as keyof typeof properties
411
+ const value = properties[propertyOrSelector]
412
+ if (value === undefined || value === null) continue
413
+ const isNestedRule = isObject(value)
414
+
415
+ if (isNestedRule) {
416
+ // Type-casting to a key that only has nested rules
417
+ const selector = propertyOrSelector as `&:${CSS.SimplePseudos}`
418
+ const existingStyles = styleDeclarationsBySelector[selector] || {}
419
+
420
+ styleDeclarationsBySelector[selector] = {
421
+ ...existingStyles,
422
+ ...properties[selector],
423
+ }
424
+ } else {
425
+ const property = propertyOrSelector as keyof BaseCSSProperties
426
+ const value = (properties as any)[property]
427
+
428
+ styleDeclarationsBySelector[""][property] = value
429
+ }
430
+ }
431
+
432
+ for (const selector in styleDeclarationsBySelector) {
433
+ const declarations = styleDeclarationsBySelector[selector]
434
+ if (declarations && isObjectEmpty(declarations)) {
435
+ delete styleDeclarationsBySelector[selector]
436
+ }
437
+ }
438
+
439
+ return styleDeclarationsBySelector as CSSPropertiesGroup
440
+ }
441
+
442
+ function compileCSS(properties: CSSProperties): StyleElement["owned"] {
443
+ if (isObjectEmpty(properties)) {
444
+ return undefined
445
+ }
446
+
447
+ const groupedCSSProperties = groupCSSProperties(properties)
448
+
449
+ const compiledStyleDeclarationsByGroup = mapValues(
450
+ groupedCSSProperties,
451
+ compileDeclarations,
452
+ )
453
+
454
+ const className = hashObject(compiledStyleDeclarationsByGroup)
455
+ const styleRules: StyleRule[] = entries(compiledStyleDeclarationsByGroup).map(
456
+ ([group, styles]) => {
457
+ if (group === "") {
458
+ // Base group
459
+ return `.${className}{${styles}}`
460
+ } else if (group.startsWith("@")) {
461
+ // At rules
462
+ return `${group}{.${className}{${styles}}}`
463
+ } else {
464
+ // Nested Rules
465
+ const selector = group.replace(/&/g, `.${className}`)
466
+ return `${selector}{${styles}}`
467
+ }
468
+ },
469
+ )
470
+
471
+ return { styleRules, className }
472
+ }
473
+
474
+ export function style(
475
+ ...styleElementsOrCSS: (CSSProperties | StyleElement)[]
476
+ ): StyleElement {
477
+ let composed: StyleElement[] = []
478
+ let cssPropertyGroups: CSSProperties[] = []
479
+
480
+ for (const styleElementOrCSS of styleElementsOrCSS) {
481
+ if (isStyleElement(styleElementOrCSS)) {
482
+ composed.push(styleElementOrCSS)
483
+ } else {
484
+ cssPropertyGroups.push(styleElementOrCSS)
485
+ }
486
+ }
487
+
488
+ const cssProperties = mergeCSSProperties(...cssPropertyGroups)
489
+ const owned = compileCSS(cssProperties)
490
+
491
+ const composedClassNames = composed.map((composed) => composed.className)
492
+ const className = clsx(...composedClassNames, owned?.className)
493
+
494
+ return {
495
+ __style__,
496
+ composed,
497
+ className,
498
+ owned,
499
+ }
500
+ }
501
+
502
+ function styleElementToRules({ owned, composed }: StyleElement): StyleRule[] {
503
+ const styleRules = composed.flatMap(styleElementToRules)
504
+ if (owned) styleRules.push(...owned.styleRules)
505
+ return styleRules
506
+ }
507
+
508
+ export function useInjectGlobalStyles(
509
+ selector: string,
510
+ cssProperties: BaseCSSProperties & CSSVarDeclarations,
511
+ deps: DependencyList,
512
+ ) {
513
+ const styleApi = useRequiredContext(PurseContext)
514
+
515
+ useLayoutEffect(() => {
516
+ const styleRule = `${selector}{${compileDeclarations(cssProperties)}}`
517
+ const destructor = styleApi.addGlobalStyle(styleRule)
518
+
519
+ return destructor
520
+ }, deps)
521
+ }
522
+
523
+ export function useStyles(
524
+ ...styleElementsOrCSS: (CSSProperties | StyleElement)[]
525
+ ): string {
526
+ const styleApi = useRequiredContext(PurseContext)
527
+
528
+ // let composed: StyleElement[] = []
529
+ // let cssPropertyGroups: CSSProperties[] = []
530
+
531
+ // for (const styleElementOrCSS of styleElementsOrCSS) {
532
+ // if (isStyleElement(styleElementOrCSS)) {
533
+ // composed.push(styleElementOrCSS)
534
+ // } else {
535
+ // cssPropertyGroups.push(styleElementOrCSS)
536
+ // }
537
+ // }
538
+
539
+ // const cssProperties = mergeCSSProperties(...cssPropertyGroups)
540
+
541
+ // Will want to memo this somehow at some point but its a little tricky
542
+ const styleElement = style(...styleElementsOrCSS)
543
+
544
+ useLayoutEffect(() => {
545
+ if (styleElement.className === "bpsHRa jwfJGj") {
546
+ console.log(styleElement)
547
+ }
548
+ const destructor = styleApi.addStyleElement(styleElement)
549
+ // Dep is class name because its hashed
550
+ return destructor
551
+ }, [styleElement.className])
552
+
553
+ // const memoedCSS = useMemoShallowEqual(cssProperties)
554
+
555
+ // const ownedStyleElement = useMemo(() => {
556
+ // return isObjectEmpty(memoedCSS) ? undefined : style(memoedCSS)
557
+ // }, [memoedCSS])
558
+
559
+ // useLayoutEffect(() => {
560
+ // for (const styleElement of styleElementsOrCSS.filter(isStyleElement)) {
561
+ // styleApi.addStyleElement(styleElement)
562
+ // }
563
+
564
+ // if (ownedStyleElement) {
565
+ // styleApi.addStyleElement(ownedStyleElement)
566
+ // }
567
+ // }, [ownedStyleElement])
568
+
569
+ // if (ownedStyleElement) {
570
+ // return composedClassName + " " + ownedStyleElement.className
571
+ // } else {
572
+ // return composedClassName
573
+ // }
574
+
575
+ return styleElement.className
576
+ }
@@ -0,0 +1,11 @@
1
+ import React, { useContext } from "react"
2
+
3
+ export function useRequiredContext<T>(
4
+ context: React.Context<T | undefined>,
5
+ ): T {
6
+ const value = useContext(context)
7
+ if (value === undefined) {
8
+ throw new Error(`Expected value of ${context} to be defined`)
9
+ }
10
+ return value
11
+ }