@lumx/react 3.0.1 → 3.0.2

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 (66) hide show
  1. package/esm/_internal/ClickAwayProvider.js +9 -5
  2. package/esm/_internal/ClickAwayProvider.js.map +1 -1
  3. package/esm/_internal/FlexBox.js.map +1 -1
  4. package/esm/_internal/HeadingLevelProvider.js +112 -0
  5. package/esm/_internal/HeadingLevelProvider.js.map +1 -0
  6. package/esm/_internal/ProgressTrackerStepPanel.js +2 -1
  7. package/esm/_internal/ProgressTrackerStepPanel.js.map +1 -1
  8. package/esm/_internal/Slides.js +270 -79
  9. package/esm/_internal/Slides.js.map +1 -1
  10. package/esm/_internal/TabPanel.js +2 -1
  11. package/esm/_internal/TabPanel.js.map +1 -1
  12. package/esm/_internal/Text2.js +63 -0
  13. package/esm/_internal/Text2.js.map +1 -0
  14. package/esm/_internal/_rollupPluginBabelHelpers.js +17 -1
  15. package/esm/_internal/_rollupPluginBabelHelpers.js.map +1 -1
  16. package/esm/_internal/components.js +1 -0
  17. package/esm/_internal/components.js.map +1 -1
  18. package/esm/_internal/heading.js +11 -0
  19. package/esm/_internal/heading.js.map +1 -0
  20. package/esm/_internal/progress-tracker.js +2 -1
  21. package/esm/_internal/progress-tracker.js.map +1 -1
  22. package/esm/_internal/slideshow.js +2 -0
  23. package/esm/_internal/slideshow.js.map +1 -1
  24. package/esm/_internal/state.js +145 -0
  25. package/esm/_internal/state.js.map +1 -0
  26. package/esm/_internal/tabs.js +1 -0
  27. package/esm/_internal/tabs.js.map +1 -1
  28. package/esm/_internal/text.js +10 -0
  29. package/esm/_internal/text.js.map +1 -0
  30. package/esm/_internal/useRovingTabIndex.js +9 -144
  31. package/esm/_internal/useRovingTabIndex.js.map +1 -1
  32. package/esm/index.js +5 -1
  33. package/esm/index.js.map +1 -1
  34. package/package.json +4 -5
  35. package/src/components/flex-box/FlexBox.stories.tsx +60 -4
  36. package/src/components/flex-box/FlexBox.tsx +7 -4
  37. package/src/components/flex-box/__snapshots__/FlexBox.test.tsx.snap +35 -0
  38. package/src/components/heading/Heading.stories.tsx +108 -0
  39. package/src/components/heading/Heading.test.tsx +77 -0
  40. package/src/components/heading/Heading.tsx +62 -0
  41. package/src/components/heading/HeadingLevelProvider.tsx +30 -0
  42. package/src/components/heading/constants.ts +16 -0
  43. package/src/components/heading/context.tsx +13 -0
  44. package/src/components/heading/index.ts +3 -0
  45. package/src/components/heading/useHeadingLevel.tsx +8 -0
  46. package/src/components/index.ts +1 -0
  47. package/src/components/slideshow/Slides.tsx +33 -3
  48. package/src/components/slideshow/Slideshow.stories.tsx +98 -2
  49. package/src/components/slideshow/Slideshow.tsx +15 -3
  50. package/src/components/slideshow/SlideshowControls.stories.tsx +1 -1
  51. package/src/components/slideshow/SlideshowControls.tsx +49 -11
  52. package/src/components/slideshow/SlideshowItem.tsx +0 -5
  53. package/src/components/slideshow/SlideshowItemGroup.tsx +63 -0
  54. package/src/components/slideshow/__snapshots__/Slideshow.test.tsx.snap +4 -1
  55. package/src/components/slideshow/useSlideFocusManagement.tsx +92 -0
  56. package/src/components/text/Text.stories.tsx +80 -0
  57. package/src/components/text/Text.test.tsx +62 -0
  58. package/src/components/text/Text.tsx +94 -0
  59. package/src/components/text/index.ts +1 -0
  60. package/src/hooks/useRovingTabIndex.tsx +9 -0
  61. package/src/index.ts +2 -0
  62. package/src/utils/focus/constants.ts +5 -0
  63. package/src/utils/focus/getFirstAndLastFocusable.ts +4 -10
  64. package/src/utils/focus/getFocusableElements.test.ts +151 -0
  65. package/src/utils/focus/getFocusableElements.ts +7 -0
  66. package/types.d.ts +94 -7
