uikit-react-public 0.11.13 → 0.11.15

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 (40) hide show
  1. package/dist/components/Button/Button.d.ts +21 -3
  2. package/dist/components/Button/Button.stories.d.ts +1 -1
  3. package/dist/components/Button/buttonPrimaryStyle.d.ts +2 -2
  4. package/dist/components/Button/buttonSecondaryStyle.d.ts +2 -2
  5. package/dist/components/Button/buttonTertiaryStyle.d.ts +2 -2
  6. package/dist/components/Button/index.d.ts +1 -1
  7. package/dist/components/Select/Select.d.ts +3 -8
  8. package/dist/components/Select/Select.stories.d.ts +50 -2
  9. package/dist/components/Select/Select.types.d.ts +122 -0
  10. package/dist/components/Select/index.d.ts +1 -1
  11. package/dist/components/Select/subcomponents/CustomOption.d.ts +3 -0
  12. package/dist/components/Select/subcomponents/CustomSelect.d.ts +3 -0
  13. package/dist/components/Select/subcomponents/NativeSelect.d.ts +3 -0
  14. package/dist/components/Select/subcomponents/Panel.d.ts +3 -0
  15. package/dist/components/Select/subcomponents/VisibleField.d.ts +9 -0
  16. package/dist/components/Select/subcomponents/index.d.ts +5 -0
  17. package/dist/index.js +2875 -2549
  18. package/lib/Welcome.mdx +7 -7
  19. package/lib/components/Button/Button.tsx +36 -7
  20. package/lib/components/Button/buttonPrimaryStyle.ts +4 -4
  21. package/lib/components/Button/buttonSecondaryStyle.ts +4 -4
  22. package/lib/components/Button/buttonTertiaryStyle.ts +3 -3
  23. package/lib/components/Button/index.ts +1 -1
  24. package/lib/components/Icon/svgs/AvatarSvg.tsx +2 -2
  25. package/lib/components/Icon/svgs/ChevronDownSvg.tsx +2 -5
  26. package/lib/components/Select/Select.stories.tsx +192 -13
  27. package/lib/components/Select/Select.tsx +33 -76
  28. package/lib/components/Select/Select.types.ts +146 -0
  29. package/lib/components/Select/__tests__/Select.test.tsx +99 -20
  30. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +33 -37
  31. package/lib/components/Select/index.ts +1 -1
  32. package/lib/components/Select/subcomponents/CustomOption.tsx +74 -0
  33. package/lib/components/Select/subcomponents/CustomSelect.tsx +211 -0
  34. package/lib/components/Select/subcomponents/NativeSelect.tsx +109 -0
  35. package/lib/components/Select/subcomponents/Panel.tsx +46 -0
  36. package/lib/components/Select/subcomponents/VisibleField.tsx +69 -0
  37. package/lib/components/Select/subcomponents/index.tsx +5 -0
  38. package/package.json +1 -1
  39. package/dist/components/Button/Button.types.d.ts +0 -26
  40. package/lib/components/Button/Button.types.ts +0 -46
@@ -1,40 +1,119 @@
1
1
  import { describe, expect, test } from 'vitest';
2
2
  import { render } from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
3
4
  import Select from '../Select';
4
5
  import { ThemeContextProvider } from '../../../theme/useTheme';
5
6
 
