@furystack/shades 11.0.35 → 11.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.
Files changed (70) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +86 -0
  3. package/esm/compile-route.spec.d.ts +2 -0
  4. package/esm/compile-route.spec.d.ts.map +1 -0
  5. package/esm/compile-route.spec.js +34 -0
  6. package/esm/compile-route.spec.js.map +1 -0
  7. package/esm/components/route-link.d.ts.map +1 -1
  8. package/esm/components/route-link.js +4 -5
  9. package/esm/components/route-link.js.map +1 -1
  10. package/esm/components/route-link.spec.js +1 -1
  11. package/esm/components/route-link.spec.js.map +1 -1
  12. package/esm/css-generator.d.ts +50 -0
  13. package/esm/css-generator.d.ts.map +1 -0
  14. package/esm/css-generator.js +107 -0
  15. package/esm/css-generator.js.map +1 -0
  16. package/esm/css-generator.spec.d.ts +2 -0
  17. package/esm/css-generator.spec.d.ts.map +1 -0
  18. package/esm/css-generator.spec.js +162 -0
  19. package/esm/css-generator.spec.js.map +1 -0
  20. package/esm/index.d.ts +2 -0
  21. package/esm/index.d.ts.map +1 -1
  22. package/esm/index.js +2 -0
  23. package/esm/index.js.map +1 -1
  24. package/esm/models/css-object.d.ts +33 -0
  25. package/esm/models/css-object.d.ts.map +1 -0
  26. package/esm/models/css-object.js +2 -0
  27. package/esm/models/css-object.js.map +1 -0
  28. package/esm/models/index.d.ts +1 -0
  29. package/esm/models/index.d.ts.map +1 -1
  30. package/esm/models/index.js +1 -0
  31. package/esm/models/index.js.map +1 -1
  32. package/esm/shade.d.ts +18 -2
  33. package/esm/shade.d.ts.map +1 -1
  34. package/esm/shade.js +8 -0
  35. package/esm/shade.js.map +1 -1
  36. package/esm/shade.spec.d.ts +2 -0
  37. package/esm/shade.spec.d.ts.map +1 -0
  38. package/esm/shade.spec.js +197 -0
  39. package/esm/shade.spec.js.map +1 -0
  40. package/esm/style-manager.d.ts +65 -0
  41. package/esm/style-manager.d.ts.map +1 -0
  42. package/esm/style-manager.js +95 -0
  43. package/esm/style-manager.js.map +1 -0
  44. package/esm/style-manager.spec.d.ts +2 -0
  45. package/esm/style-manager.spec.d.ts.map +1 -0
  46. package/esm/style-manager.spec.js +179 -0
  47. package/esm/style-manager.spec.js.map +1 -0
  48. package/esm/styled-element.spec.d.ts +2 -0
  49. package/esm/styled-element.spec.d.ts.map +1 -0
  50. package/esm/styled-element.spec.js +86 -0
  51. package/esm/styled-element.spec.js.map +1 -0
  52. package/esm/styled-shade.spec.d.ts +2 -0
  53. package/esm/styled-shade.spec.d.ts.map +1 -0
  54. package/esm/styled-shade.spec.js +66 -0
  55. package/esm/styled-shade.spec.js.map +1 -0
  56. package/package.json +1 -1
  57. package/src/compile-route.spec.ts +39 -0
  58. package/src/components/route-link.spec.tsx +1 -1
  59. package/src/components/route-link.tsx +4 -5
  60. package/src/css-generator.spec.ts +183 -0
  61. package/src/css-generator.ts +117 -0
  62. package/src/index.ts +2 -0
  63. package/src/models/css-object.ts +34 -0
  64. package/src/models/index.ts +1 -0
  65. package/src/shade.spec.tsx +238 -0
  66. package/src/shade.ts +29 -2
  67. package/src/style-manager.spec.ts +229 -0
  68. package/src/style-manager.ts +104 -0
  69. package/src/styled-element.spec.tsx +117 -0
  70. package/src/styled-shade.spec.ts +86 -0