@@ -8,9 +8,9 @@ import { Emphasis, IconButton, IconButtonProps, Theme } from '@lumx/react';
8
8
  import { Comp, GenericProps, getRootClassName, handleBasicClasses, HasTheme } from '@lumx/react/utils';
9
9
  import { WINDOW } from '@lumx/react/constants';
10
10
  import { useSlideshowControls, DEFAULT_OPTIONS } from '@lumx/react/hooks/useSlideshowControls';
11
+ import { useRovingTabIndex } from '@lumx/react/hooks/useRovingTabIndex';
11
12
 
12
13
  import { useSwipeNavigate } from './useSwipeNavigate';
13
- import { useKeyNavigate } from './useKeyNavigate';
14
14
  import { PAGINATION_ITEM_SIZE, PAGINATION_ITEMS_MAX } from './constants';
15
15
  import { usePaginationVisibleRange } from './usePaginationVisibleRange';
16
16
 
@@ -28,6 +28,8 @@ export interface SlideshowControlsProps extends GenericProps, HasTheme {
28
28
  /** Props to pass to the previous button (minus those already set by the SlideshowControls props). */
29
29
  previousButtonProps: Pick<IconButtonProps, 'label'> &
30
30
  Omit<IconButtonProps, 'label' | 'onClick' | 'icon' | 'emphasis' | 'color'>;
31
+ /** Props to pass to the pagination wrapper */
32
+ paginationProps?: Omit<React.HTMLAttributes<HTMLDivElement>, 'className' | 'style' | 'role'>;
31
33
  /** Number of slides. */
32
34
  slidesCount: number;
33
35
  /** On next button click callback. */
@@ -38,8 +40,15 @@ export interface SlideshowControlsProps extends GenericProps, HasTheme {
38
40
  onPreviousClick?(loopback?: boolean): void;
39
41
  /** whether the slideshow is currently playing */
40
42
  isAutoPlaying?: boolean;
41
- /** function to be executed in order to retrieve the label for the pagination item */
43
+ /**
44
+ * function to be executed in order to retrieve the label for the pagination item
45
+ * @deprecated Use paginationItemProps instead.
46
+ * */
42
47
  paginationItemLabel?: (index: number) => string;
48
+ /**
49
+ * function to be executed in order to retrieve the props for a pagination item.
50
+ */
51
+ paginationItemProps?: (itemIndex: number) => React.HTMLAttributes<HTMLButtonElement> & { label?: string };
43
52
  /** Props to pass to the lay button (minus those already set by the SlideshowControls props). */
44
53
  playButtonProps?: Pick<IconButtonProps, 'label'> &
45
54
  Omit<IconButtonProps, 'label' | 'onClick' | 'icon' | 'emphasis' | 'color'>;
@@ -80,11 +89,13 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
80
89
  onPreviousClick,
81
90
  parentRef,
82
91
  previousButtonProps,
92
+ paginationProps,
83
93
  slidesCount,
84
94
  theme,
85
95
  isAutoPlaying = false,
86
96
  playButtonProps,
87
97
  paginationItemLabel,
98
+ paginationItemProps,
88
99
  ...forwardedProps
89
100
  } = props;
90
101
 
@@ -93,8 +104,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
93
104
  // Checking window object to avoid errors in SSR.
94
105
  parent = parentRef instanceof HTMLElement ? parentRef : parentRef?.current;
95
106
  }
96
- // Listen to keyboard navigate left & right.
97
- useKeyNavigate(parent, onNextClick, onPreviousClick);
107
+ const paginationRef = React.useRef(null);
98
108
  // Listen to touch swipe navigate left & right.
