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

SAP / ui5-webcomponents-react / 10004391615

19 Jul 2024 07:35AM CUT coverage: 79.91% (+0.2%) from 79.67%
10004391615

Pull #6089

github

web-flow
Merge 7cb3d1a69 into 44c90daf1
Pull Request #6089: feat(ObjectPage): refactor component to support ui5wc v2

2522 of 4036 branches covered (62.49%)

42 of 45 new or added lines in 5 files covered. (93.33%)

13 existing lines in 4 files now uncovered.

4598 of 5754 relevant lines covered (79.91%)

74465.57 hits per line

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

69.11
/packages/main/src/components/Toolbar/index.tsx
1
'use client';
2

3
import type PopupAccessibleRole from '@ui5/webcomponents/dist/types/PopupAccessibleRole.js';
4
import {
5
  debounce,
6
  useI18nBundle,
7
  useIsomorphicLayoutEffect,
8
  useStylesheet,
9
  useSyncRef
10
} from '@ui5/webcomponents-react-base';
11
import { clsx } from 'clsx';
12
import type { ElementType, HTMLAttributes, ReactElement, ReactNode, Ref, RefObject } from 'react';
13
import {
14
  Children,
15
  cloneElement,
16
  createRef,
17
  forwardRef,
18
  useCallback,
19
  useEffect,
20
  useMemo,
21
  useRef,
22
  useState
23
} from 'react';
24
import { SHOW_MORE } from '../../i18n/i18n-defaults.js';
25
import { flattenFragments } from '../../internal/utils.js';
26
import type { CommonProps } from '../../types/index.js';
27
import type { ButtonPropTypes, PopoverDomRef, ToggleButtonPropTypes } from '../../webComponents/index.js';
28
import { OverflowPopover } from './OverflowPopover.js';
29
import { classNames, styleData } from './Toolbar.module.css.js';
30

31
export interface ToolbarPropTypes extends Omit<CommonProps, 'onClick' | 'children'> {
32
  /**
33
   * Defines the content of the `Toolbar`.
34
   *
35
   * __Note:__ Although this prop accepts all `ReactNode` types, it is strongly recommended to not pass `string`, `number` or a React Portal to it.
36
   *
37
   * __Note:__ Only components displayed inside the Toolbar are supported as children, i.e. elements positioned outside the normal flow of the document (like dialogs or popovers), can cause undesired behavior.
38
   */
39
  children?: ReactNode | ReactNode[];
40
  /**
41
   * Defines the button shown when the `Toolbar` goes into overflow.
42
   *
43
   * __Note:__ It is strongly recommended that you only use `ToggleButton` in icon only mode in order to preserve the intended design.
44
   *
45
   * __Note:__ Per default a `ToggleButton` with the `"overflow"` icon and all necessary a11y attributes will be rendered.
46
   */
47
  overflowButton?: ReactElement<ToggleButtonPropTypes> | ReactElement<ButtonPropTypes>;
48
  /**
49
   * Defines the visual style of the `Toolbar`.
50
   *
51
   * __Note:__ The visual styles are theme-dependent.
52
   */
53
  toolbarStyle?: 'Clear' | 'Standard';
54
  /**
55
   * Defines the `Toolbar` design.<br />
56
   * <b>Note:</b> Design settings are theme-dependent.
57
   */
58
  design?: 'Auto' | 'Info' | 'Solid' | 'Transparent';
59
  /**
60
   * Indicates that the whole `Toolbar` is clickable. The Press event is fired only if `active` is set to true.
61
   */
62
  active?: boolean;
63
  /**
64
   * Sets the components outer HTML tag.
65
   *
66
   * __Note:__ For TypeScript the types of `ref` are bound to the default tag name, if you change it you are responsible to set the respective types yourself.
67
   */
68
  as?: keyof HTMLElementTagNameMap;
69
  /**
70
   * Defines where modals are rendered into via `React.createPortal`.
71
   *
72
   * You can find out more about this [here](https://sap.github.io/ui5-webcomponents-react/v2/?path=/docs/knowledge-base-working-with-portals--page).
73
   *
74
   * Defaults to: `document.body`
75
   */
76
  portalContainer?: Element;
77
  /**
78
   * Defines the number of items inside the toolbar which should always be visible.
79
   * _E.g.: `numberOfAlwaysVisibleItems={3}` would always show the first three items, no matter the size of the toolbar._
80
   *
81
   * __Note__: To preserve the intended design, it's not recommended to overwrite the `min-width` when using this prop.
82
   */
83
  numberOfAlwaysVisibleItems?: number;
84
  /**
85
   * Exposes the React Ref of the overflow popover.
86
   * This can be useful, for example, when wanting to close the popover on click or selection of a child element.
87
   */
88
  overflowPopoverRef?: Ref<PopoverDomRef>;
89
  /**
90
   * Defines internally used a11y properties.
91
   *
92
   * __Note:__ When setting `contentRole` of the `overflowPopover`, the `role` is set to `"None"`.
93
   */
94
  a11yConfig?: {
95
    overflowPopover?: {
96
      /**
97
       * Defines the `accessibleRole` of the overflow `Popover`.
98
       */
99
      role?: PopupAccessibleRole | keyof typeof PopupAccessibleRole;
100
      /**
101
       * Defines the `role` of the content div inside the overflow `Popover`.
102
       *
103
       * __Note:__ When setting `contentRole`, the `role` is set to `"None"`.
104
       */
105
      contentRole?: HTMLAttributes<HTMLDivElement>['role'];
106
    };
107
  };
108
  /**
109
   * Fired if the `active` prop is set to true and the user clicks or presses Enter/Space on the `Toolbar`.
110
   */
111
  onClick?: (event: CustomEvent) => void;
112
  /**
113
   * Fired when the content of the overflow popover has changed.
114
   */
115
  onOverflowChange?: (event: {
116
    toolbarElements: HTMLElement[];
117
    overflowElements: HTMLCollection | undefined;
118
    target: HTMLElement;
119
  }) => void;
120
}
121

