@lumx/react 3.6.6 → 3.6.7-alpha.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
@@ -7,8 +7,8 @@
7
7
  },
8
8
  "dependencies": {
9
9
  "@juggle/resize-observer": "^3.2.0",
10
- "@lumx/core": "^3.6.6",
11
- "@lumx/icons": "^3.6.6",
10
+ "@lumx/core": "^3.6.7-alpha.0",
11
+ "@lumx/icons": "^3.6.7-alpha.0",
12
12
  "@popperjs/core": "^2.5.4",
13
13
  "body-scroll-lock": "^3.1.5",
14
14
  "classnames": "^2.3.2",
@@ -112,5 +112,5 @@
112
112
  "build:storybook": "storybook build"
113
113
  },
114
114
  "sideEffects": false,
115
- "version": "3.6.6"
115
+ "version": "3.6.7-alpha.0"
116
116
  }
@@ -10,6 +10,9 @@ import { CLASSNAME } from './constants';
10
10
 
11
11
  const mockedDate = new Date(1487721600000);
12
12
  Date.now = jest.fn(() => mockedDate.valueOf());
13
+ jest.mock('@lumx/react/utils/date/getYearDisplayName', () => ({
14
+ getYearDisplayName: () => 'année',
15
+ }));
13
16
 
14
17
  const setup = (propsOverride: Partial<DatePickerProps> = {}) => {
15
18
  const props: DatePickerProps = {
@@ -23,18 +23,14 @@ export const DatePicker: Comp<DatePickerProps, HTMLDivElement> = forwardRef((pro
23
23
  referenceDate = new Date();
24
24
  }
25
25
 
26
- const [monthOffset, setMonthOffset] = useState(0);
27
-
28
- const setPrevMonth = () => setMonthOffset(monthOffset - 1);
29
- const setNextMonth = () => setMonthOffset(monthOffset + 1);
26
+ const [selectedMonth, setSelectedMonth] = useState(referenceDate);
27
+ const setPrevMonth = () => setSelectedMonth((current) => addMonthResetDay(current, -1));
28
+ const setNextMonth = () => setSelectedMonth((current) => addMonthResetDay(current, +1));
30
29
 
31
30
  const onDatePickerChange = (newDate?: Date) => {
32
31
  onChange(newDate);
33
- setMonthOffset(0);
34
32
  };
35
33
 
36
- const selectedMonth = addMonthResetDay(referenceDate, monthOffset);
37
-
38
34
  return (
39
35
  <DatePickerControlled
40
36
  ref={ref}
@@ -46,6 +42,7 @@ export const DatePicker: Comp<DatePickerProps, HTMLDivElement> = forwardRef((pro
46
42
  onNextMonthChange={setNextMonth}
47
43
  selectedMonth={selectedMonth}
48
44
  onChange={onDatePickerChange}
45
+ onMonthChange={setSelectedMonth}
49
46
  />
50
47
  );
51
48
  });
@@ -1,14 +1,18 @@
1
1
  import React from 'react';
2
2
 
3
- import { render, screen } from '@testing-library/react';
3
+ import { render, screen, waitFor } from '@testing-library/react';
4
4
  import { getByClassName, queryByClassName } from '@lumx/react/testing/utils/queries';
5
5
  import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
6
6
 
7
+ import userEvent from '@testing-library/user-event';
7
8
  import { DatePickerControlled, DatePickerControlledProps } from './DatePickerControlled';
8
9
  import { CLASSNAME } from './constants';
9
10
 
10
11
  const mockedDate = new Date(1487721600000);
11
12
  Date.now = jest.fn(() => mockedDate.valueOf());
13
+ jest.mock('@lumx/react/utils/date/getYearDisplayName', () => ({
14
+ getYearDisplayName: () => 'année',
15
+ }));
12
16
 
13
17
  type SetupProps = Partial<DatePickerControlledProps>;
14
18
 
@@ -22,6 +26,7 @@ const setup = (propsOverride: SetupProps = {}) => {
22
26
  value: mockedDate,
23
27
  nextButtonProps: { label: 'Next month' },
24
28
  previousButtonProps: { label: 'Previous month' },
29
+ onMonthChange: jest.fn(),
25
30
  ...propsOverride,
26
31
  };
27
32
  render(<DatePickerControlled {...props} />);
@@ -29,18 +34,57 @@ const setup = (propsOverride: SetupProps = {}) => {
29
34
  return { props, datePickerControlled };
30
35
  };
31
36
 
37
+ const queries = {
38
+ getYear: () =>
39
+ screen.getByRole('spinbutton', {
40
+ name: /année/i,
41
+ }),
42
+ getAccessibleMonthYear: (container: HTMLElement) => getByClassName(container, 'visually-hidden'),
43
+ };
44
+
32
45
  describe(`<${DatePickerControlled.displayName}>`, () => {
33
46
  it('should render', () => {
34
47
  const { datePickerControlled } = setup();
35
48
  expect(datePickerControlled).toBeInTheDocument();
36
49
 
37
50
  const month = queryByClassName(datePickerControlled, `${CLASSNAME}__month`);
38
- expect(month).toHaveTextContent('février 2017');
51
+ expect(month).toHaveTextContent('février');
52
+
53
+ expect(queries.getYear()).toBeInTheDocument();
54
+ expect(queries.getYear()).toHaveAttribute('value', '2017');
55
+ expect(queries.getYear()).toHaveAttribute('aria-label', 'année');
56
+
57
+ const toolbar = getByClassName(document.body, 'lumx-toolbar__label');
58
+ expect(queries.getAccessibleMonthYear(toolbar)).toBeInTheDocument();
59
+ expect(queries.getAccessibleMonthYear(toolbar)).toHaveTextContent('février 2017');
39
60
 
40
61
  const selected = queryByClassName(datePickerControlled, `${CLASSNAME}__month-day--is-selected`);
41
62
  expect(selected).toBe(screen.queryByRole('button', { name: /22 février 2017/i }));
42
63
  });
43
64
 
65
+ it('should validate year when pressing Enter', async () => {
66
+ const { datePickerControlled, props } = setup();
67
+ expect(datePickerControlled).toBeInTheDocument();
68
+
69
+ expect(queries.getYear()).toHaveAttribute('value', '2017');
70
+
71
+ await userEvent.type(queries.getYear(), '{Backspace}6{Enter}'); // set year to 2016
72
+ await waitFor(() => expect(props.onMonthChange).toHaveBeenCalledTimes(1));
73
+ expect(props.onMonthChange).toHaveBeenCalledWith(new Date(1454284800000)); // 2017 - 12 months = 2016
74
+ });
75
+
76
+ it('should validate year when on Blur', async () => {
77
+ const { datePickerControlled, props } = setup();
78
+ expect(datePickerControlled).toBeInTheDocument();
79
+
80
+ expect(queries.getYear()).toHaveAttribute('value', '2017');
81
+
82
+ await userEvent.type(queries.getYear(), '{Backspace}5'); // change value to 2015
83
+ await userEvent.tab(); // set year to 2015
84
+ await waitFor(() => expect(props.onMonthChange).toHaveBeenCalledTimes(1));
85
+ expect(props.onMonthChange).toHaveBeenCalledWith(new Date(1422748800000)); // 2017 - 24 months = 2015
86
+ });
87
+
44
88
  commonTestsSuiteRTL(setup, {
45
89
  baseClassName: CLASSNAME,
46
90
  forwardRef: 'datePickerControlled',
@@ -1,6 +1,6 @@
1
- import React, { forwardRef } from 'react';
1
+ import React, { KeyboardEventHandler, forwardRef } from 'react';
2
2
  import classNames from 'classnames';
3
- import { DatePickerProps, Emphasis, IconButton, Toolbar } from '@lumx/react';
3
+ import { DatePickerProps, Emphasis, FlexBox, IconButton, Text, TextField, Toolbar } from '@lumx/react';
4
4
  import { mdiChevronLeft, mdiChevronRight } from '@lumx/icons';
5
5
  import { Comp } from '@lumx/react/utils/type';
6
6
  import { getMonthCalendar } from '@lumx/react/utils/date/getMonthCalendar';
@@ -9,6 +9,9 @@ import { getCurrentLocale } from '@lumx/react/utils/locale/getCurrentLocale';
9
9
  import { parseLocale } from '@lumx/react/utils/locale/parseLocale';
10
10
  import { Locale } from '@lumx/react/utils/locale/types';
11
11
  import { usePreviousValue } from '@lumx/react/hooks/usePreviousValue';
12
+ import { getYearDisplayName } from '@lumx/react/utils/date/getYearDisplayName';
13
+ import { onEnterPressed } from '@lumx/react/utils/event';
14
+ import { addMonthResetDay } from '@lumx/react/utils/date/addMonthResetDay';
12
15
  import { CLASSNAME } from './constants';
13
16
 
14
17
  /**
@@ -21,6 +24,8 @@ export interface DatePickerControlledProps extends DatePickerProps {
21
24
  onPrevMonthChange(): void;
22
25
  /** On next month change callback. */
23
26
  onNextMonthChange(): void;
27
+ /** On month/year change callback. */
28
+ onMonthChange?: (newMonth: Date) => void;
24
29
  }
25
30
 
26
31
  /**
@@ -48,12 +53,43 @@ export const DatePickerControlled: Comp<DatePickerControlledProps, HTMLDivElemen
48
53
  selectedMonth,
49
54
  todayOrSelectedDateRef,
50
55
  value,
56
+ onMonthChange,
51
57
  } = props;
52
58
  const { weeks, weekDays } = React.useMemo(() => {
53
59
  const localeObj = parseLocale(locale) as Locale;
54
60
  return getMonthCalendar(localeObj, selectedMonth, minDate, maxDate);
55
61
  }, [locale, minDate, maxDate, selectedMonth]);
56
62
 
63
+ const selectedYear = selectedMonth.toLocaleDateString(locale, { year: 'numeric' }).slice(0, 4);
64
+ const [textFieldYearValue, setTextFieldYearValue] = React.useState(selectedYear);
65
+ const isYearValid = Number(textFieldYearValue) > 0 && Number(textFieldYearValue) <= 9999;
66
+
67
+ // Updates month offset when validating year. Adds or removes 12 months per year when updating year value.
68
+ const updateMonthOffset = React.useCallback(() => {
69
+ if (isYearValid) {
70
+ const yearNumber = selectedMonth.getFullYear();
71
+ const offset = (Number(textFieldYearValue) - yearNumber) * 12;
72
+ if (onMonthChange) {
73
+ onMonthChange(addMonthResetDay(selectedMonth, offset));
74
+ }
75
+ }
76
+ }, [isYearValid, selectedMonth, textFieldYearValue, onMonthChange]);
77
+
78
+ const monthYear = selectedMonth.toLocaleDateString(locale, { year: 'numeric', month: 'long' });
79
+
80
+ // Year can only be validatd by pressing Enter key or on Blur. The below handles the press Enter key case
81
+ const handleKeyPress: KeyboardEventHandler = React.useMemo(() => onEnterPressed(updateMonthOffset), [
82
+ updateMonthOffset,
83
+ ]);
84
+
85
+ // Required to update year in the TextField when the user changes year by using prev next month arrows
86
+ React.useEffect(() => {
87
+ if (Number(textFieldYearValue) !== selectedMonth.getFullYear()) {
88
+ setTextFieldYearValue(selectedMonth.getFullYear().toString());
89
+ }
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [selectedMonth]);
92
+
57
93
  const prevSelectedMonth = usePreviousValue(selectedMonth);
58
94
  const monthHasChanged = prevSelectedMonth && !isSameDay(selectedMonth, prevSelectedMonth);
59
95
 
@@ -63,6 +99,8 @@ export const DatePickerControlled: Comp<DatePickerControlledProps, HTMLDivElemen
63
99
  if (monthHasChanged) setLabelAriaLive('polite');
64
100
  }, [monthHasChanged]);
65
101
 
102
+ const label = getYearDisplayName(locale);
103
+
66
104
  return (
67
105
  <div ref={ref} className={`${CLASSNAME}`}>
68
106
  <Toolbar
@@ -84,9 +122,46 @@ export const DatePickerControlled: Comp<DatePickerControlledProps, HTMLDivElemen
84
122
  />
85
123
  }
86
124
  label={
87
- <span className={`${CLASSNAME}__month`} aria-live={labelAriaLive}>
88
- {selectedMonth.toLocaleDateString(locale, { year: 'numeric', month: 'long' })}
89
- </span>
125
+ <>
126
+ <span aria-live={labelAriaLive} className={onMonthChange ? 'visually-hidden' : ''} dir="auto">
127
+ {monthYear}
128
+ </span>
129
+ {onMonthChange && (
130
+ <FlexBox
131
+ className={`${CLASSNAME}__month`}
132
+ orientation="horizontal"
133
+ hAlign="center"
134
+ gap="regular"
135
+ vAlign="center"
136
+ dir="auto"
137
+ >
138
+ {RegExp(`(.*)(${selectedYear})(.*)`)
139
+ .exec(monthYear)
140
+ ?.slice(1)
141
+ .filter((part) => part !== '')
142
+ .map((part) =>
143
+ part === selectedYear ? (
144
+ <TextField
145
+ value={textFieldYearValue}
146
+ aria-label={label}
147
+ onChange={setTextFieldYearValue}
148
+ type="number"
149
+ max={9999}
150
+ min={0}
151
+ onBlur={updateMonthOffset}
152
+ onKeyPress={handleKeyPress}
153
+ key="year"
154
+ className={`${CLASSNAME}__year`}
155
+ />
156
+ ) : (
157
+ <Text as="p" key={part}>
158
+ {part}
159
+ </Text>
160
+ ),
161
+ )}
162
+ </FlexBox>
163
+ )}
164
+ </>
90
165
  }
91
166
  />
92
167
  <div className={`${CLASSNAME}__calendar`}>
@@ -11,6 +11,9 @@ import { CLASSNAME } from './constants';
11
11
 
12
12
  const mockedDate = new Date(1487721600000);
13
13
  Date.now = jest.fn(() => mockedDate.valueOf());
14
+ jest.mock('@lumx/react/utils/date/getYearDisplayName', () => ({
15
+ getYearDisplayName: () => 'année',
16
+ }));
14
17
 
15
18
  const setup = (propsOverride: Partial<DatePickerFieldProps> = {}) => {
16
19
  const props: DatePickerFieldProps = {
@@ -5,6 +5,7 @@ import { loremIpsum } from '@lumx/react/stories/utils/lorem';
5
5
  import { iconArgType } from '@lumx/react/stories/controls/icons';
6
6
  import { withCombinations } from '@lumx/react/stories/decorators/withCombinations';
7
7
  import { withUndefined } from '@lumx/react/stories/controls/withUndefined';
8
+ import { withNestedProps } from '../../stories/decorators/withNestedProps';
8
9
 
9
10
  export default {
10
11
  title: 'LumX components/message/Message',
@@ -54,3 +55,18 @@ export const CustomIcon = {
54
55
  icon: mdiDelete,
55
56
  },
56
57
  };
58
+
59
+ /**
60
+ * With close button (has background and kind info)
61
+ */
62
+ export const ClosableMessage = {
63
+ args: {
64
+ 'closeButtonProps.label': 'Close',
65
+ hasBackground: true,
66
+ kind: 'info',
67
+ },
68
+ argTypes: {
69
+ 'closeButtonProps.onClick': { action: true },
70
+ },
71
+ decorators: [withNestedProps()],
72
+ };
@@ -2,9 +2,11 @@ import { commonTestsSuiteRTL } from '@lumx/react/testing/utils';
2
2
  import { Kind } from '@lumx/react';
3
3
 
4
4
  import React from 'react';
5
- import { render } from '@testing-library/react';
5
+ import { queryByRole, render } from '@testing-library/react';
6
6
  import { getByClassName, queryByClassName } from '@lumx/react/testing/utils/queries';
7
7
  import { mdiAbTesting } from '@lumx/icons';
8
+
9
+ import userEvent from '@testing-library/user-event';
8
10
  import { Message, MessageProps } from './Message';
9
11
 
10
12
  const CLASSNAME = Message.className as string;
@@ -19,7 +21,8 @@ const setup = (propsOverride: SetupProps = {}) => {
19
21
  render(<Message {...props} />);
20
22
  const message = getByClassName(document.body, CLASSNAME);
21
23
  const icon = queryByClassName(message, `${CLASSNAME}__icon`);
22
- return { message, icon, props };
24
+ const closeButton = queryByRole(message, 'button', { name: props.closeButtonProps?.label });
25
+ return { message, icon, closeButton, props };
23
26
  };
24
27
 
25
28
  describe(`<${Message.displayName}>`, () => {
@@ -44,9 +47,28 @@ describe(`<${Message.displayName}>`, () => {
44
47
  });
45
48
 
46
49
  it.each(Object.values(Kind))('should render kind %s', (kind) => {
47
- const { message, icon } = setup({ kind });
50
+ const { message, icon, closeButton } = setup({ kind });
48
51
  expect(message.className).toEqual(expect.stringMatching(/\blumx-message--color-\w+\b/));
49
52
  expect(icon).toBeInTheDocument();
53
+ expect(closeButton).not.toBeInTheDocument();
54
+ });
55
+
56
+ it('should render close button', async () => {
57
+ const onClick = jest.fn();
58
+ const { closeButton } = setup({
59
+ hasBackground: true,
60
+ kind: 'info',
61
+ closeButtonProps: {
62
+ label: 'Close',
63
+ onClick,
64
+ },
65
+ });
66
+
67
+ expect(closeButton).toBeInTheDocument();
68
+
69
+ await userEvent.click(closeButton as HTMLElement);
70
+
71
+ expect(onClick).toHaveBeenCalled();
50
72
  });
51
73
  });
52
74
 
@@ -1,5 +1,5 @@
1
- import { mdiAlert, mdiAlertCircle, mdiCheckCircle, mdiInformation } from '@lumx/icons';
2
- import { ColorPalette, Icon, Kind, Size } from '@lumx/react';
1
+ import { mdiAlert, mdiAlertCircle, mdiCheckCircle, mdiClose, mdiInformation } from '@lumx/icons';
2
+ import { ColorPalette, Emphasis, Icon, IconButton, Kind, Size } from '@lumx/react';
3
3
  import { Comp, GenericProps } from '@lumx/react/utils/type';
4
4
  import { getRootClassName, handleBasicClasses } from '@lumx/react/utils/className';
5
5
  import classNames from 'classnames';
@@ -17,6 +17,17 @@ export interface MessageProps extends GenericProps {
17
17
  kind?: Kind;
18
18
  /** Message custom icon SVG path. */
19
19
  icon?: string;
20
+ /**
21
+ * Displays a close button.
22
+ *
23
+ * NB: only available if `kind === 'info' && hasBackground === true`
24
+ */
25
+ closeButtonProps?: {
26
+ /** The callback called when the button is clicked */
27
+ onClick: () => void;
28
+ /** The label of the close button. */
29
+ label: string;
30
+ };
20
31
  }
21
32
 
22
33
  /**
@@ -47,8 +58,10 @@ const CONFIG = {
47
58
  * @return React element.
48
59
  */
49
60
  export const Message: Comp<MessageProps, HTMLDivElement> = forwardRef((props, ref) => {
50
- const { children, className, hasBackground, kind, icon: customIcon, ...forwardedProps } = props;
61
+ const { children, className, hasBackground, kind, icon: customIcon, closeButtonProps, ...forwardedProps } = props;
51
62
  const { color, icon } = CONFIG[kind as Kind] || {};
63
+ const { onClick, label: closeButtonLabel } = closeButtonProps || {};
64
+ const isCloseButtonDisplayed = hasBackground && kind === 'info' && onClick && closeButtonLabel;
52
65
 
53
66
  return (
54
67
  <div
@@ -67,8 +80,18 @@ export const Message: Comp<MessageProps, HTMLDivElement> = forwardRef((props, re
67
80
  <Icon className={`${CLASSNAME}__icon`} icon={customIcon || icon} size={Size.xs} color={color} />
68
81
  )}
69
82
  <div className={`${CLASSNAME}__text`}>{children}</div>
83
+ {isCloseButtonDisplayed && (
84
+ <IconButton
85
+ className={`${CLASSNAME}__close-button`}
86
+ icon={mdiClose}
87
+ onClick={onClick}
88
+ label={closeButtonLabel}
89
+ emphasis={Emphasis.low}
90
+ />
91
+ )}
70
92
  </div>
71
93
  );
72
94
  });
95
+
73
96
  Message.displayName = COMPONENT_NAME;
74
97
  Message.className = CLASSNAME;
@@ -60,6 +60,8 @@ export interface PopoverProps extends GenericProps, HasTheme {
60
60
  placement?: Placement;
61
61
  /** Whether the popover should be rendered into a DOM node that exists outside the DOM hierarchy of the parent component. */
62
62
  usePortal?: boolean;
63
+ /** The element in which the focus trap should be set. Default to popover */
64
+ focusTrapZone?: RefObject<HTMLElement>;
63
65
  /** Z-axis position. */
64
66
  zIndex?: number;
65
67
  /** On close callback (on click away or Escape pressed). */
@@ -115,6 +117,7 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
115
117
  boundaryRef,
116
118
  fitToAnchorWidth,
117
119
  fitWithinViewportHeight,
120
+ focusTrapZone,
118
121
  offset,
119
122
  placement,
120
123
  style,
@@ -151,7 +154,7 @@ const _InnerPopover: Comp<PopoverProps, HTMLDivElement> = forwardRef((props, ref
151
154
 
152
155
  /** Only set focus within if the focus trap is disabled as they interfere with one another. */
153
156
  useFocus(focusElement?.current, !withFocusTrap && isOpen && isPositioned);
154
- useFocusTrap(withFocusTrap && isOpen && popoverRef?.current, focusElement?.current);
157
+ useFocusTrap(withFocusTrap && isOpen && focusTrapZone?.current || popoverRef?.current, focusElement?.current);
155
158
 
156
159
  const clickAwayRefs = useRef([popoverRef, anchorRef]);
157
160
  const mergedRefs = useMergeRefs<HTMLDivElement>(setPopperElement, ref, popoverRef);
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export default { title: 'LumX components/message/Message Demos' };
5
5
 
6
+ export { App as Closable } from './closable';
6
7
  export { App as Error } from './error';
7
8
  export { App as Info } from './info';
8
9
  export { App as Success } from './success';
@@ -0,0 +1,20 @@
1
+ import { getYearDisplayName } from './getYearDisplayName';
2
+
3
+ describe(getYearDisplayName, () => {
4
+ beforeEach(() => {
5
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
6
+ // @ts-ignore
7
+ jest.spyOn(Intl, 'DisplayNames').mockImplementation(() => ({
8
+ resolvedOptions: () => ({
9
+ fallback: 'code',
10
+ locale: 'fr',
11
+ style: 'short',
12
+ type: 'dateTimeField',
13
+ }),
14
+ of: () => 'année',
15
+ }));
16
+ });
17
+ it('should return a label', () => {
18
+ expect(getYearDisplayName('fr-FR')).toEqual('année');
19
+ });
20
+ });
@@ -0,0 +1,12 @@
1
+ export const getYearDisplayName = (locale: string) => {
2
+ let label: string | undefined;
3
+ try {
4
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
5
+ // @ts-ignore
6
+ const displayNames = new Intl.DisplayNames(locale, { type: 'dateTimeField' });
7
+ label = displayNames.of('year');
8
+ } catch (error) {
9
+ label = '';
10
+ }
11
+ return label;
12
+ };