@@ -0,0 +1,117 @@
1
+ import type { CSSObject, CSSProperties } from './models/css-object.js'
2
+
3
+ /**
4
+ * Converts a camelCase string to kebab-case
5
+ * @param str - The camelCase string to convert
6
+ * @returns The kebab-case string
7
+ * @example
8
+ * camelToKebab('backgroundColor') // 'background-color'
9
+ * camelToKebab('fontSize') // 'font-size'
10
+ */
11
+ export const camelToKebab = (str: string): string => {
12
+ return str.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
13
+ }
14
+
15
+ /**
16
+ * Checks if a key is a selector key (starts with '&')
17
+ * @param key - The key to check
18
+ * @returns True if the key is a selector key
19
+ */
20
+ export const isSelectorKey = (key: string): boolean => {
21
+ return key.startsWith('&')
22
+ }
23
+
24
+ /**
25
+ * Converts CSS properties to a CSS declaration string
26
+ * @param properties - The CSS properties object
27
+ * @returns A CSS declaration string (e.g., "color: red; padding: 10px;")
28
+ */
29
+ export const propertiesToCSSString = (properties: CSSProperties): string => {
30
+ const declarations: string[] = []
31
+
32
+ for (const key in properties) {
33
+ if (Object.prototype.hasOwnProperty.call(properties, key) && !isSelectorKey(key)) {
34
+ const value = properties[key as keyof CSSProperties]
35
+ if (value !== undefined && value !== null && value !== '' && typeof value === 'string') {
36
+ declarations.push(`${camelToKebab(key)}: ${value}`)
37
+ }
38
+ }
39
+ }
40
+
41
+ return declarations.join('; ')
42
+ }
43
+
44
+ /**
45
+ * Generates a CSS rule string from a selector and properties
46
+ * @param selector - The CSS selector
47
+ * @param properties - The CSS properties object
48
+ * @returns A complete CSS rule string (e.g., "selector { color: red; }")
49
+ */
50
+ export const generateCSSRule = (selector: string, properties: CSSProperties): string => {
51
+ const cssString = propertiesToCSSString(properties)
52
+ if (!cssString) {
53
+ return ''
54
+ }
55
+ return `${selector} { ${cssString}; }`
56
+ }
57
+
58
+ /**
59
+ * Generates complete CSS from a CSSObject for a given component selector
60
+ * @param selector - The base selector (typically the shadowDomName)
61
+ * @param cssObject - The CSSObject containing styles and nested selectors
62
+ * @returns A complete CSS string with all rules
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * generateCSS('my-component', {
67
+ * color: 'red',
68
+ * '&:hover': { color: 'blue' },
69
+ * '& .inner': { fontWeight: 'bold' }
70
+ * })
71
+ * // Returns:
72
+ * // "my-component { color: red; }
73
+ * // my-component:hover { color: blue; }
74
+ * // my-component .inner { font-weight: bold; }"
75
+ * ```
76
+ */
77
+ export const generateCSS = (selector: string, cssObject: CSSObject): string => {
78
+ const rules: string[] = []
79
+
80
+ // Extract base properties (non-selector keys)
81
+ const baseProperties: CSSProperties = {}
82
+ const selectorRules: Array<{ selectorKey: string; properties: CSSProperties }> = []
83
+
84
+ for (const key in cssObject) {
85
+ if (Object.prototype.hasOwnProperty.call(cssObject, key)) {
86
+ if (isSelectorKey(key)) {
87
+ const properties = cssObject[key as keyof CSSObject]
88
+ if (properties && typeof properties === 'object') {
89
+ selectorRules.push({ selectorKey: key, properties: properties as CSSProperties })
90
+ }
91
+ } else {
92
+ const value = cssObject[key as keyof CSSObject]
93
+ if (typeof value !== 'object') {
94
+ ;(baseProperties as Record<string, unknown>)[key] = value
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ // Generate base rule
101
+ const baseRule = generateCSSRule(selector, baseProperties)
102
+ if (baseRule) {
103
+ rules.push(baseRule)
104
+ }
105
+
106
+ // Generate selector rules
107
+ for (const { selectorKey, properties } of selectorRules) {
108
+ // Replace '&' with the base selector
109
+ const fullSelector = selectorKey.replace(/&/g, selector)
110
+ const rule = generateCSSRule(fullSelector, properties)
111
+ if (rule) {
112
+ rules.push(rule)
113
+ }
114
+ }
115
+
116
+ return rules.join('\n')
117
+ }
package/src/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  export * from './compile-route.js'
2
2
  export * from './components/index.js'
3
+ export * from './css-generator.js'
3
4
  export * from './initialize.js'
4
5
  export * from './models/index.js'
5
6
  export * from './services/index.js'
6
7
  export * from './shade-component.js'
7
8
  export * from './shade.js'
9
+ export * from './style-manager.js'
8
10
  export * from './styled-element.js'
9
11
  export * from './styled-shade.js'
10
12
  import './jsx.js'
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Base CSS properties - subset of CSSStyleDeclaration
3
+ */
4
+ export type CSSProperties = Partial<CSSStyleDeclaration>
5
+
6
+ /**
7
+ * Selector key pattern for pseudo-classes and nested selectors
8
+ * Examples: '&:hover', '&:active', '& .className', '& > div'
9
+ */
10
+ export type SelectorKey = `&${string}`
11
+
12
+ /**
13
+ * CSS object supporting nested selectors for component-level styling.
14
+ *
15
+ * Use this type for the `css` property in Shade components to define
16
+ * styles that are injected as a stylesheet during component registration.
17
+ *
18
+ * @example
19
+ * ```typescript
20
+ * const styles: CSSObject = {
21
+ * padding: '16px',
22
+ * backgroundColor: 'white',
23
+ * '&:hover': {
24
+ * backgroundColor: '#f0f0f0'
25
+ * },
26
+ * '& .title': {
27
+ * fontWeight: 'bold'
28
+ * }
29
+ * }
30
+ * ```
31
+ */
32
+ export type CSSObject = CSSProperties & {
33
+ [K in SelectorKey]?: CSSProperties
34
+ }
@@ -1,4 +1,5 @@
1
1
  export * from './children-list.js'
2
+ export * from './css-object.js'
2
3
  export * from './partial-element.js'
3
4
  export * from './render-options.js'
4
5
  export * from './selection-state.js'
@@ -0,0 +1,238 @@
1
+ import { Injector } from '@furystack/inject'
2
+ import { sleepAsync, usingAsync } from '@furystack/utils'
3
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
4
+ import { initializeShadeRoot } from './initialize.js'
5
+ import { createComponent } from './shade-component.js'
6
+ import { Shade } from './shade.js'
7
+
8
+ describe('Shade edge cases', () => {
9
+ beforeEach(() => {
10
+ document.body.innerHTML = '<div id="root"></div>'
11
+ })
12
+ afterEach(() => {
13
+ document.body.innerHTML = ''
14
+ })
15
+
16
+ describe('duplicate shadowDomName error', () => {
17
+ it('should throw an error when registering a duplicate shadowDomName', () => {
18
+ // First registration should succeed
19
+ Shade({
20
+ shadowDomName: 'shade-duplicate-test',
21
+ render: () => <div>First</div>,
22
+ })
23
+
24
+ // Second registration with the same name should throw
25
+ expect(() => {
26
+ Shade({
27
+ shadowDomName: 'shade-duplicate-test',
28
+ render: () => <div>Second</div>,
29
+ })
30
+ }).toThrow("A custom shade with shadow DOM name 'shade-duplicate-test' has already been registered!")
31
+ })
32
+
33
+ it('should include the duplicate name in the error message', () => {
34
+ const uniqueName = `shade-duplicate-name-in-error-${Date.now()}`
35
+
36
+ Shade({
37
+ shadowDomName: uniqueName,
38
+ render: () => <div>First</div>,
39
+ })
40
+
41
+ try {
42
+ Shade({
43
+ shadowDomName: uniqueName,
44
+ render: () => <div>Second</div>,
45
+ })
46
+ // Should not reach here
47
+ expect.fail('Expected an error to be thrown')
48
+ } catch (e) {
49
+ expect((e as Error).message).toContain(uniqueName)
50
+ }
51
+ })
52
+ })
53
+
54
+ describe('injector from props', () => {
55
+ it('should use props injector for child component instead of inheriting from parent', async () => {
56
+ await usingAsync(new Injector(), async (rootInjector) => {
57
+ const propsInjector = new Injector()
58
+ const rootElement = document.getElementById('root') as HTMLDivElement
59
+
60
+ let parentCapturedInjector: Injector | undefined
61
+ let childCapturedInjector: Injector | undefined
62
+
63
+ const ChildComponent = Shade<{ injector?: Injector }>({
64
+ shadowDomName: 'shade-injector-child-props-test',
65
+ render: ({ injector }) => {
66
+ childCapturedInjector = injector
67
+ return <div>Child</div>
68
+ },
69
+ })
70
+
71
+ const ParentComponent = Shade({
72
+ shadowDomName: 'shade-injector-parent-props-test',
73
+ render: ({ injector, children }) => {
74
+ parentCapturedInjector = injector
75
+ return <div>{children}</div>
76
+ },
77
+ })
78
+
79
+ initializeShadeRoot({
80
+ injector: rootInjector,
81
+ rootElement,
82
+ jsxElement: (
83
+ <ParentComponent>
84
+ <ChildComponent injector={propsInjector} />
85
+ </ParentComponent>
86
+ ),
87
+ })
88
+
89
+ await sleepAsync(10)
90
+
91
+ // Parent should use root injector (inherited from parent)
92
+ expect(parentCapturedInjector).toBe(rootInjector)
93
+ // Child should use the props injector, not the parent's
94
+ expect(childCapturedInjector).toBe(propsInjector)
95
+ expect(childCapturedInjector).not.toBe(rootInjector)
96
+ })
97
+ })
98
+ })
99
+
100
+ describe('BroadcastChannel cross-tab communication', () => {
101
+ it('should update stored state when receiving BroadcastChannel message with matching key', async () => {
102
+ const mockedStorage = new Map<string, string>()
103
+
104
+ const store: typeof localStorage = {
105
+ getItem: (key) => mockedStorage.get(key) || null,
106
+ setItem: (key, value) => mockedStorage.set(key, value),
107
+ length: 0,
108
+ clear: () => mockedStorage.clear(),
109
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
110
+ removeItem: (key) => mockedStorage.delete(key),
111
+ }
112
+
113
+ await usingAsync(new Injector(), async (injector) => {
114
+ const rootElement = document.getElementById('root') as HTMLDivElement
115
+ const stateKey = 'broadcast-test-key'
116
+
117
+ const ExampleComponent = Shade({
118
+ shadowDomName: 'shade-broadcast-channel-test',
119
+ render: ({ useStoredState }) => {
120
+ const [value] = useStoredState(stateKey, 'initial', store)
121
+ return <div id="value">{value}</div>
122
+ },
123
+ })
124
+
125
+ initializeShadeRoot({
126
+ injector,
127
+ rootElement,
128
+ jsxElement: <ExampleComponent />,
129
+ })
130
+
131
+ await sleepAsync(50)
132
+ expect(document.getElementById('value')?.textContent).toBe('initial')
133
+
134
+ // Simulate cross-tab message via BroadcastChannel
135
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
136
+ channel.postMessage({ key: stateKey, value: 'updated-from-other-tab' })
137
+
138
+ await sleepAsync(50)
139
+ expect(document.getElementById('value')?.textContent).toBe('updated-from-other-tab')
140
+
141
+ channel.close()
142
+ })
143
+ })
144
+
145
+ it('should ignore BroadcastChannel messages with different key', async () => {
146
+ const mockedStorage = new Map<string, string>()
147
+
148
+ const store: typeof localStorage = {
149
+ getItem: (key) => mockedStorage.get(key) || null,
150
+ setItem: (key, value) => mockedStorage.set(key, value),
151
+ length: 0,
152
+ clear: () => mockedStorage.clear(),
153
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
154
+ removeItem: (key) => mockedStorage.delete(key),
155
+ }
156
+
157
+ await usingAsync(new Injector(), async (injector) => {
158
+ const rootElement = document.getElementById('root') as HTMLDivElement
159
+ const stateKey = 'broadcast-filter-test-key'
160
+
161
+ const ExampleComponent = Shade({
162
+ shadowDomName: 'shade-broadcast-channel-filter-test',
163
+ render: ({ useStoredState }) => {
164
+ const [value] = useStoredState(stateKey, 'initial', store)
165
+ return <div id="value">{value}</div>
166
+ },
167
+ })
168
+
169
+ initializeShadeRoot({
170
+ injector,
171
+ rootElement,
172
+ jsxElement: <ExampleComponent />,
173
+ })
174
+
175
+ await sleepAsync(50)
176
+ expect(document.getElementById('value')?.textContent).toBe('initial')
177
+
178
+ // Simulate cross-tab message with different key
179
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
180
+ channel.postMessage({ key: 'different-key', value: 'should-be-ignored' })
181
+
182
+ await sleepAsync(50)
183
+ // Value should remain unchanged
184
+ expect(document.getElementById('value')?.textContent).toBe('initial')
185
+
186
+ channel.close()
187
+ })
188
+ })
189
+
190
+ it('should cleanup BroadcastChannel on component disposal', async () => {
191
+ const mockedStorage = new Map<string, string>()
192
+
193
+ const store: typeof localStorage = {
194
+ getItem: (key) => mockedStorage.get(key) || null,
195
+ setItem: (key, value) => mockedStorage.set(key, value),
196
+ length: 0,
197
+ clear: () => mockedStorage.clear(),
198
+ key: (index) => Array.from(mockedStorage.keys())[index] || null,
199
+ removeItem: (key) => mockedStorage.delete(key),
200
+ }
201
+
202
+ await usingAsync(new Injector(), async (injector) => {
203
+ const rootElement = document.getElementById('root') as HTMLDivElement
204
+ const stateKey = 'broadcast-cleanup-test-key'
205
+
206
+ const ExampleComponent = Shade({
207
+ shadowDomName: 'shade-broadcast-channel-cleanup-test',
208
+ render: ({ useStoredState }) => {
209
+ const [value] = useStoredState(stateKey, 'initial', store)
210
+ return <div id="value">{value}</div>
211
+ },
212
+ })
213
+
214
+ initializeShadeRoot({
215
+ injector,
216
+ rootElement,
217
+ jsxElement: <ExampleComponent />,
218
+ })
219
+
220
+ await sleepAsync(50)
221
+ expect(document.getElementById('value')?.textContent).toBe('initial')
222
+
223
+ // Remove the component from DOM
224
+ document.body.innerHTML = ''
225
+ await sleepAsync(50)
226
+
227
+ // Create a new channel to send a message (simulating another tab)
228
+ const channel = new BroadcastChannel('useStoredState-broadcast-channel')
229
+ // This should not cause any errors since the component's channel should be closed
230
+ channel.postMessage({ key: stateKey, value: 'should-not-crash' })
231
+ await sleepAsync(50)
232
+
233
+ channel.close()
234
+ // Test passes if no errors occur
235
+ })
236
+ })
237
+ })
238
+ })
package/src/shade.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import type { Constructable } from '@furystack/inject'
2
2
  import { hasInjectorReference, Injector } from '@furystack/inject'
3
3
  import { ObservableValue } from '@furystack/utils'
4
- import type { ChildrenList, PartialElement, RenderOptions } from './models/index.js'
4
+ import type { ChildrenList, CSSObject, PartialElement, RenderOptions } from './models/index.js'
5
5
  import { LocationService } from './services/location-service.js'
6
6
  import { ResourceManager } from './services/resource-manager.js'
7
7
  import { attachProps, attachStyles } from './shade-component.js'
8
+ import { StyleManager } from './style-manager.js'
8
9
 
9
10
  export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
10
11
  /**
@@ -45,9 +46,26 @@ export type ShadeOptions<TProps, TElementBase extends HTMLElement> = {
45
46
  elementBase?: Constructable<TElementBase>
46
47
 
47
48
  /**
48
- * A default style that will be applied to the element. Can be overridden by external styles.
49
+ * A default style that will be applied to the element as inline styles.
50
+ * Can be overridden by external styles on instances.
49
51
  */
50
52
  style?: Partial<CSSStyleDeclaration>
53
+
54
+ /**
55
+ * CSS styles injected as a stylesheet during component registration.
56
+ * Supports pseudo-selectors (&:hover, &:active) and nested selectors (& .class).
57
+ * Use this for component-level styling that doesn't need per-instance overrides.
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * css: {
62
+ * padding: '16px',
63
+ * '&:hover': { backgroundColor: '#f0f0f0' },
64
+ * '& .title': { fontWeight: 'bold' }
65
+ * }
66
+ * ```
67
+ */
68
+ css?: CSSObject
51
69
  }
52
70
 
53
71
  /**
@@ -63,6 +81,11 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
63
81
 
64
82
  const existing = customElements.get(customElementName)
65
83
  if (!existing) {
84
+ // Register CSS styles if provided
85
+ if (o.css) {
86
+ StyleManager.registerComponentStyles(customElementName, o.css, o.elementBaseName)
87
+ }
88
+
66
89
  const ElementBase = o.elementBase || HTMLElement
67
90
 
68
91
  customElements.define(
@@ -285,6 +308,10 @@ export const Shade = <TProps, TElementBase extends HTMLElement = HTMLElement>(
285
308
  el.props = props || ({} as TProps & PartialElement<TElementBase>)
286
309
  el.shadeChildren = children
287
310
 
311
+ if (o.elementBaseName) {
312
+ el.setAttribute('is', customElementName)
313
+ }
314
+
288
315
  attachStyles(el, { style: o.style })
289
316
  attachProps(el, props)
290
317