• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

SAP / ui5-webcomponents-react / 10455461492

19 Aug 2024 02:36PM CUT coverage: 79.674% (-0.2%) from 79.875%
10455461492

Pull #6212

github

web-flow
Merge bd97681c7 into 70f9f2732
Pull Request #6212: feat: expose CLI package for creating web component wrappers

2526 of 3770 branches covered (67.0%)

4598 of 5771 relevant lines covered (79.67%)

72844.35 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.17
/packages/main/src/internal/withWebComponent.tsx
1
'use client';
2

3
import { getEffectiveScopingSuffixForTag } from '@ui5/webcomponents-base/dist/CustomElementsScope.js';
4
import { useIsomorphicLayoutEffect, useSyncRef } from '@ui5/webcomponents-react-base';
5
import type { ComponentType, ReactElement, ReactNode, Ref } from 'react';
6
import { cloneElement, forwardRef, Fragment, isValidElement, useEffect, useState, version } from 'react';
7
import type { CommonProps, Ui5DomRef } from '../types/index.js';
8
import { useServerSideEffect } from './ssr.js';
9
import { camelToKebabCase, capitalizeFirstLetter, kebabToCamelCase } from './utils.js';
10

11
const SEMVER_REGEX =
12
  /^(?<major>0|[1-9]\d*)\.(?<minor>0|[1-9]\d*)\.(?<patch>0|[1-9]\d*)(?:-(?<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
361✔
13

14
const createEventPropName = (eventName: string) => `on${capitalizeFirstLetter(kebabToCamelCase(eventName))}`;
1,714,795✔
15

16
const isPrimitiveAttribute = (value: unknown): boolean => {
361✔
17
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
686,191✔
18
};
19

20
type EventHandler = (event: CustomEvent<unknown>) => void;
21

22
export interface WithWebComponentPropTypes {
23
  /**
24
   * Defines whether the component should wait for the underlying custom element of the web component to be defined. This can be useful, for example, for using instance methods when mounting the component.
25
   *
26
   * __Note:__ This adds a rendering cycle to your component.
27
   */
28
  waitForDefine?: boolean;
29
}
30

31
const definedWebComponents = new Set<ComponentType>([]);
361✔
32

33
export const withWebComponent = <Props extends Record<string, any>, RefType = Ui5DomRef>(
361✔
34
  tagName: string,
35
  regularProperties: string[],
36
  booleanProperties: string[],
37
  slotProperties: string[],
38
  eventProperties: string[],
39
  loader: () => Promise<unknown>
40
) => {
41
  const reactMajorVersion = SEMVER_REGEX.exec(version)?.groups?.major;
44,403✔
42
  const webComponentsSupported = parseInt(reactMajorVersion) >= 19;
44,403✔
43
  // displayName will be assigned in the individual files
44
  // eslint-disable-next-line react/display-name
45
  return forwardRef<RefType, Props & WithWebComponentPropTypes>((props, wcRef) => {
44,403✔
46
    const { className, children, waitForDefine, ...rest } = props;
479,818✔
47
    const [componentRef, ref] = useSyncRef<RefType>(wcRef);
479,775✔
48
    const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
479,775✔
49
    const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
479,775✔
50
      CommonProps & { class?: string; ref?: Ref<RefType> }
51
    >;
52

53
    const [isDefined, setIsDefined] = useState(definedWebComponents.has(Component));
479,775✔
54

55
    useServerSideEffect(() => {
479,775✔
56
      void loader();
59,112✔
57
    });
58

59
    // regular props (no booleans, no slots and no events)
60
    const regularProps = regularProperties.reduce((acc, name) => {
479,775✔
61
      if (rest.hasOwnProperty(name) && isPrimitiveAttribute(rest[name])) {
1,990,639✔
62
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
325,156✔
63
      }
64
      return acc;
1,665,483✔
65
    }, {});
66

67
    // boolean properties - only attach if they are truthy
68
    const booleanProps = booleanProperties.reduce((acc, name) => {
479,775✔
69
      if (webComponentsSupported) {
973,472!
70
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
973,472✔
71
      } else {
72
        if (rest[name] === true || rest[name] === 'true') {
×
73
          return { ...acc, [camelToKebabCase(name)]: true };
×
74
        }
75
        return acc;
×
76
      }
77
    }, {});
78

79
    const slots = slotProperties.reduce((acc, name) => {
479,775✔
80
      const slotValue = rest[name] as ReactElement;
87,519✔
81

82
      if (!slotValue) {
87,519✔
83
        return acc;
66,302✔
84
      }
85

86
      if (rest[name]?.$$typeof === Symbol.for('react.portal')) {
21,217!
87
        console.warn('ReactPortal is not supported for slot props.');
×
88
        return acc;
×
89
      }
90

91
      const slottedChildren = [];
21,217✔
92
      let index = 0;
21,217✔
93
      const removeFragments = (element: ReactNode) => {
21,217✔
94
        if (!isValidElement(element)) return;
29,131✔
95
        if (element.type === Fragment) {
26,417✔
96
          const elementChildren = (element as ReactElement<{ children?: ReactNode | ReactNode[] }>).props?.children;
4,250✔
97
          if (Array.isArray(elementChildren)) {
4,250✔
98
            elementChildren.forEach((item) => {
4,231✔
99
              if (Array.isArray(item)) {
10,795✔
100
                item.forEach(removeFragments);
5✔
101
              } else {
102
                removeFragments(item);
10,790✔
103
              }
104
            });
105
          } else {
106
            removeFragments(elementChildren);
19✔
107
          }
108
        } else {
109
          slottedChildren.push(
22,167✔
110
            cloneElement<Partial<HTMLElement>>(element, {
111
              key: element.key ?? `${name}-${index}`,
40,018✔
112
              slot: name
113
            })
114
          );
115
          index++;
22,167✔
116
        }
117
      };
118

119
      if (Array.isArray(slotValue)) {
21,217✔
120
        slotValue.forEach((item) => {
7,211✔
121
          removeFragments(item);
4,306✔
122
        });
123
      } else {
124
        removeFragments(slotValue);
14,006✔
125
      }
126
      return [...acc, ...slottedChildren];
21,217✔
127
    }, []);
128

129
    // event binding
130
    useIsomorphicLayoutEffect(() => {
479,775✔
131
      if (webComponentsSupported) {
213,455✔
132
        return () => {
213,455✔
133
          // React can handle events
134
        };
135
      }
136
      const localRef = ref.current;
×
137
      const eventRegistry: Record<string, EventHandler> = {};
×
138
      if (!waitForDefine || isDefined) {
×
139
        eventProperties.forEach((eventName) => {
×
140
          const eventHandler = rest[createEventPropName(eventName)] as EventHandler;
×
141
          if (typeof eventHandler === 'function') {
×
142
            eventRegistry[eventName] = eventHandler;
×
143
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
144
            localRef?.addEventListener(eventName, eventRegistry[eventName]);
×
145
          }
146
        });
147

148
        return () => {
×
149
          for (const eventName in eventRegistry) {
×
150
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
151
            localRef?.removeEventListener(eventName, eventRegistry[eventName]);
×
152
          }
153
        };
154
      }
155
    }, [...eventProperties.map((eventName) => rest[createEventPropName(eventName)]), isDefined, waitForDefine]);
317,409✔
156

157
    const eventHandlers = eventProperties.reduce((events, eventName) => {
479,775✔
158
      const eventHandlerProp = rest[createEventPropName(eventName)];
317,409✔
159
      if (webComponentsSupported && eventHandlerProp) {
317,409✔
160
        events[`on${eventName}`] = eventHandlerProp;
185,882✔
161
      }
162
      return events;
317,409✔
163
    }, {});
164

165
    // non web component related props, just pass them
166
    const nonWebComponentRelatedProps = Object.entries(rest)
479,775✔
167
      .filter(([key]) => !regularProperties.includes(key))
2,090,595✔
168
      .filter(([key]) => !slotProperties.includes(key))
1,729,643✔
169
      .filter(([key]) => !booleanProperties.includes(key))
1,706,266✔
170
      .filter(([key]) => !eventProperties.map((eventName) => createEventPropName(eventName)).includes(key))
1,439,955✔
171
      .reduce((acc, [key, val]) => {
172
        if (!key.startsWith('aria-') && !key.startsWith('data-') && val === false) {
1,243,320✔
173
          return acc;
90✔
174
        }
175
        acc[key] = val;
1,243,230✔
176
        return acc;
1,243,230✔
177
      }, {});
178

179
    useEffect(() => {
479,775✔
180
      if (waitForDefine && !isDefined) {
59,112!
181
        customElements.whenDefined(Component as unknown as string).then(() => {
×
182
          setIsDefined(true);
×
183
          definedWebComponents.add(Component);
×
184
        });
185
      }
186
    }, [Component, waitForDefine, isDefined]);
187

188
    const propsToApply = regularProperties.map((prop) => ({ name: prop, value: props[prop] }));
1,990,639✔
189
    useEffect(() => {
479,775✔
190
      void customElements.whenDefined(Component as unknown as string).then(() => {
465,590✔
191
        for (const prop of propsToApply) {
465,587✔
192
          if (prop.value != null && !isPrimitiveAttribute(prop.value)) {
1,990,606✔
193
            if (ref.current) {
83✔
194
              ref.current[prop.name] = prop.value;
83✔
195
            }
196
          }
197
        }
198
      });
199
    }, [Component, ...propsToApply]);
200

201
    if (waitForDefine && !isDefined) {
479,775!
202
      return null;
×
203
    }
204

205
    return (
479,775✔
206
      <Component
207
        ref={componentRef}
208
        {...booleanProps}
209
        {...regularProps}
210
        {...eventHandlers}
211
        {...nonWebComponentRelatedProps}
212
        class={className}
213
        suppressHydrationWarning
214
      >
215
        {slots}
216
        {children}
217
      </Component>
218
    );
219
  });
220
};
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc