@pushwoosh/dumb-components 2.5.1 → 2.7.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/Chip/Chip.d.ts +3 -0
- package/Chip/Chip.js +30 -7
- package/Chip/Chip.types.d.ts +4 -2
- package/Chip/__tests__/Chip.test.d.ts +1 -0
- package/Chip/__tests__/Chip.test.js +101 -0
- package/Chip/polyglot/index.d.ts +3 -0
- package/Chip/polyglot/index.js +33 -0
- package/Chip/polyglot/messages_ar.d.ts +3 -0
- package/Chip/polyglot/messages_ar.js +3 -0
- package/Chip/polyglot/messages_bn.d.ts +3 -0
- package/Chip/polyglot/messages_bn.js +3 -0
- package/Chip/polyglot/messages_de.d.ts +3 -0
- package/Chip/polyglot/messages_de.js +3 -0
- package/Chip/polyglot/messages_en.d.ts +3 -0
- package/Chip/polyglot/messages_en.js +3 -0
- package/Chip/polyglot/messages_es.d.ts +3 -0
- package/Chip/polyglot/messages_es.js +3 -0
- package/Chip/polyglot/messages_fr.d.ts +3 -0
- package/Chip/polyglot/messages_fr.js +3 -0
- package/Chip/polyglot/messages_he.d.ts +3 -0
- package/Chip/polyglot/messages_he.js +3 -0
- package/Chip/polyglot/messages_hi.d.ts +3 -0
- package/Chip/polyglot/messages_hi.js +3 -0
- package/Chip/polyglot/messages_ja.d.ts +3 -0
- package/Chip/polyglot/messages_ja.js +3 -0
- package/Chip/polyglot/messages_ko.d.ts +3 -0
- package/Chip/polyglot/messages_ko.js +3 -0
- package/Chip/polyglot/messages_pt.d.ts +3 -0
- package/Chip/polyglot/messages_pt.js +3 -0
- package/Chip/polyglot/messages_ru.d.ts +3 -0
- package/Chip/polyglot/messages_ru.js +3 -0
- package/Chip/polyglot/messages_th.d.ts +3 -0
- package/Chip/polyglot/messages_th.js +3 -0
- package/Chip/polyglot/messages_zh.d.ts +3 -0
- package/Chip/polyglot/messages_zh.js +3 -0
- package/Chip/polyglot/types.d.ts +4 -0
- package/Chip/polyglot/types.js +1 -0
- package/Chip/styles.d.ts +3 -1
- package/Chip/styles.js +22 -12
- package/DropdownMenu/__tests__/DropdownMenuItem.test.d.ts +1 -0
- package/DropdownMenu/__tests__/DropdownMenuItem.test.js +109 -0
- package/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.d.ts +13 -4
- package/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.js +32 -9
- package/DropdownMenu/components/DropdownMenuItem/styles.d.ts +1 -1
- package/DropdownMenu/components/DropdownMenuItem/styles.js +1 -1
- package/DropdownMenu/components/DropdownMenuItem/types.d.ts +3 -1
- package/DropdownMenu/shared/styles.d.ts +1 -0
- package/DropdownMenu/shared/styles.js +6 -2
- package/package.json +1 -1
package/Chip/Chip.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Compact tag/filter pill with color variants, an optional leading icon, a close
|
|
3
3
|
* button or a help tooltip, and ellipsis truncation via `$maxWidth`.
|
|
4
|
+
* With `onClick` and no `$onClose` it renders as a toggle `<button aria-pressed>`.
|
|
4
5
|
*
|
|
5
6
|
* @example
|
|
6
7
|
* <Chip $variant="primary" $onClose={() => remove(tag)}>{tag}</Chip>
|
|
8
|
+
* <Chip $selected={active} onClick={toggle}>{label}</Chip>
|
|
7
9
|
*/
|
|
8
10
|
export declare const Chip: import("react").ForwardRefExoticComponent<{
|
|
9
11
|
$variant?: import("./Chip.types").ChipVariant;
|
|
@@ -13,6 +15,7 @@ export declare const Chip: import("react").ForwardRefExoticComponent<{
|
|
|
13
15
|
$maxWidth?: string;
|
|
14
16
|
$icon?: import("react").ReactNode;
|
|
15
17
|
$tooltip?: import("react").ReactNode;
|
|
18
|
+
disabled?: boolean;
|
|
16
19
|
as?: string;
|
|
17
20
|
} & {
|
|
18
21
|
children?: import("react").ReactNode | undefined;
|
package/Chip/Chip.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { forwardRef } from 'react';
|
|
3
3
|
import { TooltipTrigger } from '../Tooltip';
|
|
4
|
-
import {
|
|
4
|
+
import { useLang } from './polyglot';
|
|
5
|
+
import { ChipCloseButton, ChipCloseIcon, ChipLabel, ChipSlotIcon, ChipStyled } from './styles';
|
|
5
6
|
/**
|
|
6
7
|
* Compact tag/filter pill with color variants, an optional leading icon, a close
|
|
7
8
|
* button or a help tooltip, and ellipsis truncation via `$maxWidth`.
|
|
9
|
+
* With `onClick` and no `$onClose` it renders as a toggle `<button aria-pressed>`.
|
|
8
10
|
*
|
|
9
11
|
* @example
|
|
10
12
|
* <Chip $variant="primary" $onClose={() => remove(tag)}>{tag}</Chip>
|
|
13
|
+
* <Chip $selected={active} onClick={toggle}>{label}</Chip>
|
|
11
14
|
*/
|
|
12
15
|
export const Chip = forwardRef(({
|
|
13
16
|
$variant = 'primary',
|
|
@@ -17,12 +20,21 @@ export const Chip = forwardRef(({
|
|
|
17
20
|
$maxWidth,
|
|
18
21
|
$icon,
|
|
19
22
|
$tooltip,
|
|
23
|
+
disabled,
|
|
20
24
|
children,
|
|
21
25
|
as,
|
|
22
26
|
...rest
|
|
23
27
|
}, ref) => {
|
|
28
|
+
const lang = useLang();
|
|
24
29
|
const isCloseable = !!$onClose && !$readOnly;
|
|
25
30
|
const hasTooltip = !isCloseable && $tooltip !== undefined;
|
|
31
|
+
const element = as ?? (rest.onClick && !$onClose && !$readOnly ? 'button' : undefined);
|
|
32
|
+
const isButton = element === 'button';
|
|
33
|
+
const buttonProps = isButton ? {
|
|
34
|
+
type: 'button',
|
|
35
|
+
'aria-pressed': !!$selected,
|
|
36
|
+
disabled
|
|
37
|
+
} : {};
|
|
26
38
|
if ($readOnly && $onClose) {
|
|
27
39
|
console.error('[Chip]: $onClose is ignored when $readOnly is true — read-only chips have no close button.');
|
|
28
40
|
}
|
|
@@ -32,19 +44,30 @@ export const Chip = forwardRef(({
|
|
|
32
44
|
"$selected": $selected,
|
|
33
45
|
"$hasLeadingIcon": !!$icon,
|
|
34
46
|
"$hasTrailingSlot": isCloseable || hasTooltip,
|
|
35
|
-
"$
|
|
47
|
+
"$muted": $readOnly || isButton && disabled,
|
|
48
|
+
"$interactive": isButton && !disabled,
|
|
36
49
|
"$maxWidth": $maxWidth,
|
|
37
|
-
as:
|
|
50
|
+
as: element,
|
|
51
|
+
...buttonProps,
|
|
38
52
|
...rest,
|
|
39
53
|
children: [$icon && _jsx(ChipSlotIcon, {
|
|
40
54
|
children: $icon
|
|
41
55
|
}), _jsx(ChipLabel, {
|
|
42
56
|
"$truncated": $maxWidth !== undefined,
|
|
43
57
|
children: children
|
|
44
|
-
}), isCloseable && _jsx(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
58
|
+
}), isCloseable && _jsx(ChipCloseButton, {
|
|
59
|
+
type: "button",
|
|
60
|
+
"aria-label": lang({
|
|
61
|
+
message: 'DUMB.CHIP.REMOVE'
|
|
62
|
+
}),
|
|
63
|
+
onClick: event => {
|
|
64
|
+
event.stopPropagation();
|
|
65
|
+
$onClose(event);
|
|
66
|
+
},
|
|
67
|
+
children: _jsx(ChipCloseIcon, {
|
|
68
|
+
size: "small",
|
|
69
|
+
forwardedAs: "span"
|
|
70
|
+
})
|
|
48
71
|
}), hasTooltip && _jsx(ChipSlotIcon, {
|
|
49
72
|
children: _jsx(TooltipTrigger, {
|
|
50
73
|
view: "question",
|
package/Chip/Chip.types.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type HTMLAttributes, type PropsWithChildren, type ReactNode } from 'react';
|
|
2
2
|
/** Color variant of a {@link Chip}. */
|
|
3
3
|
export type ChipVariant = 'primary' | 'danger' | 'warning';
|
|
4
|
-
/** Props of {@link Chip} — all `<div>` attributes plus the chip-specific ones. */
|
|
4
|
+
/** Props of {@link Chip} — all `<div>` attributes plus the chip-specific ones; `onClick` without `$onClose` makes it a toggle button. */
|
|
5
5
|
export type ChipProps = PropsWithChildren<{
|
|
6
6
|
/** Color variant (default `primary`). */
|
|
7
7
|
$variant?: ChipVariant;
|
|
@@ -17,6 +17,8 @@ export type ChipProps = PropsWithChildren<{
|
|
|
17
17
|
$icon?: ReactNode;
|
|
18
18
|
/** Help tooltip shown via a trailing question-mark icon (only when not closeable). */
|
|
19
19
|
$tooltip?: ReactNode;
|
|
20
|
-
/**
|
|
20
|
+
/** Disables an interactive chip: passed to the `<button>`, muted like `$readOnly`. */
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
/** Render as a different element/tag; `button` gives the same result as the automatic interactive mode. */
|
|
21
23
|
as?: string;
|
|
22
24
|
}> & HTMLAttributes<HTMLDivElement>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { Chip } from '../Chip';
|
|
4
|
+
describe('Chip', () => {
|
|
5
|
+
it('renders a passive chip as a div without a button role', () => {
|
|
6
|
+
const {
|
|
7
|
+
container
|
|
8
|
+
} = render(_jsx(Chip, {
|
|
9
|
+
children: "Tag"
|
|
10
|
+
}));
|
|
11
|
+
expect(container.firstChild?.nodeName).toBe('DIV');
|
|
12
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
13
|
+
});
|
|
14
|
+
it('renders a chip with onClick as a toggle button reflecting $selected', () => {
|
|
15
|
+
const onClick = jest.fn();
|
|
16
|
+
const {
|
|
17
|
+
rerender
|
|
18
|
+
} = render(_jsx(Chip, {
|
|
19
|
+
onClick: onClick,
|
|
20
|
+
children: "All"
|
|
21
|
+
}));
|
|
22
|
+
const chip = screen.getByRole('button', {
|
|
23
|
+
name: 'All'
|
|
24
|
+
});
|
|
25
|
+
expect(chip).toHaveAttribute('type', 'button');
|
|
26
|
+
expect(chip).toHaveAttribute('aria-pressed', 'false');
|
|
27
|
+
fireEvent.click(chip);
|
|
28
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
29
|
+
rerender(_jsx(Chip, {
|
|
30
|
+
onClick: onClick,
|
|
31
|
+
"$selected": true,
|
|
32
|
+
children: "All"
|
|
33
|
+
}));
|
|
34
|
+
expect(screen.getByRole('button', {
|
|
35
|
+
name: 'All'
|
|
36
|
+
})).toHaveAttribute('aria-pressed', 'true');
|
|
37
|
+
});
|
|
38
|
+
it('passes disabled to the button and blocks clicks', () => {
|
|
39
|
+
const onClick = jest.fn();
|
|
40
|
+
render(_jsx(Chip, {
|
|
41
|
+
onClick: onClick,
|
|
42
|
+
disabled: true,
|
|
43
|
+
children: "All"
|
|
44
|
+
}));
|
|
45
|
+
const chip = screen.getByRole('button', {
|
|
46
|
+
name: 'All'
|
|
47
|
+
});
|
|
48
|
+
expect(chip).toBeDisabled();
|
|
49
|
+
fireEvent.click(chip);
|
|
50
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
it('keeps a closeable chip a div and exposes only the close button', () => {
|
|
53
|
+
const onClose = jest.fn();
|
|
54
|
+
const {
|
|
55
|
+
container
|
|
56
|
+
} = render(_jsx(Chip, {
|
|
57
|
+
onClick: () => {},
|
|
58
|
+
"$onClose": onClose,
|
|
59
|
+
children: "Tag"
|
|
60
|
+
}));
|
|
61
|
+
expect(container.firstChild?.nodeName).toBe('DIV');
|
|
62
|
+
const buttons = screen.getAllByRole('button');
|
|
63
|
+
expect(buttons).toHaveLength(1);
|
|
64
|
+
expect(buttons[0]).toHaveAccessibleName('Remove');
|
|
65
|
+
fireEvent.click(buttons[0]);
|
|
66
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
67
|
+
});
|
|
68
|
+
it('does not bubble a close click to the chip onClick', () => {
|
|
69
|
+
const onClick = jest.fn();
|
|
70
|
+
const onClose = jest.fn();
|
|
71
|
+
render(_jsx(Chip, {
|
|
72
|
+
onClick: onClick,
|
|
73
|
+
"$onClose": onClose,
|
|
74
|
+
children: "Tag"
|
|
75
|
+
}));
|
|
76
|
+
fireEvent.click(screen.getByRole('button', {
|
|
77
|
+
name: 'Remove'
|
|
78
|
+
}));
|
|
79
|
+
expect(onClose).toHaveBeenCalledTimes(1);
|
|
80
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
it('treats explicit as="button" like the automatic interactive mode', () => {
|
|
83
|
+
render(_jsx(Chip, {
|
|
84
|
+
as: "button",
|
|
85
|
+
children: "Tag"
|
|
86
|
+
}));
|
|
87
|
+
const chip = screen.getByRole('button', {
|
|
88
|
+
name: 'Tag'
|
|
89
|
+
});
|
|
90
|
+
expect(chip).toHaveAttribute('type', 'button');
|
|
91
|
+
expect(chip).toHaveAttribute('aria-pressed', 'false');
|
|
92
|
+
});
|
|
93
|
+
it('does not become a button when read-only', () => {
|
|
94
|
+
render(_jsx(Chip, {
|
|
95
|
+
onClick: () => {},
|
|
96
|
+
"$readOnly": true,
|
|
97
|
+
children: "Tag"
|
|
98
|
+
}));
|
|
99
|
+
expect(screen.queryByRole('button')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { messages as ar } from './messages_ar';
|
|
2
|
+
import { messages as bn } from './messages_bn';
|
|
3
|
+
import { messages as de } from './messages_de';
|
|
4
|
+
import { messages as en } from './messages_en';
|
|
5
|
+
import { messages as es } from './messages_es';
|
|
6
|
+
import { messages as fr } from './messages_fr';
|
|
7
|
+
import { messages as he } from './messages_he';
|
|
8
|
+
import { messages as hi } from './messages_hi';
|
|
9
|
+
import { messages as ja } from './messages_ja';
|
|
10
|
+
import { messages as ko } from './messages_ko';
|
|
11
|
+
import { messages as pt } from './messages_pt';
|
|
12
|
+
import { messages as ru } from './messages_ru';
|
|
13
|
+
import { messages as th } from './messages_th';
|
|
14
|
+
import { messages as zh } from './messages_zh';
|
|
15
|
+
import { createLang } from '../../polyglot';
|
|
16
|
+
export const {
|
|
17
|
+
useLang
|
|
18
|
+
} = createLang({
|
|
19
|
+
ar,
|
|
20
|
+
bn,
|
|
21
|
+
de,
|
|
22
|
+
en,
|
|
23
|
+
es,
|
|
24
|
+
fr,
|
|
25
|
+
he,
|
|
26
|
+
hi,
|
|
27
|
+
ja,
|
|
28
|
+
ko,
|
|
29
|
+
pt,
|
|
30
|
+
ru,
|
|
31
|
+
th,
|
|
32
|
+
zh
|
|
33
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/Chip/styles.d.ts
CHANGED
|
@@ -4,11 +4,13 @@ export declare const ChipStyled: import("styled-components").StyledComponent<"di
|
|
|
4
4
|
$selected?: boolean;
|
|
5
5
|
$hasLeadingIcon?: boolean;
|
|
6
6
|
$hasTrailingSlot?: boolean;
|
|
7
|
-
$
|
|
7
|
+
$muted?: boolean;
|
|
8
|
+
$interactive?: boolean;
|
|
8
9
|
$maxWidth?: string;
|
|
9
10
|
}, never>;
|
|
10
11
|
export declare const ChipSlotIcon: import("styled-components").StyledComponent<"span", any, {}, never>;
|
|
11
12
|
export declare const ChipLabel: import("styled-components").StyledComponent<"span", any, {
|
|
12
13
|
$truncated?: boolean;
|
|
13
14
|
}, never>;
|
|
15
|
+
export declare const ChipCloseButton: import("styled-components").StyledComponent<"button", any, {}, never>;
|
|
14
16
|
export declare const ChipCloseIcon: import("styled-components").StyledComponent<import("react").FC<import("@pushwoosh/kit-icons").CloseIconProps>, any, {}, never>;
|
package/Chip/styles.js
CHANGED
|
@@ -2,45 +2,51 @@ import styled from 'styled-components';
|
|
|
2
2
|
import { Color } from '@pushwoosh/kit-constants';
|
|
3
3
|
import { CloseIcon } from '@pushwoosh/kit-icons';
|
|
4
4
|
import { variants } from './Chip.constants';
|
|
5
|
+
const focusRing = `
|
|
6
|
+
&:focus-visible {
|
|
7
|
+
outline: 1px solid ${Color.BRIGHT};
|
|
8
|
+
outline-offset: 2px;
|
|
9
|
+
}
|
|
10
|
+
`;
|
|
5
11
|
export const ChipStyled = styled.div.withConfig({
|
|
6
12
|
displayName: "ChipStyled",
|
|
7
13
|
componentId: "sc-e2g39l-0"
|
|
8
|
-
})(["display:inline-flex;min-height:24px;", " padding:0 ", " 0 ", ";align-items:center;gap:4px;cursor:", ";border-radius:4px;color:", ";background:", ";font-weight:400;line-height:140%;", ""], ({
|
|
14
|
+
})(["appearance:none;margin:0;border:0;font:inherit;text-align:inherit;display:inline-flex;min-height:24px;", " padding:0 ", " 0 ", ";align-items:center;gap:4px;cursor:", ";border-radius:4px;color:", ";background:", ";font-weight:400;line-height:140%;", " ", ""], ({
|
|
9
15
|
$maxWidth
|
|
10
16
|
}) => $maxWidth !== undefined && `max-width: ${$maxWidth};`, ({
|
|
11
17
|
$hasTrailingSlot
|
|
12
18
|
}) => $hasTrailingSlot ? '4px' : '8px', ({
|
|
13
19
|
$hasLeadingIcon
|
|
14
20
|
}) => $hasLeadingIcon ? '4px' : '8px', ({
|
|
15
|
-
$
|
|
16
|
-
}) => $
|
|
21
|
+
$interactive
|
|
22
|
+
}) => $interactive ? 'pointer' : 'default', ({
|
|
17
23
|
$variant,
|
|
18
24
|
$selected,
|
|
19
|
-
$
|
|
25
|
+
$muted
|
|
20
26
|
}) => {
|
|
21
|
-
if ($
|
|
27
|
+
if ($muted) {
|
|
22
28
|
return Color.MAIN;
|
|
23
29
|
}
|
|
24
30
|
return $selected ? Color.CLEAR : variants[$variant].color;
|
|
25
31
|
}, ({
|
|
26
32
|
$variant,
|
|
27
33
|
$selected,
|
|
28
|
-
$
|
|
34
|
+
$muted
|
|
29
35
|
}) => {
|
|
30
|
-
if ($
|
|
36
|
+
if ($muted) {
|
|
31
37
|
return Color.SOFT_LIGHT;
|
|
32
38
|
}
|
|
33
39
|
return $selected ? Color.MAIN : variants[$variant].background;
|
|
34
40
|
}, ({
|
|
35
41
|
$variant,
|
|
36
42
|
$selected,
|
|
37
|
-
$
|
|
38
|
-
}) => !$selected && !$
|
|
43
|
+
$muted
|
|
44
|
+
}) => !$selected && !$muted && `
|
|
39
45
|
&:hover {
|
|
40
46
|
color: ${variants[$variant].hoverColor};
|
|
41
47
|
background: ${variants[$variant].hoverBackground};
|
|
42
48
|
}
|
|
43
|
-
|
|
49
|
+
`, focusRing);
|
|
44
50
|
export const ChipSlotIcon = styled.span.withConfig({
|
|
45
51
|
displayName: "ChipSlotIcon",
|
|
46
52
|
componentId: "sc-e2g39l-1"
|
|
@@ -57,7 +63,11 @@ export const ChipLabel = styled.span.withConfig({
|
|
|
57
63
|
text-overflow: ellipsis;
|
|
58
64
|
white-space: nowrap;
|
|
59
65
|
`);
|
|
66
|
+
export const ChipCloseButton = styled.button.withConfig({
|
|
67
|
+
displayName: "ChipCloseButton",
|
|
68
|
+
componentId: "sc-e2g39l-3"
|
|
69
|
+
})(["appearance:none;display:inline-flex;flex-shrink:0;margin:0;padding:0;border:0;border-radius:2px;background:none;color:inherit;cursor:pointer;", ""], focusRing);
|
|
60
70
|
export const ChipCloseIcon = styled(CloseIcon).withConfig({
|
|
61
71
|
displayName: "ChipCloseIcon",
|
|
62
|
-
componentId: "sc-e2g39l-
|
|
63
|
-
})(["
|
|
72
|
+
componentId: "sc-e2g39l-4"
|
|
73
|
+
})(["transition:transform 0.2s;", ":hover &{transform:scale(1.3);}"], ChipCloseButton);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { fireEvent, render, screen } from '@testing-library/react';
|
|
3
|
+
import { createRef } from 'react';
|
|
4
|
+
import { DropdownMenuItem } from '../components/DropdownMenuItem';
|
|
5
|
+
import { DropdownContext } from '../context';
|
|
6
|
+
const renderInMenu = (ui, close = jest.fn()) => render(_jsx(DropdownContext.Provider, {
|
|
7
|
+
value: close,
|
|
8
|
+
children: ui
|
|
9
|
+
}));
|
|
10
|
+
describe('DropdownMenuItem', () => {
|
|
11
|
+
it('calls onClick and closes the menu', () => {
|
|
12
|
+
const onClick = jest.fn();
|
|
13
|
+
const close = jest.fn();
|
|
14
|
+
renderInMenu(_jsx(DropdownMenuItem, {
|
|
15
|
+
onClick: onClick,
|
|
16
|
+
children: "Preview"
|
|
17
|
+
}), close);
|
|
18
|
+
const item = screen.getByRole('button', {
|
|
19
|
+
name: 'Preview'
|
|
20
|
+
});
|
|
21
|
+
expect(item).toHaveAttribute('type', 'button');
|
|
22
|
+
fireEvent.click(item);
|
|
23
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
24
|
+
expect(close).toHaveBeenCalledWith(false);
|
|
25
|
+
});
|
|
26
|
+
it('keeps a disabled item without hint natively disabled', () => {
|
|
27
|
+
renderInMenu(_jsx(DropdownMenuItem, {
|
|
28
|
+
onClick: jest.fn(),
|
|
29
|
+
disabled: true,
|
|
30
|
+
children: "Preview"
|
|
31
|
+
}));
|
|
32
|
+
expect(screen.getByRole('button', {
|
|
33
|
+
name: 'Preview'
|
|
34
|
+
})).toBeDisabled();
|
|
35
|
+
});
|
|
36
|
+
it('makes a disabled item with hint focusable, described and inert', () => {
|
|
37
|
+
const onClick = jest.fn();
|
|
38
|
+
const close = jest.fn();
|
|
39
|
+
renderInMenu(_jsx(DropdownMenuItem, {
|
|
40
|
+
onClick: onClick,
|
|
41
|
+
disabled: true,
|
|
42
|
+
hint: "Save the template first",
|
|
43
|
+
children: "Test email"
|
|
44
|
+
}), close);
|
|
45
|
+
const item = screen.getByRole('button', {
|
|
46
|
+
name: 'Test email'
|
|
47
|
+
});
|
|
48
|
+
expect(item).not.toBeDisabled();
|
|
49
|
+
expect(item).toHaveAttribute('aria-disabled', 'true');
|
|
50
|
+
expect(item).toHaveAccessibleDescription('Save the template first');
|
|
51
|
+
fireEvent.click(item);
|
|
52
|
+
expect(onClick).not.toHaveBeenCalled();
|
|
53
|
+
expect(close).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it('shows the hint tooltip on hover and focus', () => {
|
|
56
|
+
renderInMenu(_jsx(DropdownMenuItem, {
|
|
57
|
+
onClick: jest.fn(),
|
|
58
|
+
disabled: true,
|
|
59
|
+
hint: "Save first",
|
|
60
|
+
children: "Test email"
|
|
61
|
+
}));
|
|
62
|
+
const item = screen.getByRole('button', {
|
|
63
|
+
name: 'Test email'
|
|
64
|
+
});
|
|
65
|
+
fireEvent.mouseEnter(item);
|
|
66
|
+
expect(screen.getAllByText('Save first')).toHaveLength(2);
|
|
67
|
+
fireEvent.mouseLeave(item);
|
|
68
|
+
expect(screen.getAllByText('Save first')).toHaveLength(1);
|
|
69
|
+
fireEvent.focusIn(item);
|
|
70
|
+
expect(screen.getAllByText('Save first')).toHaveLength(2);
|
|
71
|
+
});
|
|
72
|
+
it('ignores hint while enabled', () => {
|
|
73
|
+
renderInMenu(_jsx(DropdownMenuItem, {
|
|
74
|
+
onClick: jest.fn(),
|
|
75
|
+
hint: "Save first",
|
|
76
|
+
children: "Test email"
|
|
77
|
+
}));
|
|
78
|
+
expect(screen.queryByText('Save first')).toBeNull();
|
|
79
|
+
expect(screen.getByRole('button', {
|
|
80
|
+
name: 'Test email'
|
|
81
|
+
})).not.toHaveAttribute('aria-describedby');
|
|
82
|
+
});
|
|
83
|
+
it('forwards ref to the item button, with and without hint', () => {
|
|
84
|
+
const ref = createRef();
|
|
85
|
+
const {
|
|
86
|
+
rerender
|
|
87
|
+
} = renderInMenu(_jsx(DropdownMenuItem, {
|
|
88
|
+
ref: ref,
|
|
89
|
+
onClick: jest.fn(),
|
|
90
|
+
children: "Preview"
|
|
91
|
+
}));
|
|
92
|
+
expect(ref.current).toBe(screen.getByRole('button', {
|
|
93
|
+
name: 'Preview'
|
|
94
|
+
}));
|
|
95
|
+
rerender(_jsx(DropdownContext.Provider, {
|
|
96
|
+
value: jest.fn(),
|
|
97
|
+
children: _jsx(DropdownMenuItem, {
|
|
98
|
+
ref: ref,
|
|
99
|
+
onClick: jest.fn(),
|
|
100
|
+
disabled: true,
|
|
101
|
+
hint: "Add content",
|
|
102
|
+
children: "Preview"
|
|
103
|
+
})
|
|
104
|
+
}));
|
|
105
|
+
expect(ref.current).toBe(screen.getByRole('button', {
|
|
106
|
+
name: 'Preview'
|
|
107
|
+
}));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
-
import { type FC } from 'react';
|
|
2
|
-
import { type DropdownMenuItemProps } from './types';
|
|
3
1
|
/**
|
|
4
2
|
* Clickable row inside a {@link DropdownMenu}, with active (checked), danger and
|
|
5
|
-
* disabled states.
|
|
3
|
+
* disabled states. A disabled item with `hint` stays focusable and explains itself in a tooltip.
|
|
4
|
+
* Must be rendered inside a {@link DropdownMenu}.
|
|
6
5
|
*
|
|
7
6
|
* @example
|
|
8
7
|
* <DropdownMenuItem onClick={select} isActive={selected}>Option</DropdownMenuItem>
|
|
8
|
+
* <DropdownMenuItem onClick={sendTest} disabled={!isSaved} hint="Save the template first">Test email</DropdownMenuItem>
|
|
9
9
|
*/
|
|
10
|
-
export declare const DropdownMenuItem:
|
|
10
|
+
export declare const DropdownMenuItem: import("react").ForwardRefExoticComponent<{
|
|
11
|
+
onClick: () => void;
|
|
12
|
+
isActive?: boolean;
|
|
13
|
+
isDanger?: boolean;
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
hint?: import("react").ReactNode;
|
|
16
|
+
closeOnClick?: boolean;
|
|
17
|
+
} & {
|
|
18
|
+
children?: import("react").ReactNode | undefined;
|
|
19
|
+
} & import("react").RefAttributes<HTMLButtonElement>>;
|
|
@@ -1,39 +1,50 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useContext } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useContext, useId } from 'react';
|
|
3
3
|
import { CheckIcon } from '@pushwoosh/kit-icons';
|
|
4
|
-
import { DropdownMenuItemStyled, DropdownMenuIconStyled } from './styles';
|
|
4
|
+
import { DropdownMenuItemStyled, DropdownMenuIconStyled, DropdownMenuItemHintStyled } from './styles';
|
|
5
|
+
import { Tooltip } from '../../../Tooltip';
|
|
5
6
|
import { DropdownContext } from '../../context';
|
|
6
7
|
/**
|
|
7
8
|
* Clickable row inside a {@link DropdownMenu}, with active (checked), danger and
|
|
8
|
-
* disabled states.
|
|
9
|
+
* disabled states. A disabled item with `hint` stays focusable and explains itself in a tooltip.
|
|
10
|
+
* Must be rendered inside a {@link DropdownMenu}.
|
|
9
11
|
*
|
|
10
12
|
* @example
|
|
11
13
|
* <DropdownMenuItem onClick={select} isActive={selected}>Option</DropdownMenuItem>
|
|
14
|
+
* <DropdownMenuItem onClick={sendTest} disabled={!isSaved} hint="Save the template first">Test email</DropdownMenuItem>
|
|
12
15
|
*/
|
|
13
|
-
export const DropdownMenuItem = ({
|
|
16
|
+
export const DropdownMenuItem = forwardRef(({
|
|
14
17
|
children,
|
|
15
18
|
onClick,
|
|
16
19
|
isActive,
|
|
17
20
|
isDanger,
|
|
18
21
|
disabled,
|
|
22
|
+
hint,
|
|
19
23
|
closeOnClick = true
|
|
20
|
-
}) => {
|
|
24
|
+
}, ref) => {
|
|
21
25
|
const onClickContext = useContext(DropdownContext);
|
|
26
|
+
const hintId = useId();
|
|
22
27
|
if (process.env.NODE_ENV === 'development') {
|
|
23
28
|
if (!onClickContext) {
|
|
24
29
|
console.error('DropdownMenuItem must be inside DropdownMenu');
|
|
25
30
|
}
|
|
26
31
|
}
|
|
27
|
-
|
|
32
|
+
const hasHint = Boolean(disabled && hint);
|
|
33
|
+
const item = _jsxs(DropdownMenuItemStyled, {
|
|
34
|
+
ref: ref,
|
|
35
|
+
type: "button",
|
|
28
36
|
"$active": isActive,
|
|
29
37
|
"$danger": isDanger,
|
|
30
38
|
onClick: () => {
|
|
39
|
+
if (disabled) return;
|
|
31
40
|
if (closeOnClick) {
|
|
32
41
|
onClickContext?.(false);
|
|
33
42
|
}
|
|
34
43
|
onClick();
|
|
35
44
|
},
|
|
36
|
-
disabled: disabled,
|
|
45
|
+
disabled: disabled && !hasHint,
|
|
46
|
+
"aria-disabled": hasHint || undefined,
|
|
47
|
+
"aria-describedby": hasHint ? hintId : undefined,
|
|
37
48
|
"data-active": isActive || undefined,
|
|
38
49
|
children: [children, isActive && _jsx(DropdownMenuIconStyled, {
|
|
39
50
|
children: _jsx(CheckIcon, {
|
|
@@ -41,4 +52,16 @@ export const DropdownMenuItem = ({
|
|
|
41
52
|
})
|
|
42
53
|
})]
|
|
43
54
|
});
|
|
44
|
-
|
|
55
|
+
if (!hasHint) return item;
|
|
56
|
+
return _jsxs(_Fragment, {
|
|
57
|
+
children: [_jsx(Tooltip, {
|
|
58
|
+
content: hint,
|
|
59
|
+
position: "left-middle",
|
|
60
|
+
children: item
|
|
61
|
+
}), _jsx(DropdownMenuItemHintStyled, {
|
|
62
|
+
id: hintId,
|
|
63
|
+
children: hint
|
|
64
|
+
})]
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { DropdownMenuItemStyled, DropdownMenuIconStyled } from '../../shared/styles';
|
|
1
|
+
export { DropdownMenuItemStyled, DropdownMenuIconStyled, DropdownMenuItemHintStyled } from '../../shared/styles';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { DropdownMenuItemStyled, DropdownMenuIconStyled } from '../../shared/styles';
|
|
1
|
+
export { DropdownMenuItemStyled, DropdownMenuIconStyled, DropdownMenuItemHintStyled } from '../../shared/styles';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PropsWithChildren } from 'react';
|
|
1
|
+
import type { PropsWithChildren, ReactNode } from 'react';
|
|
2
2
|
/** Props of {@link DropdownMenuItem}. */
|
|
3
3
|
export type DropdownMenuItemProps = PropsWithChildren<{
|
|
4
4
|
/** Fires when the item is clicked. */
|
|
@@ -9,6 +9,8 @@ export type DropdownMenuItemProps = PropsWithChildren<{
|
|
|
9
9
|
isDanger?: boolean;
|
|
10
10
|
/** Disable the item. */
|
|
11
11
|
disabled?: boolean;
|
|
12
|
+
/** Why the item is unavailable; shown as a tooltip on hover/focus, only while `disabled`. */
|
|
13
|
+
hint?: ReactNode;
|
|
12
14
|
/** Close the menu after clicking (default `true`). */
|
|
13
15
|
closeOnClick?: boolean;
|
|
14
16
|
}>;
|
|
@@ -21,4 +21,5 @@ export declare const DropdownMenuItemStyled: import("styled-components").StyledC
|
|
|
21
21
|
$active?: boolean;
|
|
22
22
|
$danger?: boolean;
|
|
23
23
|
}, never>;
|
|
24
|
+
export declare const DropdownMenuItemHintStyled: import("styled-components").StyledComponent<"span", any, {}, never>;
|
|
24
25
|
export declare const DropdownMenuIconStyled: import("styled-components").StyledComponent<"span", any, {}, never>;
|
|
@@ -24,14 +24,18 @@ export const Place = styled(Vertical).withConfig({
|
|
|
24
24
|
export const DropdownMenuItemStyled = styled.button.withConfig({
|
|
25
25
|
displayName: "DropdownMenuItemStyled",
|
|
26
26
|
componentId: "sc-hlq2pz-1"
|
|
27
|
-
})(["appearance:none;display:flex;align-items:center;justify-content:space-between;gap:8px;white-space:nowrap;padding:0 12px;min-height:", ";border:none;overflow:hidden;color:", ";background:none;cursor:pointer;&:hover{background:", ";color:", ";}&:disabled{color:", ";cursor:not-allowed;}"], UnitSize.FIELD_HEIGHT, ({
|
|
27
|
+
})(["appearance:none;display:flex;align-items:center;justify-content:space-between;gap:8px;white-space:nowrap;width:100%;padding:0 12px;min-height:", ";border:none;overflow:hidden;color:", ";background:none;cursor:pointer;&:hover:not(:disabled,[aria-disabled='true']){background:", ";color:", ";}&:disabled,&[aria-disabled='true']{color:", ";}&:disabled{cursor:not-allowed;}&[aria-disabled='true']{cursor:default;}"], UnitSize.FIELD_HEIGHT, ({
|
|
28
28
|
$active,
|
|
29
29
|
$danger
|
|
30
30
|
}) => {
|
|
31
31
|
if ($danger) return Color.DANGER;
|
|
32
32
|
return $active ? Color.PRIMARY : Color.MAIN;
|
|
33
33
|
}, Color.SOFT_LIGHT, Color.MAIN, Color.LOCKED);
|
|
34
|
+
export const DropdownMenuItemHintStyled = styled.span.withConfig({
|
|
35
|
+
displayName: "DropdownMenuItemHintStyled",
|
|
36
|
+
componentId: "sc-hlq2pz-2"
|
|
37
|
+
})(["position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap;"]);
|
|
34
38
|
export const DropdownMenuIconStyled = styled.span.withConfig({
|
|
35
39
|
displayName: "DropdownMenuIconStyled",
|
|
36
|
-
componentId: "sc-hlq2pz-
|
|
40
|
+
componentId: "sc-hlq2pz-3"
|
|
37
41
|
})(["margin:-2px 0 0;line-height:0;"]);
|