99
109
  useSwipeNavigate(
100
110
  parent,
@@ -104,6 +114,18 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
104
114
  useCallback(() => onPreviousClick?.(false), [onPreviousClick]),
105
115
  );
106
116
 
117
+ /**
118
+ * Add roving tab index pattern to pagination items and activate slide on focus.
119
+ */
120
+ useRovingTabIndex({
121
+ parentRef: paginationRef,
122
+ elementSelector: 'button',
123
+ keepTabIndex: true,
124
+ onElementFocus: (element) => {
125
+ element.click();
126
+ },
127
+ });
128
+
107
129
  // Pagination "bullet" range.
108
130
  const visibleRange = usePaginationVisibleRange(activeIndex as number, slidesCount);
109
131
 
@@ -126,8 +148,13 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
126
148
  emphasis={Emphasis.low}
127
149
  onClick={onPreviousClick}
128
150
  />
129
- <div className={`${CLASSNAME}__pagination`}>
130
- <div className={`${CLASSNAME}__pagination-items`} style={wrapperStyle}>
151
+ <div ref={paginationRef} className={`${CLASSNAME}__pagination`}>
152
+ <div
153
+ className={`${CLASSNAME}__pagination-items`}
154
+ style={wrapperStyle}
155
+ role="tablist"
156
+ {...paginationProps}
157
+ >
131
158
  {useMemo(
132
159
  () =>
133
160
  range(slidesCount).map((index) => {
@@ -137,6 +164,15 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
137
164
  (index === visibleRange.min || index === visibleRange.max);
138
165
  const isActive = activeIndex === index;
139
166
  const isOutRange = index < visibleRange.min || index > visibleRange.max;
167
+ const {
168
+ className: itemClassName = undefined,
169
+ label = undefined,
170
+ ...itemProps
171
+ } = paginationItemProps ? paginationItemProps(index) : {};
172
+
173
+ const ariaLabel =
174
+ label || paginationItemLabel?.(index) || `${index + 1} / ${slidesCount}`;
175
+
140
176
  return (
141
177
  <button
142
178
  className={classNames(
@@ -146,15 +182,16 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
146
182
  isOnEdge,
147
183
  isOutRange,
148
184
  }),
185
+ itemClassName,
149
186
  )}
150
187
  key={index}
151
188
  type="button"
189
+ tabIndex={isActive ? undefined : -1}
190
+ role="tab"
191
+ aria-selected={isActive}
152
192
  onClick={() => onPaginationClick?.(index)}
153
- {...{
154
- 'aria-label': paginationItemLabel
155
- ? paginationItemLabel(index)
156
- : `${index + 1} / ${slidesCount}`,
157
- }}
193
+ aria-label={ariaLabel}
194
+ {...itemProps}
158
195
  />
159
196
  );
160
197
  }),
@@ -163,6 +200,7 @@ const InternalSlideshowControls: Comp<SlideshowControlsProps, HTMLDivElement> =
163
200
  visibleRange.min,
164
201
  visibleRange.max,
165
202
  activeIndex,
203
+ paginationItemProps,
166
204
  paginationItemLabel,
167
205
  onPaginationClick,
168
206
  ],
@@ -8,8 +8,6 @@ import { Comp, GenericProps, getRootClassName, handleBasicClasses } from '@lumx/
8
8
  * Defines the props of the component.
9
9
  */
10
10
  export interface SlideshowItemProps extends GenericProps {
11
- /** whether the slideshow item is currently visible */
12
- isCurrentlyVisible?: boolean;
13
11
  /** interval in which slides are automatically shown */
14
12
  interval?: number;
15
13
  }
@@ -33,7 +31,6 @@ const CLASSNAME = getRootClassName(COMPONENT_NAME);
33
31
  */
34
32
  export const SlideshowItem: Comp<SlideshowItemProps, HTMLDivElement> = forwardRef((props, ref) => {
35
33
  const { className, children, ...forwardedProps } = props;
36
-
37
34
  return (
38
35
  <div
39
36
  ref={ref}
@@ -43,8 +40,6 @@ export const SlideshowItem: Comp<SlideshowItemProps, HTMLDivElement> = forwardRe
43
40
  prefix: CLASSNAME,
44
41
  }),
45
42
  )}