122
function getSpacerWidths(ref) {
123
  if (!ref) {
×
124
    return 0;
×
125
  }
126

127
  let spacerWidths = 0;
×
128
  if (ref.dataset.componentName === 'ToolbarSpacer') {
×
129
    spacerWidths += ref.offsetWidth;
×
130
  }
131
  return spacerWidths + getSpacerWidths(ref.previousElementSibling);
×
132
}
133

134
const OVERFLOW_BUTTON_WIDTH = 36 + 8 + 8; // width + padding end + spacing start
452✔
135

136
/**
137
 *
138
 * __Note:__ The `Toolbar` component may be replaced by the `ui5-toolbar` web-component (currently available as `ToolbarV2`) with our next major release. If you only need to pass components supported by `ToolbarV2` then please consider using `ToolbarV2` instead of this component.
139
 *
140
 * ___
141
 *
142
 * Horizontal container most commonly used to display buttons, labels, selects and various other input controls.
143
 *
144
 * The content of the `Toolbar` moves into the overflow area from right to left when the available space is not enough in the visible area of the container.
145
 * It can be accessed by the user through the overflow button that opens it in a popover.
146
 *
147
 * __Note:__ The overflow popover is mounted only when the overflow button is displayed, i.e., any child component of the popover will be remounted, when moved into it.
148
 *
149
 * __Note:__ To prevent duplicate child `id`s in the DOM, all child `id`s get an `-overflow` suffix. This is especially important when popovers are opened by id.
150
 */
