@nordcraft/runtime 1.0.32 → 1.0.34

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.
Files changed (40) hide show
  1. package/dist/components/createComponent.js +44 -0
  2. package/dist/components/createComponent.js.map +1 -1
  3. package/dist/components/createElement.js +47 -3
  4. package/dist/components/createElement.js.map +1 -1
  5. package/dist/custom-element.main.esm.js +31 -25
  6. package/dist/custom-element.main.esm.js.map +4 -4
  7. package/dist/debug/panicScreen.d.ts +6 -0
  8. package/dist/debug/panicScreen.js +25 -0
  9. package/dist/debug/panicScreen.js.map +1 -0
  10. package/dist/debug/sendEditorToast.d.ts +3 -0
  11. package/dist/debug/sendEditorToast.js +9 -0
  12. package/dist/debug/sendEditorToast.js.map +1 -0
  13. package/dist/editor-preview.main.js +84 -29
  14. package/dist/editor-preview.main.js.map +1 -1
  15. package/dist/page.main.esm.js +3 -3
  16. package/dist/page.main.esm.js.map +4 -4
  17. package/dist/styles/CustomPropertyStyleSheet.d.ts +35 -0
  18. package/dist/styles/CustomPropertyStyleSheet.js +118 -0
  19. package/dist/styles/CustomPropertyStyleSheet.js.map +1 -0
  20. package/dist/styles/CustomPropertyStyleSheet.test.d.ts +1 -0
  21. package/dist/styles/CustomPropertyStyleSheet.test.js +66 -0
  22. package/dist/styles/CustomPropertyStyleSheet.test.js.map +1 -0
  23. package/dist/styles/style.js +23 -0
  24. package/dist/styles/style.js.map +1 -1
  25. package/dist/utils/omitStyle.js +3 -1
  26. package/dist/utils/omitStyle.js.map +1 -1
  27. package/dist/utils/subscribeCustomProperty.d.ts +11 -0
  28. package/dist/utils/subscribeCustomProperty.js +16 -0
  29. package/dist/utils/subscribeCustomProperty.js.map +1 -0
  30. package/package.json +3 -3
  31. package/src/components/createComponent.ts +52 -0
  32. package/src/components/createElement.ts +61 -3
  33. package/src/debug/panicScreen.ts +37 -0
  34. package/src/debug/sendEditorToast.ts +19 -0
  35. package/src/editor-preview.main.ts +108 -40
  36. package/src/styles/CustomPropertyStyleSheet.test.ts +104 -0
  37. package/src/styles/CustomPropertyStyleSheet.ts +180 -0
  38. package/src/styles/style.ts +33 -0
  39. package/src/utils/omitStyle.ts +10 -2
  40. package/src/utils/subscribeCustomProperty.ts +54 -0
@@ -8,6 +8,7 @@ import type {
8
8
  Component,
9
9
  ComponentData,
10
10
  MetaEntry,
11
+ StyleVariant,
11
12
  } from '@nordcraft/core/dist/component/component.types'
12
13
  import { isPageComponent } from '@nordcraft/core/dist/component/isPageComponent'
