uikit-react-public 0.30.0 → 0.32.3

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 (57) hide show
  1. package/dist/components/Dialog/BaseDialog.d.ts +1 -1
  2. package/dist/components/Dialog/Dialog.d.ts +1 -1
  3. package/dist/components/Dialog/Dialog.stories.d.ts +1 -1
  4. package/dist/components/Heading/Heading.stories.d.ts +5 -7
  5. package/dist/components/MenuNew/Menu.context.d.ts +9 -0
  6. package/dist/components/MenuNew/Menu.d.ts +2 -1
  7. package/dist/components/MenuNew/MenuHeading.d.ts +1 -1
  8. package/dist/components/MenuNew/MenuItemText.d.ts +2 -1
  9. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +1 -1
  10. package/dist/components/Select/Select.types.d.ts +1 -1
  11. package/dist/components/Sidebar/Sidebar.d.ts +12 -0
  12. package/dist/components/Sidebar/Sidebar.stories.d.ts +38 -0
  13. package/dist/components/Sidebar/__tests__/Sidebar.test.d.ts +1 -0
  14. package/dist/components/Sidebar/index.d.ts +2 -0
  15. package/dist/components/Snackbar/Snackbar.d.ts +9 -5
  16. package/dist/components/Snackbar/Snackbar.stories.d.ts +2 -1
  17. package/dist/components/Snackbar/Snackbars.d.ts +19 -0
  18. package/dist/components/Snackbar/Snackbars.stories.d.ts +52 -0
  19. package/dist/components/Snackbar/__tests__/Snackbars.test.d.ts +1 -0
  20. package/dist/components/Snackbar/index.d.ts +2 -0
  21. package/dist/components/index.d.ts +4 -1
  22. package/dist/index.js +5897 -5358
  23. package/dist/utils/announce.d.ts +2 -1
  24. package/lib/components/Dialog/BaseDialog.tsx +180 -161
  25. package/lib/components/Dialog/Dialog.tsx +15 -11
  26. package/lib/components/Dropdown/Dropdown.stories.tsx +69 -74
  27. package/lib/components/Heading/Documentation.mdx +4 -14
  28. package/lib/components/Heading/Heading.stories.tsx +16 -30
  29. package/lib/components/MenuNew/Menu.context.tsx +18 -0
  30. package/lib/components/MenuNew/Menu.tsx +14 -9
  31. package/lib/components/MenuNew/MenuDivider.tsx +12 -1
  32. package/lib/components/MenuNew/MenuHeading.tsx +6 -0
  33. package/lib/components/MenuNew/MenuItemText.tsx +27 -3
  34. package/lib/components/MenuNew/MenuSection.tsx +11 -0
  35. package/lib/components/MenuNew/PrimaryMenuItem.tsx +71 -2
  36. package/lib/components/Select/Select.types.ts +1 -7
  37. package/lib/components/Select/__tests__/Select.test.tsx +112 -0
  38. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +5 -0
  39. package/lib/components/Select/subcomponents/CustomSelect.tsx +75 -3
  40. package/lib/components/Select/subcomponents/FilterInput.tsx +1 -1
  41. package/lib/components/Sidebar/Sidebar.stories.tsx +94 -0
  42. package/lib/components/Sidebar/Sidebar.tsx +208 -0
  43. package/lib/components/Sidebar/__tests__/Sidebar.test.tsx +96 -0
  44. package/lib/components/Sidebar/__tests__/__snapshots__/Sidebar.test.tsx.snap +272 -0
  45. package/lib/components/Sidebar/index.ts +2 -0
  46. package/lib/components/Snackbar/Snackbar.stories.tsx +11 -0
  47. package/lib/components/Snackbar/Snackbar.tsx +162 -118
  48. package/lib/components/Snackbar/Snackbars.stories.tsx +130 -0
  49. package/lib/components/Snackbar/Snackbars.tsx +377 -0
  50. package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +104 -0
  51. package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
  52. package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +36 -19
  53. package/lib/components/Snackbar/index.ts +6 -0
  54. package/lib/components/index.ts +10 -1
  55. package/lib/utils/__tests__/announce.test.ts +53 -0
  56. package/lib/utils/announce.ts +33 -10
  57. package/package.json +1 -1
