@furystack/shades 11.1.0 → 12.0.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.
- package/CHANGELOG.md +291 -0
- package/README.md +13 -13
- package/esm/component-factory.spec.js +13 -5
- package/esm/component-factory.spec.js.map +1 -1
- package/esm/components/index.d.ts +4 -1
- package/esm/components/index.d.ts.map +1 -1
- package/esm/components/index.js +4 -1
- package/esm/components/index.js.map +1 -1
- package/esm/components/lazy-load.d.ts +2 -4
- package/esm/components/lazy-load.d.ts.map +1 -1
- package/esm/components/lazy-load.js +40 -24
- package/esm/components/lazy-load.js.map +1 -1
- package/esm/components/lazy-load.spec.js +57 -50
- package/esm/components/lazy-load.spec.js.map +1 -1
- package/esm/components/link-to-route.d.ts +2 -0
- package/esm/components/link-to-route.d.ts.map +1 -1
- package/esm/components/link-to-route.js +3 -2
- package/esm/components/link-to-route.js.map +1 -1
- package/esm/components/link-to-route.spec.js +13 -9
- package/esm/components/link-to-route.spec.js.map +1 -1
- package/esm/components/nested-route-link.d.ts +62 -0
- package/esm/components/nested-route-link.d.ts.map +1 -0
- package/esm/components/nested-route-link.js +66 -0
- package/esm/components/nested-route-link.js.map +1 -0
- package/esm/components/nested-route-link.spec.d.ts +2 -0
- package/esm/components/nested-route-link.spec.d.ts.map +1 -0
- package/esm/components/nested-route-link.spec.js +179 -0
- package/esm/components/nested-route-link.spec.js.map +1 -0
- package/esm/components/nested-route-types.d.ts +37 -0
- package/esm/components/nested-route-types.d.ts.map +1 -0
- package/esm/components/nested-route-types.js +2 -0
- package/esm/components/nested-route-types.js.map +1 -0
- package/esm/components/nested-router.d.ts +103 -0
- package/esm/components/nested-router.d.ts.map +1 -0
- package/esm/components/nested-router.js +178 -0
- package/esm/components/nested-router.js.map +1 -0
- package/esm/components/nested-router.spec.d.ts +2 -0
- package/esm/components/nested-router.spec.d.ts.map +1 -0
- package/esm/components/nested-router.spec.js +659 -0
- package/esm/components/nested-router.spec.js.map +1 -0
- package/esm/components/route-link.d.ts +4 -0
- package/esm/components/route-link.d.ts.map +1 -1
- package/esm/components/route-link.js +5 -5
- package/esm/components/route-link.js.map +1 -1
- package/esm/components/route-link.spec.js +16 -12
- package/esm/components/route-link.spec.js.map +1 -1
- package/esm/components/router.d.ts +20 -2
- package/esm/components/router.d.ts.map +1 -1
- package/esm/components/router.js +3 -0
- package/esm/components/router.js.map +1 -1
- package/esm/components/router.spec.js +75 -74
- package/esm/components/router.spec.js.map +1 -1
- package/esm/initialize.d.ts +11 -0
- package/esm/initialize.d.ts.map +1 -1
- package/esm/initialize.js +5 -0
- package/esm/initialize.js.map +1 -1
- package/esm/jsx.d.ts +83 -2
- package/esm/jsx.d.ts.map +1 -1
- package/esm/models/children-list.d.ts +5 -1
- package/esm/models/children-list.d.ts.map +1 -1
- package/esm/models/partial-element.d.ts +12 -2
- package/esm/models/partial-element.d.ts.map +1 -1
- package/esm/models/render-options.d.ts +89 -3
- package/esm/models/render-options.d.ts.map +1 -1
- package/esm/models/selection-state.d.ts +4 -0
- package/esm/models/selection-state.d.ts.map +1 -1
- package/esm/services/location-service.d.ts +11 -0
- package/esm/services/location-service.d.ts.map +1 -1
- package/esm/services/location-service.js +11 -0
- package/esm/services/location-service.js.map +1 -1
- package/esm/services/resource-manager.d.ts +24 -0
- package/esm/services/resource-manager.d.ts.map +1 -1
- package/esm/services/resource-manager.js +30 -0
- package/esm/services/resource-manager.js.map +1 -1
- package/esm/services/resource-manager.spec.js +93 -0
- package/esm/services/resource-manager.spec.js.map +1 -1
- package/esm/services/screen-service.d.ts +81 -4
- package/esm/services/screen-service.d.ts.map +1 -1
- package/esm/services/screen-service.js +75 -4
- package/esm/services/screen-service.js.map +1 -1
- package/esm/services/screen-service.spec.js +91 -7
- package/esm/services/screen-service.spec.js.map +1 -1
- package/esm/shade-component.d.ts +17 -4
- package/esm/shade-component.d.ts.map +1 -1
- package/esm/shade-component.js +67 -5
- package/esm/shade-component.js.map +1 -1
- package/esm/shade-host-props-ref.integration.spec.d.ts +2 -0
- package/esm/shade-host-props-ref.integration.spec.d.ts.map +1 -0
- package/esm/shade-host-props-ref.integration.spec.js +381 -0
- package/esm/shade-host-props-ref.integration.spec.js.map +1 -0
- package/esm/shade-resources.integration.spec.js +208 -39
- package/esm/shade-resources.integration.spec.js.map +1 -1
- package/esm/shade.d.ts +20 -17
- package/esm/shade.d.ts.map +1 -1
- package/esm/shade.js +172 -33
- package/esm/shade.js.map +1 -1
- package/esm/shade.spec.js +31 -30
- package/esm/shade.spec.js.map +1 -1
- package/esm/shades.integration.spec.js +135 -72
- package/esm/shades.integration.spec.js.map +1 -1
- package/esm/style-manager.d.ts +2 -2
- package/esm/style-manager.js +2 -2
- package/esm/svg-types.d.ts +389 -0
- package/esm/svg-types.d.ts.map +1 -0
- package/esm/svg-types.js +9 -0
- package/esm/svg-types.js.map +1 -0
- package/esm/svg.d.ts +15 -0
- package/esm/svg.d.ts.map +1 -0
- package/esm/svg.js +76 -0
- package/esm/svg.js.map +1 -0
- package/esm/svg.spec.d.ts +2 -0
- package/esm/svg.spec.d.ts.map +1 -0
- package/esm/svg.spec.js +80 -0
- package/esm/svg.spec.js.map +1 -0
- package/esm/vnode.d.ts +103 -0
- package/esm/vnode.d.ts.map +1 -0
- package/esm/vnode.integration.spec.d.ts +2 -0
- package/esm/vnode.integration.spec.d.ts.map +1 -0
- package/esm/vnode.integration.spec.js +494 -0
- package/esm/vnode.integration.spec.js.map +1 -0
- package/esm/vnode.js +453 -0
- package/esm/vnode.js.map +1 -0
- package/esm/vnode.spec.d.ts +2 -0
- package/esm/vnode.spec.d.ts.map +1 -0
- package/esm/vnode.spec.js +473 -0
- package/esm/vnode.spec.js.map +1 -0
- package/package.json +3 -3
- package/src/component-factory.spec.tsx +18 -5
- package/src/components/index.ts +4 -1
- package/src/components/lazy-load.spec.tsx +82 -75
- package/src/components/lazy-load.tsx +49 -27
- package/src/components/link-to-route.spec.tsx +25 -21
- package/src/components/link-to-route.tsx +4 -2
- package/src/components/nested-route-link.spec.tsx +303 -0
- package/src/components/nested-route-link.tsx +100 -0
- package/src/components/nested-route-types.ts +42 -0
- package/src/components/nested-router.spec.tsx +817 -0
- package/src/components/nested-router.tsx +256 -0
- package/src/components/route-link.spec.tsx +22 -18
- package/src/components/route-link.tsx +6 -5
- package/src/components/router.spec.tsx +109 -108
- package/src/components/router.tsx +15 -2
- package/src/initialize.ts +12 -0
- package/src/jsx.ts +129 -2
- package/src/models/children-list.ts +7 -1
- package/src/models/partial-element.ts +13 -2
- package/src/models/render-options.ts +90 -3
- package/src/models/selection-state.ts +4 -0
- package/src/services/location-service.tsx +11 -0
- package/src/services/resource-manager.spec.ts +116 -0
- package/src/services/resource-manager.ts +30 -0
- package/src/services/screen-service.spec.ts +109 -7
- package/src/services/screen-service.ts +81 -4
- package/src/shade-component.ts +72 -6
- package/src/shade-host-props-ref.integration.spec.tsx +460 -0
- package/src/shade-resources.integration.spec.tsx +276 -52
- package/src/shade.spec.tsx +40 -39
- package/src/shade.ts +186 -58
- package/src/shades.integration.spec.tsx +154 -80
- package/src/style-manager.ts +2 -2
- package/src/svg-types.ts +437 -0
- package/src/svg.spec.ts +89 -0
- package/src/svg.ts +78 -0
- package/src/vnode.integration.spec.tsx +657 -0
- package/src/vnode.spec.ts +579 -0
- package/src/vnode.ts +508 -0
|
@@ -1,21 +1,74 @@
|
|
|
1
1
|
import { Injectable } from '@furystack/inject'
|
|
2
2
|
import { ObservableValue } from '@furystack/utils'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Available screen size breakpoint identifiers, ordered from smallest to largest.
|
|
6
|
+
*/
|
|
4
7
|
export const ScreenSizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const
|
|
5
8
|
|
|
9
|
+
/**
|
|
10
|
+
* A screen size breakpoint identifier.
|
|
11
|
+
*/
|
|
6
12
|
export type ScreenSize = (typeof ScreenSizes)[number]
|
|
7
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Breakpoint definition with name and size constraints.
|
|
16
|
+
*/
|
|
8
17
|
export type Breakpoint = { name: ScreenSize; minSize: number; maxSize?: number }
|
|
9
18
|
|
|
10
19
|
/**
|
|
11
|
-
* Service for
|
|
20
|
+
* Service for detecting and subscribing to screen size and orientation changes.
|
|
21
|
+
*
|
|
22
|
+
* This service provides reactive observables for responsive design decisions.
|
|
23
|
+
* Use `screenSize.atLeast[size]` to check if the viewport is at least a certain size.
|
|
24
|
+
*
|
|
25
|
+
* **Breakpoint Thresholds:**
|
|
26
|
+
* - `xs`: 0px+ (all sizes)
|
|
27
|
+
* - `sm`: 600px+ (small tablets and up)
|
|
28
|
+
* - `md`: 960px+ (tablets and up)
|
|
29
|
+
* - `lg`: 1280px+ (desktops and up)
|
|
30
|
+
* - `xl`: 1920px+ (large desktops)
|
|
31
|
+
*
|
|
32
|
+
* @example
|
|
33
|
+
* ```typescript
|
|
34
|
+
* const screenService = injector.getInstance(ScreenService);
|
|
35
|
+
*
|
|
36
|
+
* // Check if screen is at least medium size
|
|
37
|
+
* if (screenService.screenSize.atLeast.md.getValue()) {
|
|
38
|
+
* // Show desktop layout
|
|
39
|
+
* }
|
|
40
|
+
*
|
|
41
|
+
* // Subscribe to size changes for responsive behavior
|
|
42
|
+
* screenService.screenSize.atLeast.md.subscribe((isAtLeastMd) => {
|
|
43
|
+
* if (isAtLeastMd) {
|
|
44
|
+
* console.log('Desktop or tablet view');
|
|
45
|
+
* } else {
|
|
46
|
+
* console.log('Mobile view');
|
|
47
|
+
* }
|
|
48
|
+
* });
|
|
49
|
+
*
|
|
50
|
+
* // Get current breakpoint by checking from largest to smallest
|
|
51
|
+
* const getCurrentBreakpoint = (): ScreenSize => {
|
|
52
|
+
* if (screenService.screenSize.atLeast.xl.getValue()) return 'xl';
|
|
53
|
+
* if (screenService.screenSize.atLeast.lg.getValue()) return 'lg';
|
|
54
|
+
* if (screenService.screenSize.atLeast.md.getValue()) return 'md';
|
|
55
|
+
* if (screenService.screenSize.atLeast.sm.getValue()) return 'sm';
|
|
56
|
+
* return 'xs';
|
|
57
|
+
* };
|
|
58
|
+
*
|
|
59
|
+
* // Subscribe to orientation changes
|
|
60
|
+
* screenService.orientation.subscribe((orientation) => {
|
|
61
|
+
* console.log(`Screen is now in ${orientation} mode`);
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
12
64
|
*/
|
|
13
65
|
@Injectable({ lifetime: 'singleton' })
|
|
14
66
|
export class ScreenService implements Disposable {
|
|
15
67
|
private getOrientation = () => (window.matchMedia?.('(orientation:landscape').matches ? 'landscape' : 'portrait')
|
|
16
68
|
|
|
17
69
|
/**
|
|
18
|
-
* The definitions of the
|
|
70
|
+
* The definitions of the breakpoint thresholds in pixels.
|
|
71
|
+
* Each breakpoint represents the minimum width for that size category.
|
|
19
72
|
*/
|
|
20
73
|
public readonly breakpoints: { [K in ScreenSize]: { minSize: number } } = {
|
|
21
74
|
xl: { minSize: 1920 },
|
|
@@ -25,12 +78,26 @@ export class ScreenService implements Disposable {
|
|
|
25
78
|
xs: { minSize: 0 },
|
|
26
79
|
}
|
|
27
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Cleans up event listeners when the service is disposed.
|
|
83
|
+
*/
|
|
28
84
|
public [Symbol.dispose]() {
|
|
29
85
|
window.removeEventListener('resize', this.onResizeListener)
|
|
30
86
|
}
|
|
31
87
|
|
|
32
88
|
/**
|
|
33
|
-
*
|
|
89
|
+
* Observable values for checking if the screen is at least a certain size.
|
|
90
|
+
*
|
|
91
|
+
* Each observable emits `true` when the viewport width is >= the breakpoint threshold,
|
|
92
|
+
* and `false` otherwise. Values update automatically on window resize.
|
|
93
|
+
*
|
|
94
|
+
* @example
|
|
95
|
+
* ```typescript
|
|
96
|
+
* // Hide sidebar on small screens
|
|
97
|
+
* screenService.screenSize.atLeast.md.subscribe((isAtLeastMd) => {
|
|
98
|
+
* sidebarVisible.setValue(isAtLeastMd);
|
|
99
|
+
* });
|
|
100
|
+
* ```
|
|
34
101
|
*/
|
|
35
102
|
public readonly screenSize: {
|
|
36
103
|
atLeast: { [K in ScreenSize]: ObservableValue<boolean> }
|
|
@@ -49,7 +116,17 @@ export class ScreenService implements Disposable {
|
|
|
49
116
|
}
|
|
50
117
|
|
|
51
118
|
/**
|
|
52
|
-
* Observable value for tracking the screen orientation
|
|
119
|
+
* Observable value for tracking the screen orientation.
|
|
120
|
+
* Emits 'landscape' or 'portrait' based on the current viewport dimensions.
|
|
121
|
+
*
|
|
122
|
+
* @example
|
|
123
|
+
* ```typescript
|
|
124
|
+
* screenService.orientation.subscribe((orientation) => {
|
|
125
|
+
* if (orientation === 'landscape') {
|
|
126
|
+
* // Adjust layout for landscape mode
|
|
127
|
+
* }
|
|
128
|
+
* });
|
|
129
|
+
* ```
|
|
53
130
|
*/
|
|
54
131
|
public orientation = new ObservableValue<'landscape' | 'portrait'>(this.getOrientation())
|
|
55
132
|
|
package/src/shade-component.ts
CHANGED
|
@@ -1,17 +1,37 @@
|
|
|
1
1
|
import type { ChildrenList, ShadeComponent } from './models/index.js'
|
|
2
2
|
import { isShadeComponent } from './models/shade-component.js'
|
|
3
|
+
import { SVG_NS, isSvgTag } from './svg.js'
|
|
4
|
+
import { createVNode } from './vnode.js'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Render-mode toggle
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
let renderMode = false
|
|
3
11
|
|
|
4
12
|
/**
|
|
5
|
-
*
|
|
13
|
+
* When true, the JSX factory produces VNode descriptors instead of real DOM elements.
|
|
14
|
+
* Set to true by `_performUpdate` before calling `render()`, then back to false after.
|
|
15
|
+
*/
|
|
16
|
+
export const setRenderMode = (mode: boolean): void => {
|
|
17
|
+
renderMode = mode
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Real-DOM helpers (used outside render mode)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Appends a list of items to an element
|
|
6
26
|
* @param el the root element
|
|
7
27
|
* @param children array of items to append
|
|
8
28
|
*/
|
|
9
|
-
export const appendChild = (el:
|
|
29
|
+
export const appendChild = (el: Element | DocumentFragment, children: ChildrenList) => {
|
|
10
30
|
for (const child of children) {
|
|
11
31
|
if (typeof child === 'string' || typeof child === 'number') {
|
|
12
32
|
el.appendChild(document.createTextNode(child))
|
|
13
33
|
} else {
|
|
14
|
-
if (child instanceof
|
|
34
|
+
if (child instanceof Element || child instanceof DocumentFragment) {
|
|
15
35
|
el.appendChild(child)
|
|
16
36
|
} else if (child instanceof Array) {
|
|
17
37
|
appendChild(el, child)
|
|
@@ -44,7 +64,7 @@ export const attachStyles = (el: HTMLElement, props: unknown) => {
|
|
|
44
64
|
export const attachDataAttributes = <TProps extends object>(el: HTMLElement, props: TProps) => {
|
|
45
65
|
if (props) {
|
|
46
66
|
Object.entries(props)
|
|
47
|
-
.filter(([key]) => key.startsWith('data-'))
|
|
67
|
+
.filter(([key]) => key.startsWith('data-') || key.startsWith('aria-'))
|
|
48
68
|
.forEach(([key, value]) => el.setAttribute(key, (value as string) || ''))
|
|
49
69
|
}
|
|
50
70
|
}
|
|
@@ -69,6 +89,32 @@ export const attachProps = <TProps extends object>(el: HTMLElement, props: TProp
|
|
|
69
89
|
attachDataAttributes(el, props)
|
|
70
90
|
}
|
|
71
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Attaches properties to an SVG element via setAttribute.
|
|
94
|
+
* SVG attributes are XML-based and must be set via setAttribute,
|
|
95
|
+
* not via property assignment like HTML elements.
|
|
96
|
+
* @param el The target SVG element
|
|
97
|
+
* @param props The props to attach
|
|
98
|
+
*/
|
|
99
|
+
export const attachSvgProps = <TProps extends object>(el: Element, props: TProps) => {
|
|
100
|
+
if (!props) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
for (const [key, value] of Object.entries(props)) {
|
|
104
|
+
if (key === 'style' && typeof value === 'object' && value !== null) {
|
|
105
|
+
for (const [sk, sv] of Object.entries(value as Record<string, string>)) {
|
|
106
|
+
;((el as HTMLElement).style as unknown as Record<string, string>)[sk] = sv
|
|
107
|
+
}
|
|
108
|
+
} else if (key === 'className') {
|
|
109
|
+
el.setAttribute('class', String(value))
|
|
110
|
+
} else if (key.startsWith('on') && typeof value === 'function') {
|
|
111
|
+
;(el as unknown as Record<string, unknown>)[key] = value
|
|
112
|
+
} else if (value !== null && value !== undefined && value !== false) {
|
|
113
|
+
el.setAttribute(key, String(value))
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
72
118
|
type CreateComponentArgs<TProps> = [
|
|
73
119
|
elementType: string | ShadeComponent<TProps>,
|
|
74
120
|
props: TProps,
|
|
@@ -83,9 +129,14 @@ export const createComponentInner = <TProps extends object>(
|
|
|
83
129
|
...[elementType, props, ...children]: CreateComponentArgs<TProps>
|
|
84
130
|
) => {
|
|
85
131
|
if (typeof elementType === 'string') {
|
|
86
|
-
const
|
|
132
|
+
const isSvg = isSvgTag(elementType)
|
|
133
|
+
const el = isSvg ? document.createElementNS(SVG_NS, elementType) : document.createElement(elementType)
|
|
87
134
|
|
|
88
|
-
|
|
135
|
+
if (isSvg) {
|
|
136
|
+
attachSvgProps(el, props)
|
|
137
|
+
} else {
|
|
138
|
+
attachProps(el as HTMLElement, props)
|
|
139
|
+
}
|
|
89
140
|
|
|
90
141
|
if (children) {
|
|
91
142
|
appendChild(el, children)
|
|
@@ -108,6 +159,21 @@ export const createFragmentInner = (...[_props, ...children]: CreateFragmentArgs
|
|
|
108
159
|
}
|
|
109
160
|
|
|
110
161
|
export const createComponent = <TProps extends object>(...args: CreateComponentArgs<TProps> | CreateFragmentArgs) => {
|
|
162
|
+
// In render mode, produce VNode descriptors instead of real DOM elements
|
|
163
|
+
if (renderMode) {
|
|
164
|
+
const [type, props, ...children] = args
|
|
165
|
+
// When jsxFragmentFactory === jsxFactory (both "createComponent"), the compiler
|
|
166
|
+
// passes createComponent itself as the first arg for fragments: createComponent(createComponent, null, ...)
|
|
167
|
+
if (type === null || (type as unknown) === createComponent) {
|
|
168
|
+
return createVNode(null, null, ...children) as unknown as ReturnType<typeof createComponentInner>
|
|
169
|
+
}
|
|
170
|
+
return createVNode(
|
|
171
|
+
type as string | ((...a: unknown[]) => unknown),
|
|
172
|
+
props as Record<string, unknown> | null,
|
|
173
|
+
...children,
|
|
174
|
+
) as unknown as ReturnType<typeof createComponentInner>
|
|
175
|
+
}
|
|
176
|
+
|
|
111
177
|
if (args[0] === null) {
|
|
112
178
|
return createFragmentInner(...args)
|
|
113
179
|
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { Injector } from '@furystack/inject'
|
|
2
|
+
import { ObservableValue, 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 { flushUpdates, Shade } from './shade.js'
|
|
7
|
+
|
|
8
|
+
describe('useHostProps integration tests', () => {
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
11
|
+
})
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
document.body.innerHTML = ''
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should set data attributes on the host element', async () => {
|
|
17
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
18
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
19
|
+
|
|
20
|
+
const ExampleComponent = Shade<{ variant: string }>({
|
|
21
|
+
shadowDomName: 'host-props-data-attr-test',
|
|
22
|
+
render: ({ props, useHostProps }) => {
|
|
23
|
+
useHostProps({
|
|
24
|
+
'data-variant': props.variant,
|
|
25
|
+
})
|
|
26
|
+
return <div>content</div>
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
initializeShadeRoot({
|
|
31
|
+
injector,
|
|
32
|
+
rootElement,
|
|
33
|
+
jsxElement: <ExampleComponent variant="primary" />,
|
|
34
|
+
})
|
|
35
|
+
await flushUpdates()
|
|
36
|
+
|
|
37
|
+
const el = document.querySelector('host-props-data-attr-test') as HTMLElement
|
|
38
|
+
expect(el).toBeTruthy()
|
|
39
|
+
expect(el.getAttribute('data-variant')).toBe('primary')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should set aria attributes on the host element', async () => {
|
|
44
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
45
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
46
|
+
|
|
47
|
+
const ExampleComponent = Shade({
|
|
48
|
+
shadowDomName: 'host-props-aria-test',
|
|
49
|
+
render: ({ useHostProps }) => {
|
|
50
|
+
useHostProps({
|
|
51
|
+
role: 'progressbar',
|
|
52
|
+
'aria-valuenow': '50',
|
|
53
|
+
'aria-valuemin': '0',
|
|
54
|
+
'aria-valuemax': '100',
|
|
55
|
+
})
|
|
56
|
+
return <div>progress</div>
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
initializeShadeRoot({
|
|
61
|
+
injector,
|
|
62
|
+
rootElement,
|
|
63
|
+
jsxElement: <ExampleComponent />,
|
|
64
|
+
})
|
|
65
|
+
await flushUpdates()
|
|
66
|
+
|
|
67
|
+
const el = document.querySelector('host-props-aria-test') as HTMLElement
|
|
68
|
+
expect(el.getAttribute('role')).toBe('progressbar')
|
|
69
|
+
expect(el.getAttribute('aria-valuenow')).toBe('50')
|
|
70
|
+
expect(el.getAttribute('aria-valuemin')).toBe('0')
|
|
71
|
+
expect(el.getAttribute('aria-valuemax')).toBe('100')
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should apply CSS custom properties via style', async () => {
|
|
76
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
77
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
78
|
+
|
|
79
|
+
const ExampleComponent = Shade({
|
|
80
|
+
shadowDomName: 'host-props-css-vars-test',
|
|
81
|
+
render: ({ useHostProps }) => {
|
|
82
|
+
useHostProps({
|
|
83
|
+
style: {
|
|
84
|
+
'--my-color': 'red',
|
|
85
|
+
'--my-size': '16px',
|
|
86
|
+
},
|
|
87
|
+
})
|
|
88
|
+
return <div>styled</div>
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
initializeShadeRoot({
|
|
93
|
+
injector,
|
|
94
|
+
rootElement,
|
|
95
|
+
jsxElement: <ExampleComponent />,
|
|
96
|
+
})
|
|
97
|
+
await flushUpdates()
|
|
98
|
+
|
|
99
|
+
const el = document.querySelector('host-props-css-vars-test') as HTMLElement
|
|
100
|
+
expect(el.style.getPropertyValue('--my-color')).toBe('red')
|
|
101
|
+
expect(el.style.getPropertyValue('--my-size')).toBe('16px')
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('should apply standard inline styles', async () => {
|
|
106
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
107
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
108
|
+
|
|
109
|
+
const ExampleComponent = Shade({
|
|
110
|
+
shadowDomName: 'host-props-inline-style-test',
|
|
111
|
+
render: ({ useHostProps }) => {
|
|
112
|
+
useHostProps({
|
|
113
|
+
style: {
|
|
114
|
+
display: 'flex',
|
|
115
|
+
gap: '8px',
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
return <div>styled</div>
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
initializeShadeRoot({
|
|
123
|
+
injector,
|
|
124
|
+
rootElement,
|
|
125
|
+
jsxElement: <ExampleComponent />,
|
|
126
|
+
})
|
|
127
|
+
await flushUpdates()
|
|
128
|
+
|
|
129
|
+
const el = document.querySelector('host-props-inline-style-test') as HTMLElement
|
|
130
|
+
expect(el.style.display).toBe('flex')
|
|
131
|
+
expect(el.style.gap).toBe('8px')
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should merge multiple useHostProps calls', async () => {
|
|
136
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
137
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
138
|
+
|
|
139
|
+
const ExampleComponent = Shade({
|
|
140
|
+
shadowDomName: 'host-props-merge-test',
|
|
141
|
+
render: ({ useHostProps }) => {
|
|
142
|
+
useHostProps({
|
|
143
|
+
'data-first': 'one',
|
|
144
|
+
style: { '--color-a': 'red' },
|
|
145
|
+
})
|
|
146
|
+
useHostProps({
|
|
147
|
+
'data-second': 'two',
|
|
148
|
+
style: { '--color-b': 'blue' },
|
|
149
|
+
})
|
|
150
|
+
return <div>merged</div>
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
initializeShadeRoot({
|
|
155
|
+
injector,
|
|
156
|
+
rootElement,
|
|
157
|
+
jsxElement: <ExampleComponent />,
|
|
158
|
+
})
|
|
159
|
+
await flushUpdates()
|
|
160
|
+
|
|
161
|
+
const el = document.querySelector('host-props-merge-test') as HTMLElement
|
|
162
|
+
expect(el.getAttribute('data-first')).toBe('one')
|
|
163
|
+
expect(el.getAttribute('data-second')).toBe('two')
|
|
164
|
+
expect(el.style.getPropertyValue('--color-a')).toBe('red')
|
|
165
|
+
expect(el.style.getPropertyValue('--color-b')).toBe('blue')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('should remove attributes when they are no longer set on re-render', async () => {
|
|
170
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
171
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
172
|
+
const showExtra = new ObservableValue(true)
|
|
173
|
+
|
|
174
|
+
const ExampleComponent = Shade({
|
|
175
|
+
shadowDomName: 'host-props-remove-attr-test',
|
|
176
|
+
render: ({ useHostProps, useObservable }) => {
|
|
177
|
+
const [show] = useObservable('showExtra', showExtra)
|
|
178
|
+
useHostProps({
|
|
179
|
+
'data-always': 'yes',
|
|
180
|
+
...(show ? { 'data-extra': 'present' } : {}),
|
|
181
|
+
})
|
|
182
|
+
return <div>content</div>
|
|
183
|
+
},
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
initializeShadeRoot({
|
|
187
|
+
injector,
|
|
188
|
+
rootElement,
|
|
189
|
+
jsxElement: <ExampleComponent />,
|
|
190
|
+
})
|
|
191
|
+
await flushUpdates()
|
|
192
|
+
|
|
193
|
+
const el = document.querySelector('host-props-remove-attr-test') as HTMLElement
|
|
194
|
+
expect(el.getAttribute('data-always')).toBe('yes')
|
|
195
|
+
expect(el.getAttribute('data-extra')).toBe('present')
|
|
196
|
+
|
|
197
|
+
showExtra.setValue(false)
|
|
198
|
+
await flushUpdates()
|
|
199
|
+
|
|
200
|
+
expect(el.getAttribute('data-always')).toBe('yes')
|
|
201
|
+
expect(el.getAttribute('data-extra')).toBeNull()
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('should remove CSS custom properties when they are no longer set', async () => {
|
|
206
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
207
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
208
|
+
const showColor = new ObservableValue(true)
|
|
209
|
+
|
|
210
|
+
const ExampleComponent = Shade({
|
|
211
|
+
shadowDomName: 'host-props-remove-css-var-test',
|
|
212
|
+
render: ({ useHostProps, useObservable }) => {
|
|
213
|
+
const [show] = useObservable('showColor', showColor)
|
|
214
|
+
useHostProps({
|
|
215
|
+
style: {
|
|
216
|
+
display: 'block',
|
|
217
|
+
...(show ? { '--my-color': 'red' } : {}),
|
|
218
|
+
},
|
|
219
|
+
})
|
|
220
|
+
return <div>content</div>
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
initializeShadeRoot({
|
|
225
|
+
injector,
|
|
226
|
+
rootElement,
|
|
227
|
+
jsxElement: <ExampleComponent />,
|
|
228
|
+
})
|
|
229
|
+
await flushUpdates()
|
|
230
|
+
|
|
231
|
+
const el = document.querySelector('host-props-remove-css-var-test') as HTMLElement
|
|
232
|
+
expect(el.style.getPropertyValue('--my-color')).toBe('red')
|
|
233
|
+
expect(el.style.display).toBe('block')
|
|
234
|
+
|
|
235
|
+
showColor.setValue(false)
|
|
236
|
+
await flushUpdates()
|
|
237
|
+
|
|
238
|
+
expect(el.style.getPropertyValue('--my-color')).toBe('')
|
|
239
|
+
expect(el.style.display).toBe('block')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('should set event handlers on the host element', async () => {
|
|
244
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
245
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
246
|
+
let clicked = false
|
|
247
|
+
|
|
248
|
+
const ExampleComponent = Shade({
|
|
249
|
+
shadowDomName: 'host-props-event-test',
|
|
250
|
+
render: ({ useHostProps }) => {
|
|
251
|
+
useHostProps({
|
|
252
|
+
onclick: () => {
|
|
253
|
+
clicked = true
|
|
254
|
+
},
|
|
255
|
+
})
|
|
256
|
+
return <div>clickable</div>
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
initializeShadeRoot({
|
|
261
|
+
injector,
|
|
262
|
+
rootElement,
|
|
263
|
+
jsxElement: <ExampleComponent />,
|
|
264
|
+
})
|
|
265
|
+
await flushUpdates()
|
|
266
|
+
|
|
267
|
+
const el = document.querySelector('host-props-event-test') as HTMLElement
|
|
268
|
+
el.click()
|
|
269
|
+
expect(clicked).toBe(true)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
describe('useRef integration tests', () => {
|
|
275
|
+
beforeEach(() => {
|
|
276
|
+
document.body.innerHTML = '<div id="root"></div>'
|
|
277
|
+
})
|
|
278
|
+
afterEach(() => {
|
|
279
|
+
document.body.innerHTML = ''
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('should set ref.current to the mounted element', async () => {
|
|
283
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
284
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
285
|
+
|
|
286
|
+
let capturedRef: { readonly current: HTMLDivElement | null } | undefined
|
|
287
|
+
|
|
288
|
+
const ExampleComponent = Shade({
|
|
289
|
+
shadowDomName: 'use-ref-basic-test',
|
|
290
|
+
render: ({ useRef }) => {
|
|
291
|
+
const divRef = useRef<HTMLDivElement>('myDiv')
|
|
292
|
+
capturedRef = divRef
|
|
293
|
+
return (
|
|
294
|
+
<div ref={divRef} id="target">
|
|
295
|
+
hello
|
|
296
|
+
</div>
|
|
297
|
+
)
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
initializeShadeRoot({
|
|
302
|
+
injector,
|
|
303
|
+
rootElement,
|
|
304
|
+
jsxElement: <ExampleComponent />,
|
|
305
|
+
})
|
|
306
|
+
await flushUpdates()
|
|
307
|
+
|
|
308
|
+
expect(capturedRef).toBeTruthy()
|
|
309
|
+
expect(capturedRef!.current).toBeTruthy()
|
|
310
|
+
expect(capturedRef!.current).toBe(document.getElementById('target'))
|
|
311
|
+
expect(capturedRef!.current?.textContent).toBe('hello')
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
|
|
315
|
+
it('should return the same ref object across re-renders', async () => {
|
|
316
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
317
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
318
|
+
const counter = new ObservableValue(0)
|
|
319
|
+
|
|
320
|
+
const capturedRefs: Array<{ readonly current: Element | null }> = []
|
|
321
|
+
|
|
322
|
+
const ExampleComponent = Shade({
|
|
323
|
+
shadowDomName: 'use-ref-stable-test',
|
|
324
|
+
render: ({ useRef, useObservable }) => {
|
|
325
|
+
const [count] = useObservable('counter', counter)
|
|
326
|
+
const divRef = useRef('myDiv')
|
|
327
|
+
capturedRefs.push(divRef)
|
|
328
|
+
return <div ref={divRef}>{count}</div>
|
|
329
|
+
},
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
initializeShadeRoot({
|
|
333
|
+
injector,
|
|
334
|
+
rootElement,
|
|
335
|
+
jsxElement: <ExampleComponent />,
|
|
336
|
+
})
|
|
337
|
+
await flushUpdates()
|
|
338
|
+
|
|
339
|
+
counter.setValue(1)
|
|
340
|
+
await flushUpdates()
|
|
341
|
+
|
|
342
|
+
counter.setValue(2)
|
|
343
|
+
await flushUpdates()
|
|
344
|
+
|
|
345
|
+
expect(capturedRefs.length).toBe(3)
|
|
346
|
+
expect(capturedRefs[0]).toBe(capturedRefs[1])
|
|
347
|
+
expect(capturedRefs[1]).toBe(capturedRefs[2])
|
|
348
|
+
})
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it('should set ref.current on nested child elements', async () => {
|
|
352
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
353
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
354
|
+
|
|
355
|
+
let capturedInputRef: { readonly current: HTMLInputElement | null } | undefined
|
|
356
|
+
|
|
357
|
+
const ExampleComponent = Shade({
|
|
358
|
+
shadowDomName: 'use-ref-nested-test',
|
|
359
|
+
render: ({ useRef }) => {
|
|
360
|
+
const inputRef = useRef<HTMLInputElement>('input')
|
|
361
|
+
capturedInputRef = inputRef
|
|
362
|
+
return (
|
|
363
|
+
<div>
|
|
364
|
+
<label>
|
|
365
|
+
<input ref={inputRef} type="text" id="my-input" />
|
|
366
|
+
</label>
|
|
367
|
+
</div>
|
|
368
|
+
)
|
|
369
|
+
},
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
initializeShadeRoot({
|
|
373
|
+
injector,
|
|
374
|
+
rootElement,
|
|
375
|
+
jsxElement: <ExampleComponent />,
|
|
376
|
+
})
|
|
377
|
+
await flushUpdates()
|
|
378
|
+
|
|
379
|
+
expect(capturedInputRef).toBeTruthy()
|
|
380
|
+
expect(capturedInputRef!.current).toBeTruthy()
|
|
381
|
+
expect(capturedInputRef!.current).toBe(document.getElementById('my-input'))
|
|
382
|
+
expect(capturedInputRef!.current?.type).toBe('text')
|
|
383
|
+
})
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('should clear ref.current when element is unmounted', async () => {
|
|
387
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
388
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
389
|
+
const showChild = new ObservableValue(true)
|
|
390
|
+
|
|
391
|
+
let capturedRef: { readonly current: HTMLSpanElement | null } | undefined
|
|
392
|
+
|
|
393
|
+
const ExampleComponent = Shade({
|
|
394
|
+
shadowDomName: 'use-ref-unmount-test',
|
|
395
|
+
render: ({ useRef, useObservable }) => {
|
|
396
|
+
const [show] = useObservable('showChild', showChild)
|
|
397
|
+
const spanRef = useRef<HTMLSpanElement>('span')
|
|
398
|
+
capturedRef = spanRef
|
|
399
|
+
return <div>{show ? <span ref={spanRef}>visible</span> : null}</div>
|
|
400
|
+
},
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
initializeShadeRoot({
|
|
404
|
+
injector,
|
|
405
|
+
rootElement,
|
|
406
|
+
jsxElement: <ExampleComponent />,
|
|
407
|
+
})
|
|
408
|
+
await flushUpdates()
|
|
409
|
+
|
|
410
|
+
expect(capturedRef!.current).toBeTruthy()
|
|
411
|
+
expect(capturedRef!.current?.textContent).toBe('visible')
|
|
412
|
+
|
|
413
|
+
showChild.setValue(false)
|
|
414
|
+
await flushUpdates()
|
|
415
|
+
|
|
416
|
+
expect(capturedRef!.current).toBeNull()
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
it('should work with useRef in onChange callbacks', async () => {
|
|
421
|
+
await usingAsync(new Injector(), async (injector) => {
|
|
422
|
+
const rootElement = document.getElementById('root') as HTMLDivElement
|
|
423
|
+
const counter = new ObservableValue(0)
|
|
424
|
+
|
|
425
|
+
const ExampleComponent = Shade({
|
|
426
|
+
shadowDomName: 'use-ref-onchange-test',
|
|
427
|
+
render: ({ useRef, useObservable }) => {
|
|
428
|
+
const spanRef = useRef<HTMLSpanElement>('counterSpan')
|
|
429
|
+
useObservable('counter', counter, {
|
|
430
|
+
onChange: (value) => {
|
|
431
|
+
if (spanRef.current) {
|
|
432
|
+
spanRef.current.textContent = String(value)
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
})
|
|
436
|
+
return (
|
|
437
|
+
<span ref={spanRef} id="counter-span">
|
|
438
|
+
0
|
|
439
|
+
</span>
|
|
440
|
+
)
|
|
441
|
+
},
|
|
442
|
+
})
|
|
443
|
+
|
|
444
|
+
initializeShadeRoot({
|
|
445
|
+
injector,
|
|
446
|
+
rootElement,
|
|
447
|
+
jsxElement: <ExampleComponent />,
|
|
448
|
+
})
|
|
449
|
+
await flushUpdates()
|
|
450
|
+
|
|
451
|
+
const span = document.getElementById('counter-span')
|
|
452
|
+
expect(span?.textContent).toBe('0')
|
|
453
|
+
|
|
454
|
+
counter.setValue(42)
|
|
455
|
+
await flushUpdates()
|
|
456
|
+
|
|
457
|
+
expect(span?.textContent).toBe('42')
|
|
458
|
+
})
|
|
459
|
+
})
|
|
460
|
+
})
|