13
14
  import type {
@@ -37,6 +38,8 @@ import { createLegacyAPI } from './api/createAPI'
37
38
  import { createAPI } from './api/createAPIv2'
38
39
  import { createNode } from './components/createNode'
39
40
  import { isContextProvider } from './context/isContextProvider'
41
+ import { createPanicScreen } from './debug/panicScreen'
42
+ import { sendEditorToast } from './debug/sendEditorToast'
40
43
  import { dragEnded } from './editor/drag-drop/dragEnded'
41
44
  import { dragMove } from './editor/drag-drop/dragMove'
42
45
  import { dragReorder } from './editor/drag-drop/dragReorder'
@@ -565,7 +568,7 @@ export const createRoot = (
565
568
  }
566
569
  const { x, y, type } = message.data
567
570
  const elementsAtPoint = document.elementsFromPoint(x, y)
568
- let element = elementsAtPoint.find((elem) => {
571
+ const element = elementsAtPoint.find((elem) => {
569
572
  const id = elem.getAttribute('data-id')
570
573
  if (
571
574
  typeof id !== 'string' ||
@@ -582,31 +585,12 @@ export const createRoot = (
582
585
  if (elem.getAttribute('data-node-type') === 'text') {
583
586
  return (
584
587
  // Select text nodes if the meta key is pressed or the text node is double-clicked
585
- metaKey ||
586
- type === 'dblclick' ||
587
- // Select text nodes if the selected node is a text node. This is useful as the user is likely in a text editing mode
588
- getDOMNodeFromNodeId(selectedNodeId)?.getAttribute(
589
- 'data-node-type',
590
- ) === 'text'
588
+ metaKey || type === 'dblclick'
591
589
  )
592
590
  }
593
591
  return true
594
592
  })
595
593
 
596
- // Bubble selection to the topmost parent that has the exact same size as the element.
597
- // This is important for drag and drop as you are often left with childless parents after dragging.
598
- while (
599
- element?.parentElement &&
600
- element.getAttribute('data-node-id') !== 'root' &&
601
- fastDeepEqual(
602
- element.getBoundingClientRect().toJSON(),
603
- element.parentElement.getBoundingClientRect().toJSON(),
604
- ) &&
605
- element.getAttribute('data-node-type') !== 'text'
606
- ) {
607
- element = element.parentElement
608
- }
609
-
610
594
  const id = element?.getAttribute('data-id') ?? null
611
595
  if (type === 'click' && id !== selectedNodeId) {
612
596
  if (message.data.metaKey) {
@@ -985,10 +969,30 @@ export const createRoot = (
985
969
  document.head.appendChild(styleTag)
986
970
  }
987
971
 
972
+ // If style variant targets a pseudo-element, apply styles to it instead
973
+ let pseudoElement = ''
974
+ if (component && styleVariantSelection) {
975
+ const nodeLookup = getNodeAndAncestors(
976
+ component,
977
+ component.nodes.root,
978
+ styleVariantSelection.nodeId,
979
+ )
980
+
981
+ if (
982
+ nodeLookup?.node.type === 'element' ||
983
+ (nodeLookup?.node.type === 'component' &&
984
+ nodeLookup.node.variants?.[
985
+ styleVariantSelection.styleVariantIndex
986
+ ].pseudoElement)
987
+ ) {
988
+ pseudoElement = `::${nodeLookup.node.variants?.[styleVariantSelection.styleVariantIndex].pseudoElement}`
989
+ }
990
+ }
991
+
988
992
  const previewStyles = Object.entries(previewStyleStyles)
989
993
  .map(([key, value]) => `${key}: ${value} !important;`)
990
994
  .join('\n')
991
- styleTag.innerHTML = `[data-id="${selectedNodeId}"], [data-id="${selectedNodeId}"] ~ [data-id^="${selectedNodeId}("] {
995
+ styleTag.innerHTML = `[data-id="${selectedNodeId}"]${pseudoElement}, [data-id="${selectedNodeId}"] ~ [data-id^="${selectedNodeId}("]${pseudoElement} {
992
996
  ${previewStyles}
993
997
  transition: none !important;
994
998
  }`
@@ -1053,19 +1057,49 @@ export const createRoot = (
1053
1057
  (nodeLookup.node.type === 'element' ||
1054
1058
  nodeLookup.node.type === 'component')
1055
1059
  ) {
1056
- const selectedStyleVariant = nodeLookup.node.variants?.[
1057
- styleVariantSelection.styleVariantIndex
1058
- ] ?? { style: {} }
1060
+ const selectedStyleVariant =
1061
+ nodeLookup.node.variants?.[
1062
+ styleVariantSelection.styleVariantIndex
1063
+ ] ?? ({ style: {} } as StyleVariant)
1059
1064
  // Add a style element specific to the selected element which
1060
1065
  // is only applied when the preview is in design mode
1066
+ const styleVariantCustomProperties = Object.entries(
1067
+ (selectedStyleVariant as StyleVariant).customProperties ?? {},
1068
+ )
1069
+ .map(([customPropertyName, customProperty]) => ({
1070
+ name: customPropertyName,
1071
+ value: applyFormula(customProperty.formula, {
1072
+ data: {
1073
+ Attributes: dataSignal.get().Attributes,
1074
+ Variables: dataSignal.get().Variables,
1075
+ Contexts: ctxDataSignal?.get().Contexts ?? {},
1076
+ },
1077
+ component: getCurrentComponent(),
1078
+ root: ctx?.root,
1079
+ formulaCache: {},
1080
+ package: ctx?.package,
1081
+ toddle: window.toddle,
1082
+ env,
1083
+ } as FormulaContext),
1084
+ }))
1085
+ .filter(({ value }) => value !== undefined)
1086
+
1061
1087
  const styleElem = document.createElement('style')
1088
+ const pseudoElement = selectedStyleVariant.pseudoElement
1089
+ ? `::${selectedStyleVariant.pseudoElement}`
1090
+ : ''
1062
1091
  styleElem.setAttribute('data-hash', selectedNodeId)
1063
1092
  styleElem.appendChild(
1064
1093
  document.createTextNode(`
1065
- body[data-mode="design"] [data-id="${selectedNodeId}"] {
1094
+ body[data-mode="design"] [data-id="${selectedNodeId}"]${pseudoElement} {
1066
1095
  ${styleToCss({
1067
- ...nodeLookup.node.style,
1096
+ ...(!pseudoElement && nodeLookup.node.style),
1068
1097
  ...selectedStyleVariant.style,
1098
+ ...Object.fromEntries(
1099
+ styleVariantCustomProperties.map(
1100
+ ({ name, value }) => [name, value],
1101
+ ),
1102
+ ),
1069
1103
  })}
1070
1104
  }
1071
1105
  `),
@@ -1379,19 +1413,53 @@ export const createRoot = (
1379
1413
  // Clear old root signal and create a new one to not keep old signals with previous root around
1380
1414
  ctxDataSignal?.destroy()
1381
1415
  ctxDataSignal = dataSignal.map((data) => data)
1382
- const rootElem = createNode({
1383
- id: 'root',
1384
- path: '0',
1385
- dataSignal: ctxDataSignal,
1386
- ctx: newCtx,
1387
- parentElement: domNode,
1388
- instance: { [newCtx.component.name]: 'root' },
1389
- })
1390
- newCtx.component.onLoad?.actions.forEach((action) => {
1391
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
1392
- handleAction(action, dataSignal.get(), newCtx)
1393
- })
1394
- rootElem.forEach((elem) => domNode.appendChild(elem))
1416
+ try {
1417
+ const rootElem = createNode({
1418
+ id: 'root',
1419
+ path: '0',
1420
+ dataSignal: ctxDataSignal,
1421
+ ctx: newCtx,
1422
+ parentElement: domNode,
1423
+ instance: { [newCtx.component.name]: 'root' },
1424
+ })
1425
+ newCtx.component.onLoad?.actions.forEach((action) => {
1426
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1427
+ handleAction(action, dataSignal.get(), newCtx)
1428
+ })
1429
+ rootElem.forEach((elem) => domNode.appendChild(elem))
1430
+ } catch (error: unknown) {
1431
+ const isPage = isPageComponent(newCtx.component)
1432
+ let name = `Unexpected error while rendering ${isPage ? 'page' : 'component'}`
1433
+ let message = error instanceof Error ? error.message : String(error)
1434
+ let panic = false
1435
+ if (error instanceof RangeError) {
1436
+ // RangeError is unrecoverable
1437
+ panic = true
1438
+ name = 'Infinite loop detected'
1439
+ message =
1440
+ 'RangeError (Maximum call stack size exceeded): Remove any circular dependencies or recursive calls. This is most likely caused by components, formulas or actions using themselves without an exit case.'
1441
+ }
1442
+
1443
+ // Send a toast to the editor with the error
1444
+ sendEditorToast(name, message, {
1445
+ type: 'critical',
1446
+ })
1447
+
1448
+ if (panic) {
1449
+ // Show error overlay in the editor until next update
1450
+ const panicScreen = createPanicScreen({
1451
+ name: name,
1452
+ message,
1453
+ isPage,
1454
+ cause: error,
1455
+ })
1456
+
1457
+ // Replace the inner HTML of the editor preview with the panic screen
1458
+ domNode.innerHTML = ''
1459
+ domNode.appendChild(panicScreen)
1460
+ }
1461
+ console.error(name, message, error)
1462
+ }
1395
1463
  window.parent?.postMessage(
1396
1464
  {
1397
1465
  type: 'style',
@@ -0,0 +1,104 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { CustomPropertyStyleSheet } from './CustomPropertyStyleSheet'
3
+
4
+ describe('CustomPropertyStyleSheet', () => {
5
+ test('it creates a new stylesheet', () => {
6
+ const instance = new CustomPropertyStyleSheet(document)
7
+ expect(instance).toBeInstanceOf(CustomPropertyStyleSheet)
8
+ expect(instance.getStyleSheet().cssRules).toHaveLength(0)
9
+ })
10
+
11
+ test('it adds a property definition', () => {
12
+ const instance = new CustomPropertyStyleSheet(document)
13
+ instance.registerProperty('.my-class', '--my-property')
14
+ expect(instance.getStyleSheet().cssRules.length).toBe(1)
15
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe('.my-class { }')
16
+ })
17
+
18
+ test('it puts different selectors in different rules', () => {
19
+ const instance = new CustomPropertyStyleSheet(document)
20
+ instance.registerProperty('.my-class', '--my-property')('256px')
21
+ instance.registerProperty('.my-other-class', '--my-property')('256px')
22
+ expect(instance.getStyleSheet().cssRules.length).toBe(2)
23
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
24
+ '.my-class { --my-property: 256px; }',
25
+ )
26
+ expect(instance.getStyleSheet().cssRules[1].cssText).toBe(
27
+ '.my-other-class { --my-property: 256px; }',
28
+ )
29
+ })
30
+
31
+ test('it can update properties', () => {
32
+ const instance = new CustomPropertyStyleSheet(document)
33
+ const setter = instance.registerProperty('.my-class', '--my-property')
34
+ setter('256px')
35
+ expect(instance.getStyleSheet().cssRules.length).toBe(1)
36
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
37
+ '.my-class { --my-property: 256px; }',
38
+ )
39
+
40
+ setter('inherit')
41
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
42
+ '.my-class { --my-property: inherit; }',
43
+ )
44
+ })
45
+
46
+ test('it works with media queries', () => {
47
+ const instance = new CustomPropertyStyleSheet(document)
48
+ instance.registerProperty('.my-class', '--my-property', {
49
+ mediaQuery: { 'max-width': '600px' },
50
+ })('256px')
51
+ expect(instance.getStyleSheet().cssRules.length).toBe(1)
52
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
53
+ '@media (max-width: 600px) { .my-class { --my-property: 256px; } }',
54
+ )
55
+ })
56
+
57
+ test('it unregisters a property', () => {
58
+ const instance = new CustomPropertyStyleSheet(document)
59
+ const setter = instance.registerProperty('.my-class', '--my-property')
60
+ setter('256px')
61
+ const setter2 = instance.registerProperty(
62
+ '.my-class',
63
+ '--my-other-property',
64
+ )
65
+ setter2('512px')
66
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
67
+ '.my-class { --my-property: 256px; --my-other-property: 512px; }',
68
+ )
69
+
70
+ instance.unregisterProperty('.my-class', '--my-property')
71
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
72
+ '.my-class { --my-other-property: 512px; }',
73
+ )
74
+ instance.unregisterProperty('.my-class', '--my-other-property')
75
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe('.my-class { }')
76
+ })
77
+
78
+ test('it unregisters a property with media queries', () => {
79
+ const instance = new CustomPropertyStyleSheet(document)
80
+ const setter = instance.registerProperty(
81
+ '.my-class-with-media',
82
+ '--my-property-with-media',
83
+ {
84
+ mediaQuery: { 'max-width': '600px' },
85
+ },
86
+ )
87
+ setter('256px')
88
+ expect(instance.getStyleSheet().cssRules.length).toBe(1)
89
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
90
+ '@media (max-width: 600px) { .my-class-with-media { --my-property-with-media: 256px; } }',
91
+ )
92
+
93
+ instance.unregisterProperty(
94
+ '.my-class-with-media',
95
+ '--my-property-with-media',
96
+ {
97
+ mediaQuery: { 'max-width': '600px' },
98
+ },
99
+ )
100
+ expect(instance.getStyleSheet().cssRules[0].cssText).toBe(
101
+ '@media (max-width: 600px) { .my-class-with-media { } }',
102
+ )
103
+ })
104
+ })
@@ -0,0 +1,180 @@
1
+ import type { MediaQuery } from '@nordcraft/core/dist/component/component.types'
2
+
3
+ /**
4
+ * CustomPropertyStyleSheet is a utility class that manages CSS custom properties
5
+ * (variables) in a dedicated CSSStyleSheet. It allows for efficient registration,
6
+ * updating, and removal of style properties as fast as setting style properties.
7
+ *
8
+ * It abstracts the complexity of managing CSS rules via. indexing and
9
+ * provides a simple API to register and unregister style properties for specific
10
+ * selectors.
11
+ */
12
+ export class CustomPropertyStyleSheet {
13
+ private styleSheet: CSSStyleSheet
14
+
15
+ // Selector to rule index mapping
16
+ private ruleMap: Map<string, CSSStyleRule | CSSNestedDeclarations> | undefined
17
+
18
+ constructor(root: Document | ShadowRoot, styleSheet?: CSSStyleSheet | null) {
19
+ if (styleSheet) {
20
+ this.styleSheet = styleSheet
21
+ } else {
22
+ this.styleSheet = new CSSStyleSheet()
23
+ root.adoptedStyleSheets.push(this.getStyleSheet())
24
+ }
25
+ }
26
+
27
+ /**
28
+ * @returns A function to update the property value efficiently.
29
+ */
30
+ public registerProperty(
31
+ selector: string,
32
+ name: string,
33
+ options?: {
34
+ mediaQuery?: MediaQuery
35
+ startingStyle?: boolean
36
+ },
37
+ ): (newValue: string) => void {
38
+ this.ruleMap ??= this.hydrateFromBase()
39
+ const fullSelector = CustomPropertyStyleSheet.getFullSelector(
40
+ selector,
41
+ options,
42
+ )
43
+
44
+ // Check if the selector already exists
45
+ let rule = this.ruleMap.get(fullSelector)
46
+ if (!rule) {
47
+ const ruleIndex = this.styleSheet.insertRule(
48
+ fullSelector,
49
+ this.styleSheet.cssRules.length,
50
+ )
51
+ let newRule = this.styleSheet.cssRules[ruleIndex]
52
+
53
+ // We are only interested in the dynamic style, so get the actual style rule, not media or other nested rules. Loop until we are at the bottom most rule.
54
+ while (
55
+ (newRule as any).cssRules &&
56
+ (newRule as CSSGroupingRule).cssRules.length > 0
57
+ ) {
58
+ newRule = (newRule as CSSGroupingRule).cssRules[0]
59
+ }
60
+ rule = newRule as CSSStyleRule | CSSNestedDeclarations
61
+ this.ruleMap.set(fullSelector, rule)
62
+ }
63
+
64
+ return (value: string) => {
65
+ rule.style.setProperty(name, value)
66
+ }
67
+ }
68
+
69
+ public unregisterProperty(
70
+ selector: string,
71
+ name: string,
72
+ options?: {
73
+ mediaQuery?: MediaQuery
74
+ startingStyle?: boolean
75
+ deepClean?: boolean
76
+ },
77
+ ): void {
78
+ if (!this.ruleMap) {
79
+ return
80
+ }
81
+
82
+ const fullSelector = CustomPropertyStyleSheet.getFullSelector(
83
+ selector,
84
+ options,
85
+ )
86
+
87
+ const rule = this.ruleMap.get(fullSelector)
88
+ if (!rule) {
89
+ return
90
+ }
91
+
92
+ rule.style.removeProperty(name)
93
+
94
+ // Cleaning up empty selectors is probably not necessary in production and may have performance implications.
95
+ // However, it is required for the editor-preview as it is a dynamic environment and things may get reordered and canvas reused.
96
+ if (options?.deepClean && rule.style.length === 0) {
97
+ this.styleSheet.deleteRule(
98
+ Array.from(this.ruleMap.keys()).indexOf(fullSelector),
99
+ )
100
+ this.ruleMap.delete(fullSelector)
101
+ }
102
+ }
103
+
104
+ public getStyleSheet(): CSSStyleSheet {
105
+ return this.styleSheet
106
+ }
107
+
108
+ /**
109
+ * Maps all selectors to their rule index. This is used to map the initial
110
+ * SSR style variable values to their selectors.
111
+ */
112
+ private hydrateFromBase() {
113
+ const ruleIndex: Map<string, CSSStyleRule> = new Map()
114
+ for (let i = 0; i < this.styleSheet.cssRules.length; i++) {
115
+ let rule = this.styleSheet.cssRules[i]
116
+ const selector = CustomPropertyStyleSheet.selectorFromCSSRule(rule)
117
+ // Get last part of the selector, which is the actual selector we are interested in
118
+ while (
119
+ (rule as CSSGroupingRule).cssRules &&
120
+ (rule as CSSGroupingRule).cssRules.length > 0
121
+ ) {
122
+ rule = (rule as CSSGroupingRule).cssRules[0]
123
+ }
124
+
125
+ ruleIndex.set(selector, rule as CSSStyleRule)
126
+ }
127
+ return ruleIndex
128
+ }
129
+
130
+ private static selectorFromCSSRule(rule: CSSRule): string {
131
+ switch (rule.constructor.name) {
132
+ case 'CSSStyleRule':
133
+ // For these rules, we just return (potentially with subrules if any cssRules exist)
134
+ return `${(rule as CSSStyleRule).selectorText} { ${Array.from(
135
+ (rule as CSSStyleRule).cssRules,
136
+ )
137
+ .map(CustomPropertyStyleSheet.selectorFromCSSRule)
138
+ .join(', ')}}`
139
+ case 'CSSStartingStyleRule':
140
+ return `@starting-style { ${Array.from(
141
+ (rule as CSSStartingStyleRule).cssRules,
142
+ )
143
+ .map(CustomPropertyStyleSheet.selectorFromCSSRule)
144
+ .join(', ')}}`
145
+ case 'CSSMediaRule':
146
+ return `@media ${(rule as CSSMediaRule).media.mediaText} { ${Array.from(
147
+ (rule as CSSMediaRule).cssRules,
148
+ )
149
+ .map(CustomPropertyStyleSheet.selectorFromCSSRule)
150
+ .join(', ')}}`
151
+ case 'CSSNestedDeclarations':
152
+ return ''
153
+ default:
154
+ // eslint-disable-next-line no-console
155
+ console.warn(
156
+ `Unsupported CSS rule type: ${rule.constructor.name}. Returning empty selector.`,
157
+ )
158
+ return ''
159
+ }
160
+ }
161
+
162
+ private static getFullSelector(
163
+ selector: string,
164
+ options?: {
165
+ mediaQuery?: MediaQuery
166
+ startingStyle?: boolean
167
+ },
168
+ ) {
169
+ let result =
170
+ selector + (options?.startingStyle ? ' { @starting-style { }}' : ' { }')
171
+ if (options?.mediaQuery) {
172
+ result = `@media (${Object.entries(options.mediaQuery)
173
+ .map(([key, value]) => `${key}: ${value}`)
174
+ .filter(Boolean)
175
+ .join(') and (')}) { ${result}}`
176
+ }
177
+
178
+ return result
179
+ }
180
+ }
@@ -8,6 +8,10 @@ import {
8
8
  getClassName,
9
9
  toValidClassName,
10
10
  } from '@nordcraft/core/dist/styling/className'
11
+ import {
12
+ syntaxNodeToPropertyAtDefinition,
13
+ type CssSyntaxNode,
14
+ } from '@nordcraft/core/dist/styling/customProperty'
11
15
  import { kebabCase } from '@nordcraft/core/dist/styling/style.css'
12
16
  import { variantSelector } from '@nordcraft/core/dist/styling/variantSelector'
13
17
  import { omitKeys } from '@nordcraft/core/dist/utils/collections'
@@ -61,6 +65,7 @@ export const insertStyles = (
61
65
  root: Component,
62
66
  components: Component[],
63
67
  ) => {
68
+ const registeredCustomProperties = new Map<string, CssSyntaxNode>()
64
69
  const getNodeStyles = (
65
70
  node: ElementNodeModel | ComponentNodeModel,
66
71
  classHash: string,
@@ -131,6 +136,34 @@ ${
131
136
  .join('\n')
132
137
  : ''
133
138
  }
139
+ ${[
140
+ ...Object.entries(node.customProperties ?? {}),
141
+ ...(node.variants?.flatMap((variant) =>
142
+ Object.entries(variant.customProperties ?? {}),
143
+ ) ?? []),
144
+ ]
145
+ .map(([customPropertyName, customProperty]) => {
146
+ const existingCustomProperty =
147
+ registeredCustomProperties.get(customPropertyName)
148
+ if (existingCustomProperty) {
149
+ // Warn if the style variable is already registered with a different syntax, as registration is global.
150
+ // The editor should also report an Error-level issue.
151
+ if (
152
+ existingCustomProperty.type === 'primitive' &&
153
+ customProperty.syntax.type === 'primitive' &&
154
+ existingCustomProperty.name !== customProperty.syntax.name
155
+ ) {
156
+ // eslint-disable-next-line no-console
157
+ console.warn(
158
+ `Custom property "${customPropertyName}" is already registered with a different syntax: "${existingCustomProperty.name}".`,
159
+ )
160
+ }
161
+ return ''
162
+ }
163
+ registeredCustomProperties.set(customPropertyName, customProperty.syntax)
164
+ return syntaxNodeToPropertyAtDefinition(customPropertyName, customProperty)
165
+ })
166
+ .join('\n')}
134
167
  `),
135
168
  )
136
169
  return styleElem
@@ -1,4 +1,7 @@
1
- import type { Component } from '@nordcraft/core/dist/component/component.types'
1
+ import type {
2
+ Component,
3
+ StyleVariant,
4
+ } from '@nordcraft/core/dist/component/component.types'
2
5
 
3
6
  export function omitSubnodeStyleForComponent<T extends Component | undefined>(
4
7
  component: T,
@@ -10,8 +13,13 @@ export function omitSubnodeStyleForComponent<T extends Component | undefined>(
10
13
  nodeId !== 'root'
11
14
  ) {
12
15
  delete node.style
13
- delete node.variants
14
16
  delete node.animations
17
+ node.variants = node.variants?.map(
18
+ ({ customProperties }) =>
19
+ ({
20
+ customProperties,
21
+ }) as StyleVariant,
22
+ )
15
23
  }
16
24
  })
17
25
 
@@ -0,0 +1,54 @@
1
+ import type { StyleVariant } from '@nordcraft/core/dist/component/component.types'
2
+ import type { Signal } from '../signal/signal'
3
+
4
+ import { CUSTOM_PROPERTIES_STYLESHEET_ID } from '@nordcraft/core/dist/styling/theme.const'
5
+ import type { Runtime } from '@nordcraft/core/dist/types'
6
+ import { CustomPropertyStyleSheet } from '../styles/CustomPropertyStyleSheet'
7
+
8
+ let customPropertiesStylesheet: CustomPropertyStyleSheet | undefined
9
+
10
+ export function subscribeCustomProperty({
11
+ selector,
12
+ customPropertyName,
13
+ signal,
14
+ variant,
15
+ root,
16
+ runtime,
17
+ }: {
18
+ selector: string
19
+ customPropertyName: string
20
+ signal: Signal<string>
21
+ variant?: StyleVariant
22
+ root: Document | ShadowRoot
23
+ runtime: Runtime
24
+ }) {
25
+ customPropertiesStylesheet ??= new CustomPropertyStyleSheet(
26
+ root,
27
+ (
28
+ root.getElementById(CUSTOM_PROPERTIES_STYLESHEET_ID) as
29
+ | HTMLStyleElement
30
+ | undefined
31
+ )?.sheet,
32
+ )
33
+
34
+ signal.subscribe(
35
+ customPropertiesStylesheet.registerProperty(
36
+ selector,
37
+ customPropertyName,
38
+ variant,
39
+ ),
40
+ {
41
+ destroy: () => {
42
+ customPropertiesStylesheet?.unregisterProperty(
43
+ selector,
44
+ customPropertyName,
45
+ {
46
+ deepClean: runtime === 'preview',
47
+ mediaQuery: variant?.mediaQuery,
48
+ startingStyle: variant?.startingStyle,
49
+ },
50
+ )
51
+ },
52
+ },
53
+ )
54
+ }