@@ -21,45 +21,31 @@ export const Default: Story = {
21
21
  },
22
22
  };
23
23
 
24
- export const LevelXxl: Story = {
24
+ export const Level1: Story = {
25
25
  args: {
26
- level: 'xxl',
27
- children: 'Heading level XXL',
26
+ level: 1,
27
+ children: 'Level 1 Heading',
28
28
  },
29
29
  };
30
30
 
31
- export const LevelXl: Story = {
31
+ export const Level2: Story = {
32
32
  args: {
33
- level: 'xl',
34
- children: 'Heading level XL',
33
+ level: 2,
34
+ children: 'Level 2 Heading',
35
35
  },
36
36
  };
37
37
 
38
- export const LevelLg: Story = {
38
+ export const Level3: Story = {
39
39
  args: {
40
- level: 'lg',
41
- children: 'Heading level LG',
42
- },
43
- };
44
-
45
- export const LevelMd: Story = {
46
- args: {
47
- level: 'md',
48
- children: 'Heading level MD',
49
- },
50
- };
51
-
52
- export const LevelSm: Story = {
53
- args: {
54
- level: 'sm',
55
- children: 'Heading level SM',
40
+ level: 3,
41
+ children: 'Level 3 Heading',
56
42
  },
57
43
  };
58
44
 
59
- export const LevelXs: Story = {
45
+ export const Level4: Story = {
60
46
  args: {
61
- level: 'xs',
62
- children: 'Heading level XS',
47
+ level: 4,
48
+ children: 'Level 4 Heading',
63
49
  },
64
50
  };
65
51
 
@@ -71,12 +57,12 @@ export const AsSpan: Story = {
71
57
  },
72
58
  };
73
59
 
74
- export const NoMargins: Story = {
75
- name: 'No margins',
60
+ export const withMargins: Story = {
61
+ name: 'With margins',
76
62
  args: {
77
- noMargins: true,
63
+ mb: '24',
78
64
  level: 'md',
79
- children: 'Heading without margins',
65
+ children: 'Heading with bottom margin',
80
66
  },
81
67
  decorators: [
82
68
  (Story) => (
@@ -0,0 +1,18 @@
1
+ import { ReactNode, createContext, use } from 'react';
2
+
3
+ interface MenuContextValue {
4
+ collapsed: boolean;
5
+ }
6
+
7
+ const MenuContext = createContext<MenuContextValue>({
8
+ collapsed: false,
9
+ });
10
+
11
+ export const MenuProvider = ({
12
+ collapsed,
13
+ children,
14
+ }: MenuContextValue & { children: ReactNode }) => (
15
+ <MenuContext value={{ collapsed }}>{children}</MenuContext>
16
+ );
17
+
18
+ export const useMenu = () => use(MenuContext);
@@ -13,12 +13,14 @@ import ActionMenuButton, { ActionMenuButtonProps } from './ActionMenuButton';
13
13
  import PrimaryMenuItem, { PrimaryMenuItemProps } from './PrimaryMenuItem';
14
14
  import SecondaryMenuItem, { SecondaryMenuItemProps } from './SecondaryMenuItem';
15
15
  import { useTheme } from '../../theme';
16
+ import { MenuProvider } from './Menu.context';
16
17
 
17
18
  export const NAME = 'ucl-uikit-menu';
18
19
 
19
- export interface MenuProps extends HTMLAttributes<HTMLDivElement> {
20
+ export interface MenuProps extends HTMLAttributes<HTMLElement> {
20
21
  testId?: string;
21
22
  border?: boolean;
23
+ collapsed?: boolean;
22
24
  }
23
25
 
24
26
  export type {
@@ -39,6 +41,7 @@ const Menu = ({
39
41
  className,
40
42
  children,
41
43
  border = false,
44
+ collapsed = false,
42
45
  ...props
43
46
  }: MenuProps) => {
44
47
  const [theme] = useTheme();
@@ -58,14 +61,16 @@ const Menu = ({
58
61
  const style = cx(baseStyle, border && borderStyle, className);
59
62
 
60
63
  return (
61
- <nav
62
- className={style}
63
- data-testid={testId}
64
- role='menu'
65
- {...props}
66
- >
67
- {children}
68
- </nav>
64
+ <MenuProvider collapsed={collapsed}>
65
+ <nav
66
+ className={style}
67
+ data-testid={testId}
68
+ role='menu'
69
+ {...props}
70
+ >
71
+ {children}
72
+ </nav>
73
+ </MenuProvider>
69
74
  );
70
75
  };
71
76
 
@@ -1,6 +1,7 @@
1
1
  import { HTMLAttributes } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
3
  import { useTheme } from '../../theme';
4
+ import { useMenu } from './Menu.context';
4
5
 
5
6
  export const NAME = 'ucl-uikit-menu__divider';
6
7
 
@@ -8,13 +9,23 @@ export interface MenuDividerProps extends HTMLAttributes<HTMLHRElement> {}
8
9
 
9
10
  const MenuDivider = ({ className, ...props }: MenuDividerProps) => {
10
11
  const [theme] = useTheme();
12
+ const { collapsed } = useMenu();
11
13
  const baseStyle = css`
14
+ flex: 0 0 1px;
15
+ min-height: 1px;
16
+ box-sizing: border-box;
17
+ width: 100%;
18
+ align-self: stretch;
12
19
  border: 0;
13
20
  height: 1px;
14
21
  background-color: ${theme.colour.border.subtle};
15
22
  margin: ${theme.margin.m16} 0;
16
23
  `;
17
- const style = cx(NAME, baseStyle, className);
24
+ const collapsedStyle = css`
25
+ width: 32px;
26
+ align-self: center;
27
+ `;
28
+ const style = cx(NAME, baseStyle, collapsed && collapsedStyle, className);
18
29
 
19
30
  return (
20
31
  <hr
@@ -2,6 +2,7 @@ import { HTMLAttributes } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
3
  import Heading from '../Heading';
4
4
  import { useTheme } from '../../theme';
5
+ import { useMenu } from './Menu.context';
5
6
 
6
7
  export const NAME = 'ucl-uikit-menu__heading';
7
8
 
@@ -16,6 +17,7 @@ const MenuHeading = ({
16
17
  ...props
17
18
  }: MenuHeadingProps) => {
18
19
  const [theme] = useTheme();
20
+ const { collapsed } = useMenu();
19
21
 
20
22
  const baseStyle = css`
21
23
  box-sizing: border-box;
@@ -30,6 +32,10 @@ const MenuHeading = ({
30
32
 
31
33
  const style = cx(NAME, baseStyle, className);
32
34
 
35
+ if (collapsed) {
36
+ return null;
37
+ }
38
+
33
39
  const headingStyle = css`
34
40
  color: ${theme.colour.text.secondary};
35
41
  text-transform: uppercase;
@@ -13,12 +13,14 @@ export const NAME = 'ucl-uikit-menu__item-text';
13
13
  export interface MenuItemTextProps {
14
14
  asChild?: boolean;
15
15
  className?: string;
16
+ visuallyHidden?: boolean;
16
17
  children?: ReactNode;
17
18
  }
18
19
 
19
20
  const MenuItemText = ({
20
21
  asChild = false,
21
22
  className,
23
+ visuallyHidden = false,
22
24
  children,
23
25
  }: MenuItemTextProps) => {
24
26
  const asChildResetStyle = css`
@@ -46,18 +48,40 @@ const MenuItemText = ({
46
48
  box-shadow: none;
47
49
  }
48
50
  `;
49
- const style = cx(NAME, className);
51
+ const visuallyHiddenStyle = css`
52
+ position: absolute;
53
+ width: 1px;
54
+ height: 1px;
55
+ padding: 0;
56
+ margin: -1px;
57
+ overflow: hidden;
58
+ clip: rect(0 0 0 0);
59
+ white-space: nowrap;
60
+ border: 0;
61
+ `;
62
+
63
+ const style = cx(NAME, visuallyHidden && visuallyHiddenStyle, className);
50
64
 
51
65
  if (asChild) {
66
+ const visuallyHiddenAsChildStyle = css`
67
+ color: transparent;
68
+ font-size: 0;
69
+ line-height: 0;
70
+ `;
71
+
52
72
  const child = Children.only(children) as ReactElement<{
53
73
  className?: string;
54
74
  }>;
55
75
 
56
76
  if (isValidElement(child)) {
57
77
  return (
58
- <Text className={style}>
78
+ <Text className={cx(NAME, className)}>
59
79
  {cloneElement(child, {
60
- className: cx(asChildResetStyle, child.props.className),
80
+ className: cx(
81
+ asChildResetStyle,
82
+ visuallyHidden && visuallyHiddenAsChildStyle,
83
+ child.props.className
84
+ ),
61
85
  })}
62
86
  </Text>
63
87
  );
@@ -1,5 +1,6 @@
1
1
  import { HTMLAttributes } from 'react';
2
2
  import { css, cx } from '@emotion/css';
3
+ import { useMenu } from './Menu.context';
3
4
 
4
5
  export const NAME = 'ucl-uikit-menu__section';
5
6
 
@@ -13,12 +14,22 @@ const MenuSection = ({
13
14
  children,
14
15
  ...props
15
16
  }: MenuSectionProps) => {
17
+ const { collapsed } = useMenu();
18
+
16
19
  const style = cx(
17
20
  NAME,
18
21
  css`
22
+ width: 100%;
23
+ box-sizing: border-box;
19
24
  margin: 0;
20
25
  padding: 0;
21
26
  `,
27
+ collapsed &&
28
+ css`
29
+ display: flex;
30
+ flex-direction: column;
31
+ align-items: center;
32
+ `,
22
33
  className
23
34
  );
24
35
 
@@ -4,6 +4,7 @@ import { MenuBaseItemProps } from './MenuBaseItem';
4
4
  import MenuBaseItem from './MenuBaseItem';
5
5
  import MenuItemText from './MenuItemText';
6
6
  import { useTheme } from '../../theme';
7
+ import { useMenu } from './Menu.context';
7
8
 
8
9
  export const NAME = 'ucl-uikit-menu__primary-item';
9
10
 
@@ -20,20 +21,84 @@ const PrimaryMenuItem = ({
20
21
  children,
21
22
  asChild,
22
23
  trailingContent,
24
+ icon: iconProp,
25
+ active = false,
23
26
  ...props
24
27
  }: PrimaryMenuItemProps) => {
25
28
  const [theme] = useTheme();
29
+ const { collapsed } = useMenu();
26
30
 
27
31
  const baseStyle = css`
28
32
  color: ${theme.colour.text.default};
29
33
  padding: 0 ${theme.padding.p16};
34
+ white-space: nowrap;
35
+ border-radius: 4px;
36
+
37
+ > svg {
38
+ width: 20px;
39
+ height: 20px;
40
+ flex: 0 0 20px;
41
+ }
30
42
 
31
43
  @media screen and (min-width: ${theme.breakpoints.tablet}px) {
32
44
  padding: 0 ${theme.padding.p16};
33
45
  }
46
+
47
+ &:focus-visible,
48
+ &:focus-within {
49
+ border-radius: 4px;
50
+ box-shadow: none !important;
51
+ z-index: 1;
52
+ }
53
+
54
+ &:focus-visible::after,
55
+ &:focus-within::after {
56
+ content: '';
57
+ position: absolute;
58
+ inset: 0;
59
+ box-sizing: border-box;
60
+ border-radius: 4px;
61
+ border: 2px solid ${theme.colour.border.brand};
62
+ pointer-events: none;
63
+ }
34
64
  `;
35
65
 
36
- const style = cx(NAME, baseStyle, className);
66
+ const collapsedStyle = css`
67
+ width: 32px;
68
+ min-height: 32px;
69
+ height: 32px;
70
+ padding: 0;
71
+ margin-bottom: ${theme.margin.m8};
72
+ justify-content: center;
73
+ gap: 0;
74
+
75
+ &:focus-visible,
76
+ &:focus-within {
77
+ box-shadow: ${theme.boxShadow.focus} !important;
78
+ }
79
+
80
+ &:focus-visible::after,
81
+ &:focus-within::after {
82
+ content: none;
83
+ }
84
+ `;
85
+
86
+ const collapsedActiveStyle = css`
87
+ background-color: ${theme.colour.fill.brand};
88
+ color: ${theme.colour.fill.inverse};
89
+
90
+ &:hover {
91
+ background-color: ${theme.colour.fill.brandHover};
92
+ }
93
+ `;
94
+
95
+ const style = cx(
96
+ NAME,
97
+ baseStyle,
98
+ collapsed && collapsedStyle,
99
+ collapsed && active && collapsedActiveStyle,
100
+ className
101
+ );
37
102
 
38
103
  const textStyle = css``;
39
104
 
@@ -48,16 +113,20 @@ const PrimaryMenuItem = ({
48
113
  className={style}
49
114
  asChild={asChild}
50
115
  iconPosition='left'
116
+ icon={iconProp}
117
+ active={!collapsed && active}
118
+ aria-current={active ? 'page' : props['aria-current']}
51
119
  {...props}
52
120
  >
53
121
  <span className={contentStyle}>
54
122
  <MenuItemText
55
123
  asChild={asChild}
56
124
  className={textStyle}
125
+ visuallyHidden={collapsed}
57
126
  >
58
127
  {children}
59
128
  </MenuItemText>
60
- {trailingContent}
129
+ {!collapsed && trailingContent}
61
130
  </span>
62
131
  </MenuBaseItem>
63
132
  );
@@ -28,13 +28,7 @@ export type OptionData<T> = {
28
28
  */
29
29
  export type FilterInputProps = Omit<
30
30
  React.InputHTMLAttributes<HTMLInputElement>,
31
- | 'value'
32
- | 'onChange'
33
- | 'disabled'
34
- | 'ref'
35
- | 'role'
36
- | 'aria-autocomplete'
37
- | 'aria-label'
31
+ 'value' | 'onChange' | 'disabled' | 'ref' | 'role' | 'aria-autocomplete'
38
32
  >;
39
33
 
40
34
  export type SelectDropdownWidth = 'content' | 'match-select';
@@ -518,6 +518,76 @@ describe('Select', () => {
518
518
  );
519
519
  });
520
520
 
521
+ test('selectionBehaviour=commit returns focus to the combobox on Enter (filterable)', async () => {
522
+ const user = userEvent.setup();
523
+
524
+ const ControlledSelect = () => {
525
+ const [value, setValue] = useState('');
526
+ return (
527
+ <Select
528
+ filterable
529
+ options={defaultOptions}
530
+ value={value}
531
+ selectionBehaviour='commit'
532
+ onValueChange={(next) => setValue(next as string)}
533
+ />
534
+ );
535
+ };
536
+
537
+ const result = render(
538
+ <ThemeContextProvider>
539
+ <ControlledSelect />
540
+ </ThemeContextProvider>
541
+ );
542
+
543
+ await user.click(result.getByTestId('ucl-uikit-select'));
544
+ // Filter input has focus while the panel is open
545
+ expect(result.getByRole('searchbox')).toHaveFocus();
546
+
547
+ await user.keyboard('{ArrowDown}');
548
+ await user.keyboard('{Enter}');
549
+
550
+ // Panel (and its filter input) has closed, and focus is back on the
551
+ // combobox rather than lost to the document body.
552
+ expect(
553
+ result.queryByTestId('ucl-uikit-select__panel')
554
+ ).not.toBeInTheDocument();
555
+ expect(result.getByRole('combobox')).toHaveFocus();
556
+ });
557
+
558
+ test('selectionBehaviour=commit announces the committed option via a live region', async () => {
559
+ const user = userEvent.setup();
560
+
561
+ const ControlledSelect = () => {
562
+ const [value, setValue] = useState('');
563
+ return (
564
+ <Select
565
+ filterable
566
+ options={defaultOptions}
567
+ value={value}
568
+ selectionBehaviour='commit'
569
+ onValueChange={(next) => setValue(next as string)}
570
+ />
571
+ );
572
+ };
573
+
574
+ const result = render(
575
+ <ThemeContextProvider>
576
+ <ControlledSelect />
577
+ </ThemeContextProvider>
578
+ );
579
+
580
+ const status = result.getByRole('status');
581
+ expect(status).toHaveTextContent('');
582
+
583
+ await user.click(result.getByTestId('ucl-uikit-select'));
584
+ await user.keyboard('{ArrowDown}');
585
+ await user.keyboard('{ArrowDown}');
586
+ await user.keyboard('{Enter}');
587
+
588
+ expect(status).toHaveTextContent('Option 2 selected');
589
+ });
590
+
521
591
  test('keyboard navigation does not get stuck on duplicate values', async () => {
522
592
  const user = userEvent.setup();
523
593
  const options = [
@@ -969,6 +1039,48 @@ describe('Select', () => {
969
1039
  ).not.toBeInTheDocument();
970
1040
  });
971
1041
 
1042
+ test('aria-activedescendant is absent immediately after opening with a preselected value', async () => {
1043
+ const user = userEvent.setup();
1044
+ const result = render(
1045
+ <ThemeContextProvider>
1046
+ <Select
1047
+ options={defaultOptions}
1048
+ value='2'
1049
+ onValueChange={() => {}}
1050
+ />
1051
+ </ThemeContextProvider>
1052
+ );
1053
+
1054
+ const combobox = result.getByRole('combobox');
1055
+ await user.click(combobox);
1056
+
1057
+ expect(combobox).not.toHaveAttribute('aria-activedescendant');
1058
+
1059
+ await user.keyboard('{ArrowDown}');
1060
+ expect(combobox).toHaveAttribute('aria-activedescendant');
1061
+ });
1062
+
1063
+ test('filterable: aria-activedescendant is absent immediately after opening with a preselected value', async () => {
1064
+ const user = userEvent.setup();
1065
+ const result = render(
1066
+ <ThemeContextProvider>
1067
+ <Select
1068
+ filterable
1069
+ options={defaultOptions}
1070
+ value='2'
1071
+ onValueChange={() => {}}
1072
+ />
1073
+ </ThemeContextProvider>
1074
+ );
1075
+
1076
+ await user.click(result.getByRole('combobox'));
1077
+ const filterInput = result.getByRole('searchbox');
1078
+ expect(filterInput).not.toHaveAttribute('aria-activedescendant');
1079
+
1080
+ await user.keyboard('{ArrowDown}');
1081
+ expect(filterInput).toHaveAttribute('aria-activedescendant');
1082
+ });
1083
+
972
1084
  test('disabled clearable select does not show clear button', () => {
973
1085
  const result = render(
974
1086
  <ThemeContextProvider>
@@ -35,5 +35,10 @@ exports[`Select > Snapshot: default 1`] = `
35
35
  />
36
36
  </svg>
37
37
  </div>
38
+ <span
39
+ aria-live="polite"
40
+ class="css-fj1rnv"
41
+ role="status"
42
+ />
38
43
  </div>
39
44
  `;