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

SAP / ui5-webcomponents-react / 13287547337

12 Feb 2025 02:23PM CUT coverage: 86.925%. First build
13287547337

Pull #6929

github

web-flow
Merge 67c43dea0 into 65b203ac2
Pull Request #6929: chore(coverage): ignore `dist` folders

2254 of 2778 branches covered (81.14%)

3836 of 4413 relevant lines covered (86.92%)

57673.51 hits per line

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

73.0
/packages/base/src/wrapper/withWebComponent.tsx
1
'use client';
2

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

11
const createEventPropName = (eventName: string) => `on${capitalizeFirstLetter(kebabToCamelCase(eventName))}`;
2,339,341✔
12

13
const isPrimitiveAttribute = (value: unknown): boolean => {
245✔
14
  return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
959,903✔
15
};
16

17
type EventHandler = (event: CustomEvent<unknown>) => void;
18

19
export interface WithWebComponentPropTypes {
20
  /**
21
   * 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.
22
   *
23
   * __Note:__ This adds a rendering cycle to your component.
24
   */
25
  waitForDefine?: boolean;
26
}
27

28
const definedWebComponents = new Set<ComponentType>([]);
245✔
29

30
export const withWebComponent = <Props extends Record<string, any>, RefType = Ui5DomRef>(
245✔
31
  tagName: string,
32
  regularProperties: string[],
33
  booleanProperties: string[],
34
  slotProperties: string[],
35
  eventProperties: string[]
36
) => {
37
  const webComponentsSupported = parseSemVer(version).major >= 19;
32,585✔
38
  // displayName will be assigned in the individual files
39
  // eslint-disable-next-line react/display-name
40
  return forwardRef<RefType, Props & WithWebComponentPropTypes>((props, wcRef) => {
32,585✔
41
    const { className, children, waitForDefine, ...rest } = props;
627,437✔
42
    const [componentRef, ref] = useSyncRef<RefType>(wcRef);
627,437✔
43
    const tagNameSuffix: string = getEffectiveScopingSuffixForTag(tagName);
627,437✔
44
    const Component = (tagNameSuffix ? `${tagName}-${tagNameSuffix}` : tagName) as unknown as ComponentType<
627,437!
45
      CommonProps & { class?: string; ref?: Ref<RefType> }
46
    >;
47
    const [isDefined, setIsDefined] = useState(definedWebComponents.has(Component));
627,437✔
48
    // regular props (no booleans, no slots and no events)
49
    const regularProps = regularProperties.reduce((acc, name) => {
627,437✔
50
      if (Object.prototype.hasOwnProperty.call(rest, name) && isPrimitiveAttribute(rest[name])) {
2,977,626✔
51
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
478,233✔
52
      }
53
      return acc;
2,499,393✔
54
    }, {});
55

56
    // boolean properties - only attach if they are truthy
57
    const booleanProps = booleanProperties.reduce((acc, name) => {
627,437✔
58
      if (webComponentsSupported) {
1,262,404!
59
        return { ...acc, [camelToKebabCase(name)]: rest[name] };
1,262,404✔
60
      } else {
61
        if (rest[name] === true || rest[name] === 'true') {
×
62
          return { ...acc, [camelToKebabCase(name)]: true };
×
63
        }
64
        return acc;
×
65
      }
66
    }, {});
67

68
    const slots = slotProperties.reduce((acc, name) => {
627,437✔
69
      const slotValue = rest[name] as ReactElement;
262,385✔
70

71
      if (!slotValue) {
262,385✔
72
        return acc;
206,213✔
73
      }
74

75
      if (rest[name]?.$$typeof === Symbol.for('react.portal')) {
56,172!
76
        console.warn('ReactPortal is not supported for slot props.');
×
77
        return acc;
×
78
      }
79

80
      const slottedChildren = [];
56,172✔
81
      let index = 0;
56,172✔
82
      const removeFragments = (element: ReactNode) => {
56,172✔
83
        if (!isValidElement(element)) return;
58,409✔
84
        if (element.type === Fragment) {
55,366✔
85
          const elementChildren = (element as ReactElement<{ children?: ReactNode | ReactNode[] }>).props?.children;
6,835✔
86
          if (Array.isArray(elementChildren)) {
6,835✔
87
            elementChildren.forEach((item) => {
4,932✔
88
              if (Array.isArray(item)) {
12,624!
89
                item.forEach(removeFragments);
×
90
              } else {
91
                removeFragments(item);
12,624✔
92
              }
93
            });
94
          } else {
95
            removeFragments(elementChildren);
1,903✔
96
          }
97
        } else {
98
          slottedChildren.push(
48,531✔
99
            cloneElement<Partial<HTMLElement>>(element, {
100
              key: element.key ?? `${name}-${index}`,
84,689✔
101
              slot: name
102
            })
103
          );
104
          index++;
48,531✔
105
        }
106
      };
107

108
      if (Array.isArray(slotValue)) {
56,172✔
109
        slotValue.forEach((item) => {
24,663✔
110
          removeFragments(item);
12,373✔
111
        });
112
      } else {
113
        removeFragments(slotValue);
31,509✔
114
      }
115
      return [...acc, ...slottedChildren];
56,172✔
116
    }, []);
117

118
    // event binding
119
    useIsomorphicLayoutEffect(() => {
627,437✔
120
      if (webComponentsSupported) {
269,103✔
121
        return () => {
269,103✔
122
          // React can handle events
123
        };
124
      }
125
      const localRef = ref.current;
×
126
      const eventRegistry: Record<string, EventHandler> = {};
×
127
      if (!waitForDefine || isDefined) {
×
128
        eventProperties.forEach((eventName) => {
×
129
          const eventHandler = rest[createEventPropName(eventName)] as EventHandler;
×
130
          if (typeof eventHandler === 'function') {
×
131
            eventRegistry[eventName] = eventHandler;
×
132
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
133
            localRef?.addEventListener(eventName, eventRegistry[eventName]);
×
134
          }
135
        });
136

137
        return () => {
×
138
          for (const eventName in eventRegistry) {
×
139
            // @ts-expect-error: all custom events can be passed here, so `keyof HTMLElementEventMap` isn't sufficient
140
            localRef?.removeEventListener(eventName, eventRegistry[eventName]);
×
141
          }
142
        };
143
      }
144
    }, [...eventProperties.map((eventName) => rest[createEventPropName(eventName)]), isDefined, waitForDefine]);
444,266✔
145

146
    const eventHandlers = eventProperties.reduce((events, eventName) => {
627,437✔
147
      const eventHandlerProp = rest[createEventPropName(eventName)];
444,266✔
148
      if (webComponentsSupported && eventHandlerProp) {
444,266✔
149
        events[`on${eventName}`] = eventHandlerProp;
243,595✔
150
      }
151
      return events;
444,266✔
152
    }, {});
153

154
    // In React 19 events aren't correctly attached after hydration
155
    const [attachEvents, setAttachEvents] = useState(!webComponentsSupported || !Object.keys(eventHandlers).length); // apply workaround only for React19 and if event props are defined
627,437✔
156

157
    // non web component related props, just pass them
158
    const nonWebComponentRelatedProps = Object.entries(rest)
627,437✔
159
      .filter(([key]) => !regularProperties.includes(key))
2,726,792✔
160
      .filter(([key]) => !slotProperties.includes(key))
2,244,401✔
161
      .filter(([key]) => !booleanProperties.includes(key))
2,183,286✔
162
      .filter(([key]) => !eventProperties.map((eventName) => createEventPropName(eventName)).includes(key))
1,845,438✔
163
      .reduce((acc, [key, val]) => {
164
        if (!key.startsWith('aria-') && !key.startsWith('data-') && val === false) {
1,589,419✔
165
          return acc;
718✔
166
        }
167
        acc[key] = val;
1,588,701✔
168
        return acc;
1,588,701✔
169
      }, {});
170

171
    useEffect(() => {
627,437✔
172
      if (waitForDefine && !isDefined) {
76,722!
173
        void customElements.whenDefined(Component as unknown as string).then(() => {
×
174
          setIsDefined(true);
×
175
          definedWebComponents.add(Component);
×
176
        });
177
      }
178
    }, [Component, waitForDefine, isDefined]);
179

180
    const propsToApply = regularProperties.map((prop) => ({ name: prop, value: props[prop] }));
2,977,626✔
181
    useEffect(() => {
627,437✔
182
      void customElements.whenDefined(Component as unknown as string).then(() => {
623,949✔
183
        for (const prop of propsToApply) {
623,949✔
184
          if (prop.value != null && !isPrimitiveAttribute(prop.value)) {
2,973,998!
185
            if (ref.current) {
×
186
              ref.current[prop.name] = prop.value;
×
187
            }
188
          }
189
        }
190
      });
191
    }, [Component, ...propsToApply]);
192

193
    useEffect(() => {
627,437✔
194
      setAttachEvents(true);
76,722✔
195
    }, []);
196

197
    if (waitForDefine && !isDefined) {
627,437!
198
      return null;
×
199
    }
200

201
    // compatibility wrapper for ExpandableText - remove in v3
202
    if (tagName === 'ui5-expandable-text') {
627,437!
203
      const renderWhiteSpace = nonWebComponentRelatedProps['renderWhitespace'] ? true : undefined;
×
204
      // @ts-expect-error: overflowMode is available
205
      const { ['overflow-mode']: overflowMode, text, ...restRegularProps } = regularProps;
×
206
      const showOverflowInPopover = nonWebComponentRelatedProps['showOverflowInPopover'];
×
207
      return (
×
208
        <Component
209
          ref={componentRef}
210
          {...booleanProps}
211
          {...restRegularProps}
212
          {...(attachEvents ? eventHandlers : {})}
×
213
          {...nonWebComponentRelatedProps}
214
          overflow-mode={overflowMode ?? (showOverflowInPopover ? 'Popover' : 'InPlace')}
×
215
          // @ts-expect-error: text is available
216
          text={text ?? children}
×
217
          class={className}
218
          suppressHydrationWarning
219
          data-_render-whitespace={renderWhiteSpace}
220
        >
221
          {slots}
222
        </Component>
223
      );
224
    }
225

226
    return (
627,437✔
227
      <Component
228
        ref={componentRef}
229
        {...booleanProps}
230
        {...regularProps}
231
        {...(attachEvents ? eventHandlers : {})}
627,437✔
232
        {...nonWebComponentRelatedProps}
233
        class={className}
234
        suppressHydrationWarning
235
      >
236
        {slots}
237
        {children}
238
      </Component>
239
    );
240
  });
241
};
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