46
- aria-roledescription="slide"
47
- role="group"
48
43
  {...forwardedProps}
49
44
  >
50
45
  {children}
@@ -0,0 +1,63 @@
1
+ import React, { forwardRef } from 'react';
2
+
3
+ import classNames from 'classnames';
4
+ import { mergeRefs } from '@lumx/react/utils/mergeRefs';
5
+
6
+ import { Comp, GenericProps, getRootClassName, handleBasicClasses } from '@lumx/react/utils';
7
+ import { useSlideFocusManagement } from './useSlideFocusManagement';
8
+
9
+ /**
10
+ * Defines the props of the component.
11
+ */
12
+ export interface SlideshowItemGroupProps extends GenericProps {
13
+ role?: 'tabpanel' | 'group';
14
+ label?: string;
15
+ isDisplayed?: boolean;
16
+ }
17
+
18
+ /**
19
+ * Component display name.
20
+ */
21
+ const COMPONENT_NAME = 'SlideshowItemGroup';
22
+
23
+ /**
24
+ * Component default class name and class prefix.
25
+ */
26
+ export const CLASSNAME = getRootClassName(COMPONENT_NAME);
27
+
28
+ export const buildSlideShowGroupId = (slidesId: string, index: number) => `${slidesId}-slide-${index}`;
29
+
30
+ /**
31
+ * SlideshowItemGroup component.
32
+ *
33
+ * @param props Component props.
34
+ * @param ref Component ref.
35
+ * @return React element.
36
+ */
37
+ export const SlideshowItemGroup: Comp<SlideshowItemGroupProps, HTMLDivElement> = forwardRef((props, ref) => {
38
+ const { className, children, role = 'group', label, isDisplayed, ...forwardedProps } = props;
39
+ const groupRef = React.useRef<HTMLDivElement>(null);
40
+
41
+ useSlideFocusManagement({ isSlideDisplayed: isDisplayed, slideRef: groupRef });
42
+
43
+ return (
44
+ <div
45
+ ref={mergeRefs(groupRef, ref)}
46
+ role={role}
47
+ className={classNames(
48
+ className,
49
+ handleBasicClasses({
50
+ prefix: CLASSNAME,
51
+ }),
52
+ )}
53
+ aria-roledescription="slide"
54
+ aria-label={label}
55
+ {...forwardedProps}
56
+ >
57
+ {children}
58
+ </div>
59
+ );
60
+ });
61
+
62
+ SlideshowItemGroup.displayName = COMPONENT_NAME;
63
+ SlideshowItemGroup.className = CLASSNAME;
@@ -20,6 +20,7 @@ Array [
20
20
  onNextClick={[Function]}
21
21
  onPaginationClick={[Function]}
22
22
  onPreviousClick={[Function]}
23
+ paginationItemProps={[Function]}
23
24
  parentRef={null}
24
25
  previousButtonProps={
25
26
  Object {
@@ -32,11 +33,13 @@ Array [
32
33
  />
33
34
  </div>
34
35
  }
36
+ aria-label="Simple carousel example"
35
37
  autoPlay={false}
36
38
  groupBy={1}
39
+ hasControls={true}
37
40
  id="slideshow1"
38
- interval={1000}
39
41
  isAutoPlaying={false}
42
+ slideGroupLabel={[Function]}
40
43
  slidesId="slideshow-slides2"
41
44
  style={
42
45
  Object {
@@ -0,0 +1,92 @@
1
+ import React, { useEffect } from 'react';
2
+ import { getFocusableElements } from '@lumx/react/utils/focus/getFocusableElements';
3
+
4
+ export interface UseSlideFocusManagementProps {
5
+ isSlideDisplayed?: boolean;
6
+ slideRef: React.RefObject<HTMLDivElement>;
7
+ }
8
+
9
+ /**
10
+ * Classname set on elements whose focus was blocked.
11
+ * This is to easily find elements that have been tempered with,
12
+ * and not elements whose focus was already initially blocked.
13
+ * */
14
+ const BLOCKED_FOCUS_CLASSNAME = `focus-blocked`;
15
+
16
+ /**
17
+ * Manage how slides must behave when visible or not.
18
+ * When not visible, they should be hidden from screen readers and not focusable.
19
+ */
20
+ export const useSlideFocusManagement = ({ isSlideDisplayed, slideRef }: UseSlideFocusManagementProps) => {
21
+ useEffect(() => {
22
+ const element = slideRef?.current;
23
+
24
+ if (!element) {
25
+ return undefined;
26
+ }
27
+
28
+ /**
29
+ * Display given slide to screen readers and, if focus was blocked, restore focus on elements.
30
+ */
31
+ const enableSlide = () => {
32
+ // Hide from screen readers
33
+ element.setAttribute('aria-hidden', 'false');
34
+ // Find elements we have blocked focus on
35
+ element.querySelectorAll(`.${BLOCKED_FOCUS_CLASSNAME}`).forEach((focusableElement) => {
36
+ focusableElement.removeAttribute('tabindex');
37
+ focusableElement.classList.remove(BLOCKED_FOCUS_CLASSNAME);
38
+ });
39
+ };
40
+
41
+ /**
42
+ * Hide given slide from screen readers and block focus on all focusable elements within.
43
+ */
44
+ const blockSlide = () => {
45
+ element.setAttribute('aria-hidden', 'true');
46
+ getFocusableElements(element).forEach((focusableElement) => {
47
+ focusableElement.setAttribute('tabindex', '-1');
48
+ focusableElement.classList.add(BLOCKED_FOCUS_CLASSNAME);
49
+ });
50
+ };
51
+
52
+ const handleDisplay = () => {
53
+ if (!element) {
54
+ return;
55
+ }
56
+ if (isSlideDisplayed) {
57
+ enableSlide();
58
+ } else {
59
+ blockSlide();
60
+ }
61
+ };
62
+
63
+ // Callback function to execute when mutations are observed
64
+ const callback: MutationCallback = (mutationsList) => {
65
+ if (element) {
66
+ for (const mutation of mutationsList) {
67
+ if (mutation.type === 'childList') {
68
+ handleDisplay();
69
+ }
70
+ }
71
+ }
72
+ };
73
+
74
+ // Create an observer instance linked to the callback function
75
+ const observer = new MutationObserver(callback);
76
+
77
+ if (element) {
78
+ handleDisplay();
79
+
80
+ /** If slide is hidden, start observing for elements to block focus */
81
+ if (!isSlideDisplayed) {
82
+ observer.observe(element, { attributes: true, childList: true, subtree: true });
83
+ }
84
+ }
85
+
86
+ return () => {
87
+ if (!isSlideDisplayed) {
88
+ observer.disconnect();
89
+ }
90
+ };
91
+ }, [isSlideDisplayed, slideRef]);
92
+ };
@@ -0,0 +1,80 @@
1
+ import React from 'react';
2
+ import { ColorPalette, ColorVariant, TypographyCustom, TypographyInterface } from '@lumx/react';
3
+ import { Text } from './Text';
4
+
5
+ export default { title: 'LumX components/text/Text' };
6
+
7
+ export const Default = () => <Text as="p">Some text</Text>;
8
+
9
+ const withResizableBox = (Story: any) => (
10
+ <div
11
+ style={{
12
+ width: 150,
13
+ height: 60,
14
+ border: '1px solid red',
15
+ resize: 'both',
16
+ overflow: 'hidden',
17
+ }}
18
+ >
19
+ <Story />
20
+ </div>
21
+ );
22
+
23
+ export const Truncate = () => (
24
+ <Text as="p" truncate>
25
+ Some very very very long text
26
+ </Text>
27
+ );
28
+ Truncate.decorators = [withResizableBox];
29
+
30
+ export const TruncateMultiline = () => (
31
+ <Text as="p" truncate={{ lines: 2 }}>
32
+ Some very very very very very very very very very long text
33
+ </Text>
34
+ );
35
+ TruncateMultiline.decorators = [withResizableBox];
36
+
37
+ export const AllTypography = () => {
38
+ const typographies = [undefined, ...Object.values(TypographyInterface), ...Object.values(TypographyCustom)];
39
+ return (
40
+ <table>
41
+ {typographies.map((typography) => (
42
+ <tr key={typography}>
43
+ <td>{typography}</td>
44
+ <td>
45
+ <Text as="p" typography={typography}>
46
+ Some text
47
+ </Text>
48
+ </td>
49
+ </tr>
50
+ ))}
51
+ </table>
52
+ );
53
+ };
54
+
55
+ export const AllColor = () => {
56
+ const colorVariants = [undefined, ...Object.values(ColorVariant)];
57
+ const colors = [undefined, ...Object.values(ColorPalette)];
58
+ return (
59
+ <table style={{ borderCollapse: 'separate', borderSpacing: 5 }}>
60
+ <tr>
61
+ <td />
62
+ {colorVariants.map((colorVariant) => (
63
+ <td key={colorVariant}>{colorVariant}</td>
64
+ ))}
65
+ </tr>
66
+ {colors.map((color) => (
67
+ <tr key={color}>
68
+ <td>{color}</td>
69
+ {colorVariants.map((colorVariant) => (
70
+ <td key={colorVariant}>
71
+ <Text as="p" color={color} colorVariant={colorVariant}>
72
+ Some text
73
+ </Text>
74
+ </td>
75
+ ))}
76
+ </tr>
77
+ ))}
78
+ </table>
79
+ );
80
+ };
@@ -0,0 +1,62 @@
1
+ import React from 'react';
2
+
3
+ import { shallow } from 'enzyme';
4
+ import 'jest-enzyme';
5
+
6
+ import { commonTestsSuite } from '@lumx/react/testing/utils';
7
+ import { Text, TextProps } from './Text';
8
+
9
+ const setup = (props: Partial<TextProps> = {}) => {
10
+ const wrapper = shallow(<Text as="span" {...props} />);
11
+ return { props, wrapper };
12
+ };
13
+
14
+ describe(`<${Text.displayName}>`, () => {
15
+ describe('Snapshots and structure', () => {
16
+ it('should render default', () => {
17
+ const { wrapper } = setup({ children: 'Some text' });
18
+ expect(wrapper).toHaveDisplayName('span');
19
+ expect(wrapper.prop('className')).toBe(Text.className);
20
+ });
21
+
22
+ it('should render with as', () => {
23
+ const { wrapper } = setup({ children: 'Some text', as: 'p' });
24
+ expect(wrapper).toHaveDisplayName('p');
25
+ expect(wrapper.prop('className')).toBe(Text.className);
26
+ });
27
+
28
+ it('should render with typography', () => {
29
+ const { wrapper } = setup({ typography: 'body2', children: 'Some text' });
30
+ expect(wrapper).toHaveDisplayName('span');
31
+ expect(wrapper).toHaveClassName('lumx-typography-body2');
32
+ });
33
+
34
+ it('should render with color', () => {
35
+ const { wrapper } = setup({ color: 'blue', children: 'Some text' });
36
+ expect(wrapper).toHaveDisplayName('span');
37
+ expect(wrapper).toHaveClassName('lumx-color-font-blue-N');
38
+ });
39
+
40
+ it('should render with color and variant', () => {
41
+ const { wrapper } = setup({ color: 'blue', colorVariant: 'D2', children: 'Some text' });
42
+ expect(wrapper).toHaveDisplayName('span');
43
+ expect(wrapper).toHaveClassName('lumx-color-font-blue-D2');
44
+ });
45
+
46
+ it('should render truncated', () => {
47
+ const { wrapper } = setup({ truncate: true });
48
+ expect(wrapper).toHaveDisplayName('span');
49
+ expect(wrapper).toHaveClassName('lumx-text--is-truncated');
50
+ });
51
+
52
+ it('should render truncated multiline', () => {
53
+ const { wrapper } = setup({ truncate: { lines: 2 } });
54
+ expect(wrapper).toHaveDisplayName('span');
55
+ expect(wrapper).toHaveClassName('lumx-text--is-truncated-multiline');
56
+ expect(wrapper).toHaveStyle({ '--lumx-text-truncate-lines': 2 });
57
+ });
58
+ });
59
+
60
+ // Common tests suite.
61
+ commonTestsSuite(setup, { className: 'wrapper', prop: 'wrapper' }, { className: Text.className });
62
+ });
@@ -0,0 +1,94 @@
1
+ import React, { forwardRef } from 'react';
2
+
3
+ import { Color, ColorVariant, Typography } from '@lumx/react';
4
+ import { Comp, GenericProps, getRootClassName, handleBasicClasses, HeadingElement } from '@lumx/react/utils';
5
+ import classNames from 'classnames';
6
+
7
+ type TextComponents = 'span' | 'p' | HeadingElement;
8
+
9
+ /**
10
+ * Defines the props of the component.
11
+ */
12
+ export interface TextProps extends GenericProps {
13
+ /**
14
+ * Color variant.
15
+ */
16
+ color?: Color;
17
+ /**
18
+ * Lightened or darkened variant of the selected color.
19
+ */
20
+ colorVariant?: ColorVariant;
21
+ /**
22
+ * Typography variant.
23
+ */
24
+ typography?: Typography;
25
+ /**
26
+ * Custom component to render the text.
27
+ */
28
+ as: TextComponents;
29
+ /**
30
+ * Control whether the text should truncate or not.
31
+ * Setting as `true` will make the text truncate on a single line.
32
+ * Setting as `{ lines: number }` will make the text truncate on a multiple lines.
33
+ */
34
+ truncate?: boolean | { lines: number };
35
+ }
36
+
37
+ /**
38
+ * Component display name.
39
+ */
40
+ const COMPONENT_NAME = 'Text';
41
+
42
+ /**
43
+ * Component default class name and class prefix.
44
+ */
45
+ const CLASSNAME = getRootClassName(COMPONENT_NAME);
46
+
47
+ /**
48
+ * Component default props.
49
+ */
50
+ const DEFAULT_PROPS = {} as const;
51
+
52
+ /**
53
+ * Text component.
54
+ *
55
+ * @param props Component props.
56
+ * @param ref Component ref.
57
+ * @return React element.
58
+ */
59
+ export const Text: Comp<TextProps> = forwardRef((props, ref) => {
60
+ const { as, children, className, color, colorVariant, typography, truncate, style, ...forwardedProps } = props;
61
+
62
+ const Component = as as TextComponents;
63
+ const colorClass = color && `lumx-color-font-${color}-${colorVariant || ColorVariant.N}`;
64
+ const typographyClass = typography && `lumx-typography-${typography}`;
65
+
66
+ // Truncate mode
67
+ const truncateLinesStyle = typeof truncate === 'object' &&
68
+ truncate.lines > 1 && { '--lumx-text-truncate-lines': truncate.lines };
69
+ const isTruncatedMultiline = !!truncateLinesStyle;
70
+ const isTruncated = !!truncate;
71
+
72
+ return (
73
+ <Component
74
+ ref={ref as React.Ref<any>}
75
+ className={classNames(
76
+ className,
77
+ handleBasicClasses({
78
+ prefix: CLASSNAME,
79
+ isTruncated: isTruncated && !isTruncatedMultiline,
80
+ isTruncatedMultiline,
81
+ }),
82
+ typographyClass,
83
+ colorClass,
84
+ )}
85
+ style={{ ...truncateLinesStyle, ...style }}
86
+ {...forwardedProps}
87
+ >
88
+ {children}
89
+ </Component>
90
+ );
91
+ });
92
+ Text.displayName = COMPONENT_NAME;
93
+ Text.className = CLASSNAME;
94
+ Text.defaultProps = DEFAULT_PROPS;
@@ -0,0 +1 @@
1
+ export * from './Text';
@@ -4,6 +4,8 @@ interface UseRovingTabIndexOptions {
4
4
  parentRef: RefObject<HTMLElement>;
5
5
  elementSelector: string;
6
6
  keepTabIndex?: boolean;
7
+ /** Action to trigger when an element is focused using roving tab index */
8
+ onElementFocus?: (element: HTMLElement) => void;
7
9
  /** List of values to be used as extra dependencies of the useEffect */
8
10
  extraDependencies?: any[];
9
11
  }
@@ -12,6 +14,7 @@ export const useRovingTabIndex = ({
12
14
  parentRef,
13
15
  elementSelector,
14
16
  keepTabIndex,
17
+ onElementFocus,
15
18
  extraDependencies = [],
16
19
  }: UseRovingTabIndexOptions): void => {
17
20
  useEffect(
@@ -47,6 +50,12 @@ export const useRovingTabIndex = ({
47
50
  }
48
51
  const newElement = elements[newTabFocus];
49
52
  newElement?.focus();
53
+
54
+ // When an element is focused using roving tab index, trigger the onElementFocus callback
55
+ if (newElement && onElementFocus) {
56
+ onElementFocus(newElement);
57
+ }
58
+
50
59
  if (keepTabIndex) {
51
60
  (evt.currentTarget as HTMLElement).setAttribute('tabindex', '-1');
52
61
  newElement?.setAttribute('tabindex', '0');
package/src/index.ts CHANGED
@@ -16,6 +16,7 @@ export * from './components/expansion-panel';
16
16
  export * from './components/flag';
17
17
  export * from './components/flex-box';
18
18
  export * from './components/generic-block';
19
+ export * from './components/heading';
19
20
  export * from './components/grid';
20
21
  export * from './components/icon';
21
22
  export * from './components/image-block';
@@ -41,6 +42,7 @@ export * from './components/slideshow';
41
42
  export * from './components/switch';
42
43
  export * from './components/table';
43
44
  export * from './components/tabs';
45
+ export * from './components/text';
44
46
  export * from './components/text-field';
45
47
  export * from './components/thumbnail';
46
48
  export * from './components/toolbar';
@@ -0,0 +1,5 @@
1
+ /** CSS selector listing all tabbable elements. */
2
+ export const TABBABLE_ELEMENTS_SELECTOR = `a[href], button, textarea, input:not([type="hidden"]):not([hidden]), [tabindex]`;
3
+
4
+ /** CSS selector matching element that are disabled (should not receive focus). */
5
+ export const DISABLED_SELECTOR = `[hidden], [tabindex="-1"], [disabled]:not([disabled="false"]), [aria-disabled]:not([aria-disabled="false"])`;
@@ -1,10 +1,4 @@
1
- /** CSS selector listing all tabbable elements. */
2
- const TABBABLE_ELEMENTS_SELECTOR = `a[href], button, textarea, input:not([type="hidden"]):not([hidden]), [tabindex]`;
3
-
4
- /** CSS selector matching element that are disabled (should not receive focus). */
5
- const DISABLED_SELECTOR = `[hidden], [tabindex="-1"], [disabled]:not([disabled="false"]), [aria-disabled]:not([aria-disabled="false"])`;
6
-
7
- const isNotDisabled = (element: HTMLElement) => !element.matches(DISABLED_SELECTOR);
1
+ import { getFocusableElements } from './getFocusableElements';
8
2
 
9
3
  /**
10
4
  * Get first and last elements focusable in an element.
@@ -13,12 +7,12 @@ const isNotDisabled = (element: HTMLElement) => !element.matches(DISABLED_SELECT
13
7
  * @return first and last focusable elements
14
8
  */
15
9
  export function getFirstAndLastFocusable(parentElement: HTMLElement) {
16
- const focusableElements = Array.from(parentElement.querySelectorAll<HTMLElement>(TABBABLE_ELEMENTS_SELECTOR));
10
+ const focusableElements = getFocusableElements(parentElement);
17
11
 
18
12
  // First non disabled element.
19
- const first = focusableElements.find(isNotDisabled);
13
+ const first = focusableElements[0];
20
14
  // Last non disabled element.
21
- const last = focusableElements.reverse().find(isNotDisabled);
15
+ const last = focusableElements[focusableElements.length - 1];
22
16
 
23
17
  if (last && first) {
24
18
  return { first, last };