7
+ const defaultOptions = [
8
+ { text: 'Option 1', value: '1' },
9
+ { text: 'Option 2', value: '2' },
10
+ { text: 'Option 3', value: '3' },
11
+ ];
12
+
6
13
  describe('Select', () => {
7
14
  // Snapshot tests
8
15
 
9
- test('snapshot: no props', () => {
16
+ test('Snapshot: default', () => {
17
+ const renderResult = render(
18
+ <ThemeContextProvider>
19
+ <Select
20
+ options={defaultOptions}
21
+ value=''
22
+ onChange={() => {}}
23
+ />
24
+ </ThemeContextProvider>
25
+ );
26
+ expect(renderResult.container.firstChild).toMatchSnapshot();
27
+ });
28
+
29
+ // Interation tests
30
+ test('Can be found by default testId', () => {
31
+ const defaultTestId = 'ucl-uikit-select';
32
+
33
+ const renderResult = render(
34
+ <ThemeContextProvider>
35
+ <Select
36
+ options={defaultOptions}
37
+ value=''
38
+ onChange={() => {}}
39
+ />
40
+ </ThemeContextProvider>
41
+ );
42
+ const select = renderResult.getByTestId(defaultTestId);
43
+ expect(select).toBeDefined();
44
+ });
45
+
46
+ test('Returns a native select when native prop is true', () => {
47
+ const renderResult = render(
48
+ <ThemeContextProvider>
49
+ <Select
50
+ native
51
+ options={defaultOptions}
52
+ value=''
53
+ onChange={() => {}}
54
+ />
55
+ </ThemeContextProvider>
56
+ );
57
+ const select = renderResult.getByTestId('ucl-uikit-select--native');
58
+ expect(select).toBeDefined();
59
+ expect(select.tagName).toBe('SELECT');
60
+ });
61
+
62
+ test('Opens the panel when clicked', async () => {
63
+ const user = userEvent.setup();
64
+ const renderResult = render(
65
+ <ThemeContextProvider>
66
+ <Select
67
+ options={defaultOptions}
68
+ value=''
69
+ onChange={() => {}}
70
+ />
71
+ </ThemeContextProvider>
72
+ );
73
+ const select = renderResult.getByTestId('ucl-uikit-select');
74
+ await user.click(select);
75
+ // Panel should open
76
+ const panel = renderResult.getByTestId('ucl-uikit-select__panel');
77
+ expect(panel).toBeInTheDocument();
78
+ // Options should be present
79
+ const options = renderResult.getAllByTestId('ucl-uikit-select__option');
80
+ expect(options.length).toBe(defaultOptions.length);
81
+ expect(options[0].textContent).toBe(defaultOptions[0].text);
82
+ expect(options[1].textContent).toBe(defaultOptions[1].text);
83
+ expect(options[2].textContent).toBe(defaultOptions[2].text);
84
+ });
85
+
86
+ test('Cannot be used when disabled', async () => {
87
+ const user = userEvent.setup();
10
88
  const renderResult = render(
11
89
  <ThemeContextProvider>
12
- <Select>
13
- <option>Option 1</option>
14
- <option>Option 2</option>
15
- <option>Option 3</option>
16
- <option>Option 4</option>
17
- </Select>
90
+ <Select
91
+ disabled
92
+ options={defaultOptions}
93
+ value=''
94
+ onChange={() => {}}
95
+ />
18
96
  </ThemeContextProvider>
19
97
  );
20
- expect(
21
- renderResult.container.firstChild
22
- ).toMatchSnapshot();
98
+ const select = renderResult.getByTestId('ucl-uikit-select');
99
+ await user.click(select);
100
+ // Panel should not open
101
+ const panel = renderResult.queryByTestId('ucl-uikit-select__panel');
102
+ expect(panel).not.toBeInTheDocument();
23
103
  });
24
104
 
25
- test('snapshot: disabled', () => {
105
+ test('Cannot be used when native and disabled', async () => {
26
106
  const renderResult = render(
27
107
  <ThemeContextProvider>
28
- <Select disabled>
29
- <option>Option 1</option>
30
- <option>Option 2</option>
31
- <option>Option 3</option>
32
- <option>Option 4</option>
33
- </Select>
108
+ <Select
109
+ native
110
+ options={defaultOptions}
111
+ value='1'
112
+ onChange={() => {}}
113
+ />
34
114
  </ThemeContextProvider>
35
115
  );
36
- expect(
37
- renderResult.container.firstChild
38
- ).toMatchSnapshot();
116
+ const select = renderResult.getByTestId('ucl-uikit-select--native');
117
+ expect(select).toHaveProperty('disabled');
39
118
  });
40
119
  });
@@ -1,42 +1,38 @@
1
1
  // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
2
 
3
- exports[`Select > snapshot: disabled 1`] = `
4
- <select
5
- class="ucl-uikit-select css-1k92o15"
3
+ exports[`Select > Snapshot: default 1`] = `
4
+ <div
5
+ aria-expanded="false"
6
+ aria-haspopup="listbox"
7
+ class="ucl-uikit-select css-1ur0y0n"
6
8
  data-testid="ucl-uikit-select"
7
- disabled=""
9
+ role="combobox"
10
+ tabindex="0"
8
11
  >
9
- <option>
10
- Option 1
11
- </option>
12
- <option>
13
- Option 2
14
- </option>
15
- <option>
16
- Option 3
17
- </option>
18
- <option>
19
- Option 4
20
- </option>
21
- </select>
22
- `;
23
-
24
- exports[`Select > snapshot: no props 1`] = `
25
- <select
26
- class="ucl-uikit-select css-mbwdbz"
27
- data-testid="ucl-uikit-select"
28
- >
29
- <option>
30
- Option 1
31
- </option>
32
- <option>
33
- Option 2
34
- </option>
35
- <option>
36
- Option 3
37
- </option>
38
- <option>
39
- Option 4
40
- </option>
41
- </select>
12
+ <div
13
+ class="ucl-uikit-select__visible-field css-4o4quu"
14
+ data-testid="ucl-uikit-select__visible-field"
15
+ >
16
+ <span
17
+ class="css-1oagzrk"
18
+ />
19
+ <svg
20
+ class="ucl-uikit-icon css-1ieo112"
21
+ data-testid="ucl-uikit-icon"
22
+ fill="none"
23
+ height="24"
24
+ stroke="currentColor"
25
+ stroke-linecap="round"
26
+ stroke-linejoin="round"
27
+ stroke-width="2"
28
+ viewBox="0 0 24 24"
29
+ width="24"
30
+ xmlns="http://www.w3.org/2000/svg"
31
+ >
32
+ <polyline
33
+ points="6 9 12 15 18 9"
34
+ />
35
+ </svg>
36
+ </div>
37
+ </div>
42
38
  `;
@@ -1,2 +1,2 @@
1
1
  export { default } from './Select';
2
- export type { SelectProps } from './Select';
2
+ export type { SelectProps, OptionData, SelectEvent } from './Select.types';
@@ -0,0 +1,74 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme } from '../../../theme';
4
+ import { CustomOptionProps } from '../Select.types';
5
+
6
+ const NAME = 'ucl-uikit-select__option';
7
+
8
+ const CustomOption = ({
9
+ value,
10
+ isSelected = false,
11
+ onSelect,
12
+ testId = NAME,
13
+ className,
14
+ children,
15
+ ...props
16
+ }: CustomOptionProps) => {
17
+ const [theme] = useTheme();
18
+ const internalRef = useRef<HTMLDivElement>(null);
19
+
20
+ // Scroll into view when selected & the Panel opens
21
+ useEffect(() => {
22
+ if (isSelected && internalRef.current) {
23
+ internalRef.current.scrollIntoView({
24
+ behavior: 'auto',
25
+ block: 'nearest',
26
+ });
27
+ }
28
+ }, [isSelected]);
29
+
30
+ const handleClick = (event: React.MouseEvent<HTMLDivElement>) => {
31
+ onSelect(event, value);
32
+ event.stopPropagation(); // Otherwise the panel will open again instantaneously
33
+ };
34
+
35
+ const baseStyle = css`
36
+ display: flex;
37
+ align-items: center;
38
+ justify-content: left;
39
+ gap: 16px;
40
+ width: 100%;
41
+ min-height: 40px;
42
+ box-sizing: border-box;
43
+ padding: 8px 16px;
44
+ font-family: ${theme.font.family.primary};
45
+ font-size: ${theme.font.size.f16};
46
+ background-color: ${theme.color.neutral.white};
47
+ overflow: hidden;
48
+ white-space: nowrap;
49
+
50
+ &:hover {
51
+ background-color: ${theme.color.neutral.grey5};
52
+ }
53
+ `;
54
+
55
+ const selectedStyle = css`
56
+ background-color: ${theme.color.neutral.grey5};
57
+ `;
58
+
59
+ const style = cx(NAME, baseStyle, isSelected && selectedStyle, className);
60
+
61
+ return (
62
+ <div
63
+ onClick={handleClick}
64
+ className={style}
65
+ data-testid={testId}
66
+ ref={internalRef}
67
+ {...props}
68
+ >
69
+ {children}
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export default CustomOption;
@@ -0,0 +1,211 @@
1
+ import { useState, useRef, useEffect } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { VisibleField, Panel, CustomOption } from '.';
4
+ import { useTheme } from '../../../theme';
5
+ import type { CustomSelectProps } from '../Select.types';
6
+
7
+ const NAME = 'ucl-uikit-select';
8
+
9
+ const CustomSelect = ({
10
+ value,
11
+ options = [],
12
+ onChange,
13
+ disabled,
14
+ placeholder,
15
+ width = 325,
16
+ testId = NAME,
17
+ className,
18
+ ref,
19
+ ...props
20
+ }: CustomSelectProps) => {
21
+ const internalRef = useRef<HTMLDivElement>(null);
22
+ const effectiveRef = ref || internalRef;
23
+
24
+ const [theme] = useTheme();
25
+ const [isOpen, setIsOpen] = useState(false);
26
+
27
+ useEffect(() => {
28
+ const handleClickOutside = (event: MouseEvent) => {
29
+ if (
30
+ effectiveRef.current &&
31
+ !effectiveRef.current.contains(event.target as Node)
32
+ ) {
33
+ setIsOpen(false);
34
+ }
35
+ };
36
+ document.addEventListener('mousedown', handleClickOutside);
37
+ return () => {
38
+ document.removeEventListener('mousedown', handleClickOutside);
39
+ };
40
+ }, [effectiveRef]);
41
+
42
+ useEffect(() => {
43
+ // Close the dropdown if it becomes disabled
44
+ if (disabled && isOpen) setIsOpen(false);
45
+ }, [disabled, isOpen]);
46
+
47
+ const togglePanel = () => {
48
+ if (!disabled) setIsOpen((prev) => !prev);
49
+ };
50
+
51
+ const openPanel = () => {
52
+ if (!disabled) setIsOpen(true);
53
+ };
54
+
55
+ const closePanel = () => {
56
+ if (!disabled) setIsOpen(false);
57
+ };
58
+
59
+ // Used by <CustomOption> and passed as prop
60
+ const handleSelect = (event: React.MouseEvent, optionValue: string) => {
61
+ if (onChange) onChange(event, optionValue);
62
+ closePanel();
63
+ };
64
+
65
+ const selectedOption = options.find((option) => option.value === value);
66
+
67
+ const handleKeyDown = (event: React.KeyboardEvent) => {
68
+ // Prevent scrolling the page when the select is open
69
+ if (
70
+ event.key === 'ArrowUp' ||
71
+ event.key === 'ArrowDown' ||
72
+ event.key === 'ArrowLeft' ||
73
+ event.key === 'ArrowRight'
74
+ )
75
+ event.preventDefault();
76
+
77
+ if (disabled) return;
78
+
79
+ if (event.key === 'Enter') {
80
+ togglePanel();
81
+ return;
82
+ }
83
+ if (!isOpen && event.code === 'Space') {
84
+ openPanel();
85
+ return;
86
+ }
87
+ if (isOpen && event.key === 'Escape') {
88
+ closePanel();
89
+ return;
90
+ }
91
+ // Select the previous option
92
+ if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
93
+ if (!isOpen) {
94
+ openPanel();
95
+ return;
96
+ }
97
+ if (!value) {
98
+ // Initialise at the last option if no value provided
99
+ onChange(event, options[options.length - 1].value);
100
+ return;
101
+ }
102
+ const currentOptionIndex = options.findIndex(
103
+ (option) => option.value === value
104
+ );
105
+ const previousOptionIndex =
106
+ (currentOptionIndex - 1 + options.length) % options.length;
107
+ onChange(event, options[previousOptionIndex].value);
108
+ return;
109
+ }
110
+ // Select the next option
111
+ if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
112
+ if (!isOpen) {
113
+ openPanel();
114
+ return;
115
+ }
116
+ if (!value) {
117
+ // Initialise at the first option if no value provided
118
+ onChange(event, options[0].value);
119
+ return;
120
+ }
121
+ const currentOptionIndex = options.findIndex(
122
+ (option) => option.value === value
123
+ );
124
+ const nextOptionIndex = (currentOptionIndex + 1) % options.length;
125
+ onChange(event, options[nextOptionIndex].value);
126
+ return;
127
+ }
128
+ };
129
+
130
+ const baseStyle = css`
131
+ display: flex;
132
+ align-items: center;
133
+ justify-content: space-between;
134
+ position: relative;
135
+ width: ${width}px;
136
+ min-width: 80px;
137
+ max-width: 624px;
138
+ height: 48px;
139
+ box-sizing: border-box;
140
+ padding: 0 16px;
141
+ background-color: ${theme.color.neutral.white};
142
+ color: ${theme.color.text.primary};
143
+ font-family: ${theme.font.family.primary};
144
+ font-size: ${theme.font.size.f16};
145
+ border: ${theme.border.b1} solid ${theme.color.neutral.grey60};
146
+ cursor: pointer;
147
+ user-select: none;
148
+
149
+ &:hover {
150
+ ${!isOpen && `background-color: ${theme.color.neutral.grey5};`}
151
+ }
152
+
153
+ &:focus-visible {
154
+ outline: none;
155
+ box-shadow: ${theme.boxShadow.focus};
156
+ }
157
+ `;
158
+
159
+ const disabledStyle = css`
160
+ color: ${theme.color.text.disabled};
161
+ border: ${theme.border.b1} solid ${theme.color.neutral.grey20};
162
+ cursor: not-allowed;
163
+
164
+ &:hover {
165
+ background-color: ${theme.color.neutral.white};
166
+ }
167
+ `;
168
+
169
+ const style = cx(NAME, baseStyle, disabled && disabledStyle, className);
170
+
171
+ return (
172
+ <div
173
+ onClick={togglePanel}
174
+ onKeyDown={handleKeyDown}
175
+ tabIndex={disabled ? -1 : 0}
176
+ className={style}
177
+ data-testid={testId}
178
+ ref={effectiveRef}
179
+ role='combobox'
180
+ aria-haspopup='listbox'
181
+ aria-expanded={isOpen}
182
+ {...props}
183
+ >
184
+ <VisibleField
185
+ isOpen={isOpen}
186
+ selectedOption={selectedOption}
187
+ placeholder={placeholder}
188
+ disabled={disabled}
189
+ />
190
+ {isOpen && (
191
+ <Panel role='listbox'>
192
+ {options.map((option) => (
193
+ <CustomOption
194
+ key={option.value}
195
+ value={option.value}
196
+ isSelected={value === option.value}
197
+ onSelect={handleSelect}
198
+ role='option'
199
+ aria-selected={value === option.value}
200
+ >
201
+ {option.icon}
202
+ {option.text}
203
+ </CustomOption>
204
+ ))}
205
+ </Panel>
206
+ )}
207
+ </div>
208
+ );
209
+ };
210
+
211
+ export default CustomSelect;
@@ -0,0 +1,109 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import { useTheme } from '../../../theme';
3
+ import { dataUri as chevronDownSvgDataUri } from '../../Icon/svgs/ChevronDownSvg';
4
+ import { NativeSelectProps } from '../Select.types';
5
+
6
+ const NAME = 'ucl-uikit-select--native';
7
+
8
+ const NativeSelect = ({
9
+ options,
10
+ width = 325,
11
+ disabled,
12
+ placeholder,
13
+ testId = NAME,
14
+ className,
15
+ ...props
16
+ }: NativeSelectProps) => {
17
+ const [theme] = useTheme();
18
+
19
+ const chevronColour = disabled
20
+ ? theme.color.neutral.grey20
21
+ : theme.color.interaction.blue70;
22
+ const chevronDownSvg = chevronDownSvgDataUri(chevronColour);
23
+
24
+ const baseStyle = css`
25
+ width: ${width}px;
26
+ padding: 0 ${theme.padding.p40} 0 ${theme.padding.p16};
27
+ height: ${theme.padding.p48};
28
+ line-height: ${theme.font.lineHeight.h150};
29
+ font-family: ${theme.font.family.primary};
30
+ font-size: ${theme.font.size.f16};
31
+ background-color: ${theme.color.neutral.white};
32
+ border: ${theme.border.b1} solid ${theme.color.neutral.grey60};
33
+ color: ${theme.color.text.primary};
34
+ appearance: none;
35
+ -webkit-appearance: none;
36
+ -moz-appearance: none;
37
+ outline: none;
38
+ cursor: pointer;
39
+
40
+ background-image: url(${chevronDownSvg});
41
+ background-size: 24px 24px;
42
+ background-position: right 16px center;
43
+ background-repeat: no-repeat;
44
+ `;
45
+
46
+ const hoverStyle = css`
47
+ &:hover {
48
+ border-color: ${theme.color.neutral.grey60};
49
+ background-color: ${theme.color.neutral.grey5};
50
+ }
51
+ `;
52
+
53
+ const focusStyle = css`
54
+ &:focus-visible {
55
+ box-shadow: ${theme.boxShadow.focus};
56
+ }
57
+ `;
58
+
59
+ const disabledStyle = css`
60
+ color: ${theme.color.text.disabled};
61
+ border: ${theme.border.b1} solid ${theme.color.neutral.grey20};
62
+ opacity: 1; // Override user-agent default
63
+ cursor: not-allowed;
64
+
65
+ &:hover {
66
+ background-color: ${theme.color.neutral.white};
67
+ }
68
+ `;
69
+
70
+ const style = cx(
71
+ NAME,
72
+ baseStyle,
73
+ !disabled && hoverStyle,
74
+ !disabled && focusStyle,
75
+ disabled && disabledStyle,
76
+ className
77
+ );
78
+
79
+ return (
80
+ <select
81
+ className={style}
82
+ data-testid={testId}
83
+ disabled={disabled}
84
+ {...props}
85
+ >
86
+ {placeholder && (
87
+ <option
88
+ value=''
89
+ disabled
90
+ selected
91
+ >
92
+ {placeholder}
93
+ </option>
94
+ )}
95
+ {options
96
+ ? options.map((option) => (
97
+ <option
98
+ key={option.value}
99
+ value={option.value}
100
+ >
101
+ {option.text}
102
+ </option>
103
+ ))
104
+ : null}
105
+ </select>
106
+ );
107
+ };
108
+
109
+ export default NativeSelect;
@@ -0,0 +1,46 @@
1
+ import { css, cx } from '@emotion/css';
2
+ import { useTheme } from '../../../theme';
3
+
4
+ const NAME = 'ucl-uikit-select__panel';
5
+
6
+ type PanelProps = React.ComponentPropsWithoutRef<'div'>;
7
+
8
+ const Panel = (props: PanelProps) => {
9
+ const [theme] = useTheme();
10
+
11
+ const handleClick = (event: React.MouseEvent) => {
12
+ // Missed clicks on the panel should not close the dropdown
13
+ event.stopPropagation();
14
+ };
15
+
16
+ const baseStyle = css`
17
+ display: flex;
18
+ flex-direction: column;
19
+ position: absolute;
20
+ top: 46px;
21
+ left: -1px; // -1px to align with the border of the field
22
+
23
+ z-index: 10; // Required: panel must be 'above' subsquent DOM elements
24
+ width: 100%;
25
+ max-height: 200px;
26
+ overflow-y: auto;
27
+ overflow-x: hidden;
28
+ box-sizing: content-box;
29
+ padding: 16px 0 24px 0;
30
+ border: ${theme.border.b1} solid ${theme.color.neutral.grey20};
31
+ background-color: ${theme.color.neutral.white};
32
+ `;
33
+
34
+ const style = cx(NAME, baseStyle);
35
+
36
+ return (
37
+ <div
38
+ className={style}
39
+ data-testid={NAME}
40
+ onClick={handleClick}
41
+ {...props}
42
+ />
43
+ );
44
+ };
45
+
46
+ export default Panel;