151
const Toolbar = forwardRef<HTMLDivElement, ToolbarPropTypes>((props, ref) => {
452✔
152
  const {
153
    children,
154
    toolbarStyle = 'Standard',
156✔
155
    design = 'Auto',
×
156
    active = false,
156✔
157
    style,
158
    className,
159
    onClick,
160
    slot,
161
    as = 'div',
156✔
162
    portalContainer,
163
    numberOfAlwaysVisibleItems = 0,
156✔
164
    onOverflowChange,
165
    overflowPopoverRef,
166
    overflowButton,
167
    a11yConfig,
168
    ...rest
169
  } = props;
156✔
170

171
  useStylesheet(styleData, Toolbar.displayName);
156✔
172
  const [componentRef, outerContainer] = useSyncRef<HTMLDivElement>(ref);
156✔
173
  const controlMetaData = useRef([]);
156✔
174
  const [lastVisibleIndex, setLastVisibleIndex] = useState<number>(null);
156✔
175
  const [isPopoverMounted, setIsPopoverMounted] = useState(false);
156✔
176
  const contentRef = useRef(null);
156✔
177
  const overflowContentRef = useRef(null);
156✔
178
  const overflowBtnRef = useRef(null);
156✔
179
  const [minWidth, setMinWidth] = useState('0');
156✔
180

181
  const i18nBundle = useI18nBundle('@ui5/webcomponents-react');
156✔
182
  const showMoreText = i18nBundle.getText(SHOW_MORE);
156✔
183

184
  const toolbarClasses = clsx(
156✔
185
    classNames.outerContainer,
186
    toolbarStyle === 'Clear' && classNames.clear,
156!
187
    active && classNames.active,
156!
188
    design === 'Solid' && classNames.solid,
156!
189
    design === 'Transparent' && classNames.transparent,
156!
190
    design === 'Info' && classNames.info,
312✔
191
    className
192
  );
193
  const flatChildren = useMemo(() => {
156✔
194
    return flattenFragments(children, 10);
78✔
195
  }, [children]);
196

197
  const childrenWithRef = useMemo(() => {
156✔
198
    controlMetaData.current = [];
78✔
199

200
    return flatChildren.map((item, index) => {
78✔
201
      const itemRef: RefObject<HTMLDivElement> = createRef();
78✔
202
      // @ts-expect-error: if type is not defined, it's not a spacer
203
      const isSpacer = item?.type?.displayName === 'ToolbarSpacer';
78✔
204
      controlMetaData.current.push({
78✔
205
        ref: itemRef,
206
        isSpacer
207
      });
208
      if (isSpacer) {
78!
UNCOV
209
        return item;
×
210
      }
211
      return (
78✔
212
        <div
213
          ref={itemRef}
214
          key={index}
215
          className={classNames.childContainer}
216
          data-component-name="ToolbarChildContainer"
217
        >
218
          {item}
219
        </div>
220
      );
221
    });
222
  }, [flatChildren, controlMetaData, classNames.childContainer]);
223

224
  const overflowNeeded =
156✔
225
    (lastVisibleIndex || lastVisibleIndex === 0) &&
226
    Children.count(childrenWithRef) !== lastVisibleIndex + 1 &&
227
    numberOfAlwaysVisibleItems < Children.count(flatChildren);
228

229
  useEffect(() => {
156✔
230
    let lastElementResizeObserver;
231
    const lastElement = contentRef.current.children[numberOfAlwaysVisibleItems - 1];
32✔
232
    const debouncedObserverFn = debounce(() => {
32✔
233
      const spacerWidth = getSpacerWidths(lastElement);
×
234
      const isRtl = outerContainer.current?.matches(':dir(rtl)');
×
235
      if (isRtl) {
×
236
        setMinWidth(
×
237
          `${lastElement.offsetParent.offsetWidth - lastElement.offsetLeft + OVERFLOW_BUTTON_WIDTH - spacerWidth}px`
238
        );
239
      } else {
240
        setMinWidth(
×
241
          `${
242
            lastElement.offsetLeft + lastElement.getBoundingClientRect().width + OVERFLOW_BUTTON_WIDTH - spacerWidth
243
          }px`
244
        );
245
      }
246
    }, 200);
247
    if (numberOfAlwaysVisibleItems && overflowNeeded && lastElement) {
32!
248
      lastElementResizeObserver = new ResizeObserver(debouncedObserverFn);
×
249
      lastElementResizeObserver.observe(contentRef.current);
×
250
    }
251
    return () => {
32✔
252
      debouncedObserverFn.cancel();
30✔
253
      lastElementResizeObserver?.disconnect();
30✔
254
    };
255
  }, [numberOfAlwaysVisibleItems, overflowNeeded]);
256

257
  const requestAnimationFrameRef = useRef<undefined | number>(undefined);
156✔
258
  const calculateVisibleItems = useCallback(() => {
156✔
259
    requestAnimationFrameRef.current = requestAnimationFrame(() => {
156✔
260
      if (!outerContainer.current) return;
135✔
261
      const availableWidth = outerContainer.current.getBoundingClientRect().width;
128✔
262
      let consumedWidth = 0;
128✔
263
      let lastIndex = null;
128✔
264
      if (availableWidth - OVERFLOW_BUTTON_WIDTH <= 0) {
128✔
265
        lastIndex = -1;
56✔
266
      } else {
267
        let prevItemsAreSpacer = true;
72✔
268
        controlMetaData.current.forEach((item, index) => {
72✔
269
          const currentMeta = controlMetaData.current[index] as { ref: RefObject<HTMLElement> };
72✔
270
          if (currentMeta && currentMeta.ref && currentMeta.ref.current) {
72✔
271
            let nextWidth = currentMeta.ref.current.getBoundingClientRect().width;
72✔
272
            nextWidth += index === 0 || index === controlMetaData.current.length - 1 ? 4 : 8; // first & last element = padding: 4px
72!
273
            if (index === controlMetaData.current.length - 1) {
72!
274
              if (consumedWidth + nextWidth <= availableWidth - 8) {
72!
275
                lastIndex = index;
72✔
UNCOV
276
              } else if (index === 0 || prevItemsAreSpacer) {
×
277
                lastIndex = index - 1;
×
278
              }
279
            } else {
UNCOV
280
              if (consumedWidth + nextWidth <= availableWidth - OVERFLOW_BUTTON_WIDTH) {
×
UNCOV
281
                lastIndex = index;
×
282
              }
UNCOV
283
              if (
×
284
                consumedWidth < availableWidth - OVERFLOW_BUTTON_WIDTH &&
×
285
                consumedWidth + nextWidth >= availableWidth - OVERFLOW_BUTTON_WIDTH
286
              ) {
UNCOV
287
                lastIndex = index - 1;
×
288
              }
289
            }
290
            if (prevItemsAreSpacer && !item.isSpacer) {
72✔
291
              prevItemsAreSpacer = false;
72✔
292
            }
293
            consumedWidth += nextWidth;
72✔
294
          }
295
        });
296
      }
297
      setLastVisibleIndex(lastIndex);
128✔
298
    });
299
  }, [overflowNeeded]);
300

301
  useEffect(() => {
156✔
302
    const observer = new ResizeObserver(calculateVisibleItems);
32✔
303

304
    if (outerContainer.current) {
32✔
305
      observer.observe(outerContainer.current);
32✔
306
    }
307
    return () => {
32✔
308
      cancelAnimationFrame(requestAnimationFrameRef.current);
30✔
309
      observer.disconnect();
30✔
310
    };
311
  }, [calculateVisibleItems]);
312

313
  useEffect(() => {
156✔
314
    if (Children.count(children) > 0) {
78✔
315
      calculateVisibleItems();
78✔
316
    }
317
  }, [children]);
318

319
  useIsomorphicLayoutEffect(() => {
156✔
320
    calculateVisibleItems();
32✔
321
  }, [calculateVisibleItems]);
322

323
  const handleToolbarClick = (e) => {
156✔
324
    if (active && typeof onClick === 'function') {
×
325
      const isSpaceEnterDown = e.type === 'keydown' && (e.code === 'Enter' || e.code === 'Space');
×
326
      if (isSpaceEnterDown && e.target !== e.currentTarget) {
×
327
        return;
×
328
      }
329
      if (e.type === 'click' || isSpaceEnterDown) {
×
330
        if (isSpaceEnterDown) {
×
331
          e.preventDefault();
×
332
        }
333
        onClick(e);
×
334
      }
335
    }
336
  };
337

338
  const prevChildren = useRef(flatChildren);
156✔
339
  const debouncedOverflowChange = useRef<ToolbarPropTypes['onOverflowChange'] & { cancel(): void }>(undefined);
156✔
340

341
  useEffect(() => {
156✔
342
    if (typeof onOverflowChange === 'function') {
18!
343
      debouncedOverflowChange.current = debounce(onOverflowChange, 60);
×
344
    }
345
  }, [onOverflowChange]);
346

347
  useEffect(() => {
156✔
348
    const haveChildrenChanged = prevChildren.current.length !== flatChildren.length;
64✔
349
    if ((lastVisibleIndex !== null || haveChildrenChanged) && typeof debouncedOverflowChange.current === 'function') {
64!
350
      prevChildren.current = flatChildren;
×
351
      const toolbarChildren = contentRef.current?.children;
×
352
      let toolbarElements = [];
×
353
      let overflowElements;
354
      if (isPopoverMounted) {
×
355
        overflowElements = overflowContentRef.current?.children;
×
356
      }
357
      if (toolbarChildren?.length > 0) {
×
358
        toolbarElements = Array.from(toolbarChildren).filter((item, index) => index <= lastVisibleIndex);
×
359
      }
360
      debouncedOverflowChange.current({
×
361
        toolbarElements,
362
        overflowElements,
363
        target: outerContainer.current
364
      });
365
    }
366
    return () => {
64✔
367
      if (debouncedOverflowChange.current) {
62!
368
        debouncedOverflowChange.current.cancel();
×
369
      }
370
    };
371
  }, [lastVisibleIndex, flatChildren.length, isPopoverMounted]);
372

373
  const CustomTag = as as ElementType;
156✔
374
  const styleWithMinWidth = minWidth !== '0' ? { minWidth, ...style } : style;
156!
375
  return (
156✔
376
    <CustomTag
377
      style={styleWithMinWidth}
378
      className={clsx(toolbarClasses, overflowNeeded && classNames.hasOverflow)}
212✔
379
      ref={componentRef}
380
      slot={slot}
381
      onClick={handleToolbarClick}
382
      onKeyDown={handleToolbarClick}
383
      tabIndex={active ? 0 : undefined}
156!
384
      role={active ? 'button' : undefined}
156!
385
      data-sap-ui-fastnavgroup="true"
386
      {...rest}
387
    >
388
      <div className={classNames.toolbar} data-component-name="ToolbarContent" ref={contentRef}>
389
        {overflowNeeded &&
×
390
          Children.map(childrenWithRef, (item, index) => {
212✔
391
            if (index >= lastVisibleIndex + 1 && index > numberOfAlwaysVisibleItems - 1) {
×
392
              return cloneElement(item as ReactElement<CommonProps>, {
56✔
393
                style: { visibility: 'hidden', position: 'absolute', pointerEvents: 'none' }
56✔
394
              });
395
            }
396
            return item;
397
          })}
398
        {!overflowNeeded && childrenWithRef}
×
399
      </div>
256✔
400
      {overflowNeeded && (
×
401
        <div
212✔
402
          ref={overflowBtnRef}
403
          className={classNames.overflowButtonContainer}
404
          data-component-name="ToolbarOverflowButtonContainer"
405
        >
406
          <OverflowPopover
407
            overflowPopoverRef={overflowPopoverRef}
408
            lastVisibleIndex={lastVisibleIndex}
409
            classes={classNames}
410
            portalContainer={portalContainer}
411
            overflowContentRef={overflowContentRef}
412
            numberOfAlwaysVisibleItems={numberOfAlwaysVisibleItems}
413
            showMoreText={showMoreText}
414
            overflowButton={overflowButton}
415
            setIsMounted={setIsPopoverMounted}
416
            a11yConfig={a11yConfig}
417
          >
418
            {flatChildren}
419
          </OverflowPopover>
420
        </div>
421
      )}
422
    </CustomTag>
423
  );
424
});
425

426
Toolbar.displayName = 'Toolbar';
91✔
427
export { Toolbar };
361✔
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