@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.
Files changed (49) hide show
  1. package/Chip/Chip.d.ts +3 -0
  2. package/Chip/Chip.js +30 -7
  3. package/Chip/Chip.types.d.ts +4 -2
  4. package/Chip/__tests__/Chip.test.d.ts +1 -0
  5. package/Chip/__tests__/Chip.test.js +101 -0
  6. package/Chip/polyglot/index.d.ts +3 -0
  7. package/Chip/polyglot/index.js +33 -0
  8. package/Chip/polyglot/messages_ar.d.ts +3 -0
  9. package/Chip/polyglot/messages_ar.js +3 -0
  10. package/Chip/polyglot/messages_bn.d.ts +3 -0
  11. package/Chip/polyglot/messages_bn.js +3 -0
  12. package/Chip/polyglot/messages_de.d.ts +3 -0
  13. package/Chip/polyglot/messages_de.js +3 -0
  14. package/Chip/polyglot/messages_en.d.ts +3 -0
  15. package/Chip/polyglot/messages_en.js +3 -0
  16. package/Chip/polyglot/messages_es.d.ts +3 -0
  17. package/Chip/polyglot/messages_es.js +3 -0
  18. package/Chip/polyglot/messages_fr.d.ts +3 -0
  19. package/Chip/polyglot/messages_fr.js +3 -0
  20. package/Chip/polyglot/messages_he.d.ts +3 -0
  21. package/Chip/polyglot/messages_he.js +3 -0
  22. package/Chip/polyglot/messages_hi.d.ts +3 -0
  23. package/Chip/polyglot/messages_hi.js +3 -0
  24. package/Chip/polyglot/messages_ja.d.ts +3 -0
  25. package/Chip/polyglot/messages_ja.js +3 -0
  26. package/Chip/polyglot/messages_ko.d.ts +3 -0
  27. package/Chip/polyglot/messages_ko.js +3 -0
  28. package/Chip/polyglot/messages_pt.d.ts +3 -0
  29. package/Chip/polyglot/messages_pt.js +3 -0
  30. package/Chip/polyglot/messages_ru.d.ts +3 -0
  31. package/Chip/polyglot/messages_ru.js +3 -0
  32. package/Chip/polyglot/messages_th.d.ts +3 -0
  33. package/Chip/polyglot/messages_th.js +3 -0
  34. package/Chip/polyglot/messages_zh.d.ts +3 -0
  35. package/Chip/polyglot/messages_zh.js +3 -0
  36. package/Chip/polyglot/types.d.ts +4 -0
  37. package/Chip/polyglot/types.js +1 -0
  38. package/Chip/styles.d.ts +3 -1
  39. package/Chip/styles.js +22 -12
  40. package/DropdownMenu/__tests__/DropdownMenuItem.test.d.ts +1 -0
  41. package/DropdownMenu/__tests__/DropdownMenuItem.test.js +109 -0
  42. package/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.d.ts +13 -4
  43. package/DropdownMenu/components/DropdownMenuItem/DropdownMenuItem.js +32 -9
  44. package/DropdownMenu/components/DropdownMenuItem/styles.d.ts +1 -1
  45. package/DropdownMenu/components/DropdownMenuItem/styles.js +1 -1
  46. package/DropdownMenu/components/DropdownMenuItem/types.d.ts +3 -1
  47. package/DropdownMenu/shared/styles.d.ts +1 -0
  48. package/DropdownMenu/shared/styles.js +6 -2
  49. 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 { ChipCloseIcon, ChipLabel, ChipSlotIcon, ChipStyled } from './styles';
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
- "$readOnly": $readOnly,
47
+ "$muted": $readOnly || isButton && disabled,
48
+ "$interactive": isButton && !disabled,
36
49
  "$maxWidth": $maxWidth,
37
- as: 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(ChipCloseIcon, {
45
- size: "small",
46
- onClick: $onClose,
47
- forwardedAs: "span"
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",
@@ -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
- /** Render as a different element/tag. */
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,3 @@
1
+ export declare const useLang: () => (props: {
2
+ message: "DUMB.CHIP.REMOVE";
3
+ } & import("../../polyglot").NoParams) => string;
@@ -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,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'إزالة'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'সরান'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Entfernen'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Remove'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Eliminar'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Supprimer'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'הסרה'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'हटाएँ'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': '削除'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': '제거'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Remover'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'Удалить'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': 'ลบ'
3
+ };
@@ -0,0 +1,3 @@
1
+ import { type ChipMessages } from './types';
2
+ import type { LangMessages } from '../../polyglot';
3
+ export declare const messages: LangMessages<ChipMessages>;
@@ -0,0 +1,3 @@
1
+ export const messages = {
2
+ 'DUMB.CHIP.REMOVE': '移除'
3
+ };
@@ -0,0 +1,4 @@
1
+ import type { NoParams } from '../../polyglot';
2
+ export type ChipMessages = {
3
+ 'DUMB.CHIP.REMOVE': NoParams;
4
+ };
@@ -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
- $readOnly?: boolean;
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
- $readOnly
16
- }) => $readOnly ? 'default' : 'pointer', ({
21
+ $interactive
22
+ }) => $interactive ? 'pointer' : 'default', ({
17
23
  $variant,
18
24
  $selected,
19
- $readOnly
25
+ $muted
20
26
  }) => {
21
- if ($readOnly) {
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
- $readOnly
34
+ $muted
29
35
  }) => {
30
- if ($readOnly) {
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
- $readOnly
38
- }) => !$selected && !$readOnly && `
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-3"
63
- })(["flex-shrink:0;transition:transform 0.2s;&:hover{transform:scale(1.3);}"]);
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. Must be rendered inside a {@link DropdownMenu}.
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: FC<DropdownMenuItemProps>;
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. Must be rendered inside a {@link DropdownMenu}.
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
- return _jsxs(DropdownMenuItemStyled, {
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-2"
40
+ componentId: "sc-hlq2pz-3"
37
41
  })(["margin:-2px 0 0;line-height:0;"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/dumb-components",
3
- "version": "2.5.1",
3
+ "version": "2.7.0",
4
4
  "description": "React components to build Pushwoosh products",
5
5
  "main": "index.js",
6
6
  "module": "index.js",