@lumx/react 3.18.2-alpha.3 → 3.19.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/package.json CHANGED
@@ -6,8 +6,8 @@
6
6
  "url": "https://github.com/lumapps/design-system/issues"
7
7
  },
8
8
  "dependencies": {
9
- "@lumx/core": "^3.18.2-alpha.3",
10
- "@lumx/icons": "^3.18.2-alpha.3",
9
+ "@lumx/core": "^3.19.0",
10
+ "@lumx/icons": "^3.19.0",
11
11
  "@popperjs/core": "^2.5.4",
12
12
  "body-scroll-lock": "^3.1.5",
13
13
  "classnames": "^2.3.2",
@@ -105,5 +105,5 @@
105
105
  "build:storybook": "storybook build"
106
106
  },
107
107
  "sideEffects": false,
108
- "version": "3.18.2-alpha.3"
108
+ "version": "3.19.0"
109
109
  }
@@ -42,7 +42,7 @@ export const NavigationItem = Object.assign(
42
42
  forwardRefPolymorphic(<E extends ElementType = 'a'>(props: NavigationItemProps<E>, ref: ComponentRef<E>) => {
43
43
  const { className, icon, label, isCurrentPage, as: Element = 'a', ...forwardedProps } = props;
44
44
  const theme = useTheme();
45
- const { tooltipLabel, labelRef } = useOverflowTooltipLabel();
45
+ const { tooltipLabel, labelRef } = useOverflowTooltipLabel(label);
46
46
 
47
47
  const buttonProps = Element === 'button' ? { type: 'button' } : {};
48
48
 
@@ -6,7 +6,7 @@ import { textElementArgType } from '@lumx/react/stories/controls/element';
6
6
  import { withUndefined } from '@lumx/react/stories/controls/withUndefined';
7
7
  import { loremIpsum } from '@lumx/react/stories/utils/lorem';
8
8
  import { withCombinations } from '@lumx/react/stories/decorators/withCombinations';
9
- import { ColorPalette, ColorVariant, Icon, WhiteSpace } from '@lumx/react';
9
+ import { Button, ColorPalette, ColorVariant, Icon, WhiteSpace } from '@lumx/react';
10
10
  import { mdiEarth, mdiHeart } from '@lumx/icons';
11
11
  import { withResizableBox } from '@lumx/react/stories/decorators/withResizableBox';
12
12
  import { getSelectArgType } from '@lumx/react/stories/controls/selectArgType';
@@ -107,6 +107,28 @@ export const Truncate = {
107
107
  },
108
108
  };
109
109
 
110
+ /**
111
+ * Test the update of the `title` attribute when text overflows
112
+ */
113
+ export const TestUpdateTruncateTitleLabel = {
114
+ render(args: any) {
115
+ // eslint-disable-next-line react-hooks/rules-of-hooks
116
+ const [content, setContent] = React.useState<string>('Some text');
117
+ // eslint-disable-next-line react-hooks/rules-of-hooks
118
+ const lengthen = React.useCallback(() => setContent((prevContent) => `${prevContent} ${prevContent}`), []);
119
+ return (
120
+ <>
121
+ <Button onClick={lengthen}>Lengthen text</Button>
122
+ <Text as="p" truncate style={{ maxWidth: 300 }} {...args}>
123
+ {content}
124
+ </Text>
125
+ </>
126
+ );
127
+ },
128
+ // Disables Chromatic snapshot (not relevant for this story).
129
+ parameters: { chromatic: { disable: true } },
130
+ };
131
+
110
132
  /**
111
133
  * Long text with multi line truncate ellipsis
112
134
  */
@@ -112,7 +112,7 @@ export const Text = forwardRef<TextProps>((props, ref) => {
112
112
  !(isTruncated && !isTruncatedMultiline) &&
113
113
  whiteSpace && { '--lumx-text-white-space': whiteSpace };
114
114
 
115
- const { tooltipLabel, labelRef } = useOverflowTooltipLabel();
115
+ const { tooltipLabel, labelRef } = useOverflowTooltipLabel(children);
116
116
 
117
117
  return (
118
118
  <Component
@@ -8,6 +8,8 @@ import { withWrapper } from '@lumx/react/stories/decorators/withWrapper';
8
8
 
9
9
  import { AspectRatio, GridColumn, Size, Uploader, UploaderVariant } from '@lumx/react';
10
10
  import { mdiTextBoxPlus } from '@lumx/icons';
11
+ import { getSelectArgType } from '@lumx/react/stories/controls/selectArgType';
12
+ import { withUndefined } from '@lumx/react/stories/controls/withUndefined';
11
13
 
12
14
  export default {
13
15
  title: 'LumX components/uploader/Uploader',
@@ -15,11 +17,13 @@ export default {
15
17
  argTypes: {
16
18
  onClick: { action: true },
17
19
  icon: iconArgType,
20
+ aspectRatio: getSelectArgType(AspectRatio),
18
21
  },
19
22
  };
20
23
 
21
24
  const UPLOADER_VARIANTS = [UploaderVariant.square, UploaderVariant.rounded, UploaderVariant.circle];
22
25
  const UPLOADER_SIZES = [Size.xl, Size.xxl];
26
+ const ASPECT_RATIOS = [AspectRatio.wide, AspectRatio.horizontal, AspectRatio.vertical, AspectRatio.square];
23
27
 
24
28
  export const WithLabel = {
25
29
  args: { label: 'Pick a file' },
@@ -71,13 +75,18 @@ export const Variants = {
71
75
  withCombinations({
72
76
  combinations: {
73
77
  rows: { key: 'variant', options: UPLOADER_VARIANTS },
78
+ cols: {
79
+ Default: {},
80
+ Disabled: { isDisabled: true },
81
+ 'Aria Disabled': { 'aria-disabled': true },
82
+ },
74
83
  sections: {
75
84
  Button: {},
76
85
  'File input': { fileInputProps: {} },
77
86
  },
78
87
  },
79
88
  }),
80
- withWrapper({ maxColumns: 2, itemMinWidth: 300 }, GridColumn),
89
+ withWrapper({ maxColumns: 2, itemMinWidth: 470 }, GridColumn),
81
90
  ],
82
91
  };
83
92
 
@@ -88,13 +97,13 @@ export const RatioAndSize = {
88
97
  withCombinations({
89
98
  combinations: {
90
99
  cols: { key: 'size', options: UPLOADER_SIZES },
91
- rows: { key: 'aspectRatio', options: Object.values(AspectRatio) },
100
+ rows: { key: 'aspectRatio', options: withUndefined(ASPECT_RATIOS) },
92
101
  sections: {
93
102
  Button: {},
94
103
  'File input': { fileInputProps: {} },
95
104
  },
96
105
  },
97
106
  }),
98
- withWrapper({ maxColumns: 2, itemMinWidth: 200 }, GridColumn),
107
+ withWrapper({ maxColumns: 2, itemMinWidth: 470 }, GridColumn),
99
108
  ],
100
109
  };
@@ -22,8 +22,9 @@ const setup = (propsOverride: SetupProps = {}, { wrapper }: SetupRenderOptions =
22
22
  const uploader = getByClassName(document.body, CLASSNAME);
23
23
  const label = queryByClassName(uploader, `${CLASSNAME}__label`);
24
24
  const icon = queryByClassName(uploader, `${CLASSNAME}__icon`);
25
+ const input = queryByClassName(uploader, `${CLASSNAME}__input`);
25
26
 
26
- return { props, uploader, label, icon };
27
+ return { props, uploader, label, icon, input };
27
28
  };
28
29
 
29
30
  describe(`<${Uploader.displayName}>`, () => {
@@ -83,31 +84,51 @@ describe(`<${Uploader.displayName}>`, () => {
83
84
  });
84
85
  });
85
86
 
86
- describe('Events', () => {
87
+ describe.each`
88
+ name | props
89
+ ${'button'} | ${{}}
90
+ ${'button isDisabled '} | ${{ isDisabled: true }}
91
+ ${'button aria-disabled'} | ${{ 'aria-disabled': true }}
92
+ ${'file input '} | ${{ fileInputProps: { onChange: jest.fn() } }}
93
+ `('Events $name', ({ props }) => {
94
+ const onClick = jest.fn();
95
+ beforeEach(() => onClick.mockClear());
96
+ const assertClick = () => {
97
+ if (props.isDisabled || props['aria-disabled']) {
98
+ expect(onClick).not.toHaveBeenCalled();
99
+ } else {
100
+ expect(onClick).toHaveBeenCalled();
101
+ }
102
+ };
103
+
87
104
  it('should trigger `onClick` when clicked', async () => {
88
- const onClick = jest.fn();
89
- const { uploader } = setup({ onClick });
105
+ const { uploader } = setup({ ...props, onClick });
90
106
 
91
107
  await userEvent.click(uploader);
92
- expect(onClick).toHaveBeenCalled();
108
+ assertClick();
93
109
  });
94
110
 
95
111
  it('should trigger `onClick` when pressing Enter or Escape', async () => {
96
- const onClick = jest.fn();
97
- const { uploader } = setup({ onClick });
112
+ const { uploader, input } = setup({ ...props, onClick });
113
+
114
+ if (props.isDisabled) {
115
+ expect(props.fileInputProps ? input : uploader).toBeDisabled();
116
+ //Cannot test focus or activation
117
+ return;
118
+ }
98
119
 
99
120
  await userEvent.tab();
100
- expect(uploader).toHaveFocus();
121
+ expect(props.fileInputProps ? input : uploader).toHaveFocus();
101
122
 
102
123
  // Activate with Enter
103
124
  await userEvent.keyboard('[Enter]');
104
- expect(onClick).toHaveBeenCalled();
125
+ assertClick();
105
126
 
106
127
  onClick.mockClear();
107
128
 
108
129
  // Activate with Space
109
130
  await userEvent.keyboard('[Space]');
110
- expect(onClick).toHaveBeenCalled();
131
+ assertClick();
111
132
  });
112
133
  });
113
134
 
@@ -9,6 +9,9 @@ import { useBooleanState } from '@lumx/react/hooks/useBooleanState';
9
9
  import { useId } from '@lumx/react/hooks/useId';
10
10
  import { useTheme } from '@lumx/react/utils/theme/ThemeContext';
11
11
  import { forwardRef } from '@lumx/react/utils/react/forwardRef';
12
+ import { HasAriaDisabled } from '@lumx/react/utils/type/HasAriaDisabled';
13
+ import { useDisableStateProps } from '@lumx/react/utils/disabled';
14
+ import { VISUALLY_HIDDEN } from '@lumx/react/constants';
12
15
 
13
16
  /**
14
17
  * Uploader variants.
@@ -35,11 +38,13 @@ interface FileInputProps extends Omit<React.ComponentProps<'input'>, 'onChange'>
35
38
  /**
36
39
  * Defines the props of the component.
37
40
  */
38
- export interface UploaderProps extends GenericProps, HasTheme {
41
+ export interface UploaderProps extends GenericProps, HasTheme, HasAriaDisabled {
39
42
  /** Image aspect ratio. */
40
43
  aspectRatio?: AspectRatio;
41
44
  /** Icon (SVG path). */
42
45
  icon?: string;
46
+ /** Disabled state */
47
+ isDisabled?: boolean;
43
48
  /** Label text. */
44
49
  label?: string;
45
50
  /** Size variant. */
@@ -79,6 +84,7 @@ const DEFAULT_PROPS: Partial<UploaderProps> = {
79
84
  * @return React element.
80
85
  */
81
86
  export const Uploader = forwardRef<UploaderProps>((props, ref) => {
87
+ const { disabledStateProps, otherProps, isAnyDisabled } = useDisableStateProps(props);
82
88
  const defaultTheme = useTheme() || Theme.light;
83
89
  const {
84
90
  aspectRatio = DEFAULT_PROPS.aspectRatio,
@@ -89,32 +95,45 @@ export const Uploader = forwardRef<UploaderProps>((props, ref) => {
89
95
  theme = defaultTheme,
90
96
  variant = DEFAULT_PROPS.variant,
91
97
  fileInputProps,
98
+ onClick,
92
99
  ...forwardedProps
93
- } = props;
100
+ } = otherProps;
94
101
  // Adjust to square aspect ratio when using circle variants.
95
102
  const adjustedAspectRatio = variant === UploaderVariant.circle ? AspectRatio.square : aspectRatio;
96
103
 
104
+ const handleClick: React.MouseEventHandler = React.useCallback(
105
+ (evt) => {
106
+ if (isAnyDisabled) {
107
+ evt.preventDefault();
108
+ } else {
109
+ onClick?.(evt);
110
+ }
111
+ },
112
+ [isAnyDisabled, onClick],
113
+ );
114
+
97
115
  const generatedInputId = useId();
98
116
  const inputId = fileInputProps?.id || generatedInputId;
99
117
  const [isDragHovering, unsetDragHovering, setDragHovering] = useBooleanState(false);
100
118
  const wrapper = fileInputProps
101
- ? { Component: 'label' as const, props: { htmlFor: inputId } as const }
102
- : { Component: 'button' as const, props: { type: 'button' } as const };
119
+ ? ({ Component: 'label', props: { htmlFor: inputId } } as const)
120
+ : ({ Component: 'button', props: { type: 'button', ...disabledStateProps } } as const);
103
121
 
104
122
  const onChange = React.useMemo(() => {
105
- if (!fileInputProps?.onChange) return undefined;
123
+ if (isAnyDisabled || !fileInputProps?.onChange) return undefined;
106
124
  return (evt: React.ChangeEvent<HTMLInputElement>) => {
107
125
  const fileList = evt.target.files;
108
126
  const files = fileList ? Array.from(fileList) : [];
109
127
  fileInputProps.onChange(files, evt);
110
128
  };
111
- }, [fileInputProps]);
129
+ }, [isAnyDisabled, fileInputProps]);
112
130
 
113
131
  return (
114
132
  <wrapper.Component
115
133
  ref={ref as any}
116
134
  {...wrapper.props}
117
135
  {...forwardedProps}
136
+ onClick={handleClick}
118
137
  className={classNames(
119
138
  className,
120
139
  handleBasicClasses({
@@ -124,6 +143,7 @@ export const Uploader = forwardRef<UploaderProps>((props, ref) => {
124
143
  theme,
125
144
  variant,
126
145
  isDragHovering,
146
+ isDisabled: isAnyDisabled,
127
147
  }),
128
148
  )}
129
149
  >
@@ -139,8 +159,10 @@ export const Uploader = forwardRef<UploaderProps>((props, ref) => {
139
159
  <input
140
160
  type="file"
141
161
  id={inputId}
142
- className={`${CLASSNAME}__input`}
162
+ className={`${CLASSNAME}__input ${VISUALLY_HIDDEN}`}
163
+ {...disabledStateProps}
143
164
  {...fileInputProps}
165
+ readOnly={isAnyDisabled}
144
166
  onChange={onChange}
145
167
  onDragEnter={setDragHovering}
146
168
  onDragLeave={unsetDragHovering}
@@ -1,29 +1,32 @@
1
1
  import React from 'react';
2
2
  import { useTooltipContext } from '@lumx/react/components/tooltip/context';
3
+ import { VISUALLY_HIDDEN } from '@lumx/react/constants';
3
4
 
4
5
  /**
5
6
  * Compute a tooltip label based on a label element `innerText` if the text overflows.
6
- *
7
- * Warning: only works on first render, does not update on label element resize.
7
+ * Updates dynamically on content changes (but not on resize!)
8
8
  */
9
- export const useOverflowTooltipLabel = () => {
9
+ export const useOverflowTooltipLabel = (content: React.ReactNode) => {
10
10
  const parentTooltip = useTooltipContext();
11
11
  const [tooltipLabel, setTooltipLabel] = React.useState<string | undefined>(undefined);
12
- const labelRef = React.useCallback(
13
- (labelElement: HTMLElement | null) => {
14
- if (!labelElement || !!parentTooltip) {
15
- // Skip if label element is unknown
16
- // Skip if the parent has a tooltip
17
- return;
18
- }
12
+ const [labelElement, setLabelElement] = React.useState<HTMLElement | null>(null);
19
13
 
20
- // Label overflowing
21
- if (labelElement.offsetWidth < labelElement.scrollWidth) {
22
- setTooltipLabel(labelElement.innerText);
23
- }
24
- },
25
- [parentTooltip],
26
- );
14
+ React.useLayoutEffect(() => {
15
+ if (
16
+ // Not inside a tooltip
17
+ !parentTooltip &&
18
+ labelElement &&
19
+ // Not inside a visually hidden
20
+ !labelElement?.closest(`.${VISUALLY_HIDDEN}`) &&
21
+ // Text overflows
22
+ labelElement.offsetWidth < labelElement.scrollWidth
23
+ ) {
24
+ // Set tooltip label
25
+ setTooltipLabel(labelElement.innerText);
26
+ } else {
27
+ setTooltipLabel(undefined);
28
+ }
29
+ }, [labelElement, parentTooltip, content]);
27
30
 
28
- return { labelRef, tooltipLabel };
31
+ return { labelRef: setLabelElement, tooltipLabel };
29
32
  };
@@ -13,6 +13,7 @@ import {
13
13
  Switch,
14
14
  TextField,
15
15
  Thumbnail,
16
+ Uploader,
16
17
  } from '@lumx/react';
17
18
  import { DisabledStateProvider } from '@lumx/react/utils';
18
19
  import { getSelectArgType } from '@lumx/react/stories/controls/selectArgType';
@@ -87,6 +88,7 @@ export const AllComponents = {
87
88
  <Switch>Switch</Switch>
88
89
  <TextField label="texfield" onChange={() => {}} value="" />
89
90
  <Thumbnail alt="Thumbnail" image={LANDSCAPE_IMAGES.landscape1s200} onClick={() => {}} />
91
+ <Uploader label="Upload a file" fileInputProps={{ onChange: () => {} }} />
90
92
  </DisabledStateProvider>
91
93
  ),
92
94
  };