uikit-react-public 0.27.2 → 0.28.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.
@@ -116,11 +116,7 @@ const BaseCheckbox = ({
116
116
  grid-area: 1 / 1;
117
117
  line-height: 0;
118
118
  pointer-events: none;
119
-
120
- input:focus-visible + & {
121
- outline: 2px solid currentColor;
122
- outline-offset: 2px;
123
- }
119
+ outline: none;
124
120
  `;
125
121
 
126
122
  const style = cx(NAME, rootStyle, className);
@@ -13,7 +13,7 @@ exports[`Checkbox > snapshot: checked prop 1`] = `
13
13
  />
14
14
  <span
15
15
  aria-hidden="true"
16
- class="css-1quq9cp"
16
+ class="css-g3adpt"
17
17
  >
18
18
  <svg
19
19
  class="ucl-uikit-icon css-1nacdsu"
@@ -49,7 +49,7 @@ exports[`Checkbox > snapshot: indeterminate prop 1`] = `
49
49
  />
50
50
  <span
51
51
  aria-hidden="true"
52
- class="css-1quq9cp"
52
+ class="css-g3adpt"
53
53
  >
54
54
  <svg
55
55
  class="ucl-uikit-icon css-2i3jn5"
@@ -87,7 +87,7 @@ exports[`Checkbox > snapshot: no props 1`] = `
87
87
  />
88
88
  <span
89
89
  aria-hidden="true"
90
- class="css-1quq9cp"
90
+ class="css-g3adpt"
91
91
  />
92
92
  </span>
93
93
  `;
@@ -104,7 +104,7 @@ exports[`Checkbox > snapshot: testId prop 1`] = `
104
104
  />
105
105
  <span
106
106
  aria-hidden="true"
107
- class="css-1quq9cp"
107
+ class="css-g3adpt"
108
108
  />
109
109
  </span>
110
110
  `;
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { useArgs } from '@storybook/preview-api';
3
+
4
+ import Chip from './Chip';
5
+
6
+ const meta = {
7
+ title: 'Components/Chip',
8
+ component: Chip,
9
+ argTypes: {
10
+ label: { control: { type: 'text' } },
11
+ checked: { control: { type: 'boolean' } },
12
+ disabled: { control: { type: 'boolean' } },
13
+ },
14
+ } satisfies Meta<typeof Chip>;
15
+
16
+ export default meta;
17
+ type Story = StoryObj<typeof meta>;
18
+
19
+ export const Default: Story = {
20
+ args: {
21
+ label: 'Student',
22
+ checked: false,
23
+ disabled: false,
24
+ },
25
+ render: (args) => {
26
+ const [{ checked }, updateArgs] = useArgs();
27
+ const onChange = () => updateArgs({ checked: !checked });
28
+
29
+ return (
30
+ <Chip
31
+ {...args}
32
+ checked={checked}
33
+ onChange={onChange}
34
+ />
35
+ );
36
+ },
37
+ };
@@ -0,0 +1,191 @@
1
+ import { memo, forwardRef } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import useTheme from '../../theme/useTheme';
4
+ import BaseCheckbox, { BaseCheckboxProps } from '../BaseCheckbox';
5
+ import Icon from '../Icon';
6
+ import Text from '../Text';
7
+ import marginsStyle, { MarginProps } from '../common/marginsStyle';
8
+
9
+ export const NAME = 'ucl-uikit-chip';
10
+
11
+ export interface ChipBaseProps extends Omit<
12
+ BaseCheckboxProps,
13
+ | 'renderVisual'
14
+ | 'checkedComponent'
15
+ | 'uncheckedComponent'
16
+ | 'indeterminateComponent'
17
+ | 'indeterminate'
18
+ > {
19
+ label: string;
20
+ ariaLabel?: string;
21
+ }
22
+
23
+ export type ChipProps = ChipBaseProps & MarginProps;
24
+
25
+ export type Ref = HTMLInputElement;
26
+
27
+ const Chip = forwardRef<Ref, ChipProps>(
28
+ (
29
+ {
30
+ label,
31
+ checked,
32
+ defaultChecked,
33
+ disabled,
34
+ testId = NAME,
35
+ ariaLabel,
36
+ className,
37
+ ...props
38
+ },
39
+ ref
40
+ ) => {
41
+ const [theme] = useTheme();
42
+
43
+ const baseStyle = css`
44
+ position: relative;
45
+ display: inline-flex;
46
+ align-items: center;
47
+ justify-content: center;
48
+ min-height: 32px;
49
+ width: fit-content;
50
+ padding: ${theme.margin.m4} ${theme.margin.m16};
51
+ border: ${theme.border.b1} solid ${theme.colour.icon.brand};
52
+ border-radius: 16px;
53
+ outline: none;
54
+ box-sizing: border-box;
55
+ color: ${theme.colour.icon.inverse};
56
+ transition:
57
+ background-color 0.15s ease-out,
58
+ border-color 0.15s ease-out;
59
+ cursor: pointer;
60
+ background-color: ${theme.colour.icon.inverse};
61
+ color: ${theme.colour.icon.brand};
62
+
63
+ &:hover {
64
+ background-color: ${theme.colour.fill.brandSubtleHover};
65
+ }
66
+
67
+ &:has(input:focus-visible) {
68
+ box-shadow: ${theme.boxShadow.focus};
69
+ }
70
+
71
+ &:has(input:checked) {
72
+ background-color: ${theme.colour.icon.brandSelectedOnBgFill};
73
+ border-color: ${theme.colour.icon.brandSelectedOnBgFill};
74
+ color: ${theme.colour.icon.inverse};
75
+ }
76
+
77
+ &:has(input:checked):hover {
78
+ background-color: ${theme.colour.icon.brandSelectedOnBgFill};
79
+ }
80
+
81
+ &:has(input:disabled),
82
+ &:has(input:disabled):hover {
83
+ background-color: ${theme.colour.fill.disabled};
84
+ border-color: ${theme.colour.border.disabled};
85
+ color: ${theme.colour.text.disabled};
86
+ cursor: not-allowed;
87
+ }
88
+ `;
89
+
90
+ const style = cx(NAME, baseStyle, marginsStyle(props, theme), className);
91
+
92
+ const contentStyle = css`
93
+ display: inline-flex;
94
+ align-items: center;
95
+ justify-content: center;
96
+ `;
97
+
98
+ const iconBaseStyle = css`
99
+ flex-shrink: 0;
100
+ color: inherit;
101
+ user-select: none;
102
+ pointer-events: none;
103
+ transition:
104
+ opacity 0.15s ease-out,
105
+ margin-left 0.15s ease-out,
106
+ margin-right 0.15s ease-out;
107
+
108
+ @media (prefers-reduced-motion: reduce) {
109
+ transition: none;
110
+ }
111
+ `;
112
+
113
+ const iconVisibleStyle = css`
114
+ opacity: 1;
115
+ margin-left: 0;
116
+ margin-right: 0;
117
+ `;
118
+
119
+ const iconHiddenStyle = css`
120
+ opacity: 0;
121
+ margin-left: -4px;
122
+ margin-right: 4px;
123
+ `;
124
+
125
+ const labelBaseStyle = css`
126
+ color: inherit;
127
+ transition:
128
+ margin-left 0.15s ease-out,
129
+ margin-right 0.15s ease-out;
130
+
131
+ @media (prefers-reduced-motion: reduce) {
132
+ transition: none;
133
+ }
134
+ `;
135
+
136
+ const labelCheckedStyle = css`
137
+ margin-left: ${theme.margin.m4};
138
+ margin-right: 0;
139
+ `;
140
+
141
+ const labelUncheckedStyle = css`
142
+ margin-left: -6px;
143
+ margin-right: 10px;
144
+ `;
145
+
146
+ return (
147
+ <label className={style}>
148
+ <BaseCheckbox
149
+ ref={ref}
150
+ testId={`${testId}__root`}
151
+ data-testid={testId}
152
+ aria-label={ariaLabel}
153
+ checked={checked}
154
+ defaultChecked={defaultChecked}
155
+ disabled={disabled}
156
+ renderVisual={({ checked }) => {
157
+ const iconStyle = cx(
158
+ iconBaseStyle,
159
+ checked && iconVisibleStyle,
160
+ !checked && iconHiddenStyle
161
+ );
162
+
163
+ const labelStyle = cx(
164
+ labelBaseStyle,
165
+ checked && labelCheckedStyle,
166
+ !checked && labelUncheckedStyle
167
+ );
168
+
169
+ return (
170
+ <span className={contentStyle}>
171
+ <Icon.Check
172
+ className={iconStyle}
173
+ size={16}
174
+ />
175
+ <Text
176
+ level='sm-semibold'
177
+ className={labelStyle}
178
+ >
179
+ {label}
180
+ </Text>
181
+ </span>
182
+ );
183
+ }}
184
+ {...props}
185
+ />
186
+ </label>
187
+ );
188
+ }
189
+ );
190
+
191
+ export default memo(Chip);
@@ -0,0 +1,287 @@
1
+ import { createRef } from 'react';
2
+ import { describe, expect, test, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { ThemeContextProvider } from '../../../theme/useTheme';
6
+ import Chip from '../Chip';
7
+
8
+ describe('Chip', () => {
9
+ test('snapshot: no props', () => {
10
+ const renderResult = render(
11
+ <ThemeContextProvider>
12
+ <Chip label='Student' />
13
+ </ThemeContextProvider>
14
+ );
15
+
16
+ expect(renderResult.container.firstChild).toMatchSnapshot();
17
+ });
18
+
19
+ test('snapshot: checked prop', () => {
20
+ const renderResult = render(
21
+ <ThemeContextProvider>
22
+ <Chip
23
+ label='Student'
24
+ checked
25
+ onChange={() => {}}
26
+ />
27
+ </ThemeContextProvider>
28
+ );
29
+
30
+ expect(renderResult.container.firstChild).toMatchSnapshot();
31
+ });
32
+
33
+ test('snapshot: disabled prop', () => {
34
+ const renderResult = render(
35
+ <ThemeContextProvider>
36
+ <Chip
37
+ label='Student'
38
+ disabled
39
+ />
40
+ </ThemeContextProvider>
41
+ );
42
+
43
+ expect(renderResult.container.firstChild).toMatchSnapshot();
44
+ });
45
+
46
+ test('snapshot: disabled and checked props', () => {
47
+ const renderResult = render(
48
+ <ThemeContextProvider>
49
+ <Chip
50
+ label='Student'
51
+ checked
52
+ disabled
53
+ />
54
+ </ThemeContextProvider>
55
+ );
56
+
57
+ expect(renderResult.container.firstChild).toMatchSnapshot();
58
+ });
59
+
60
+ test('snapshot: testId prop', () => {
61
+ const renderResult = render(
62
+ <ThemeContextProvider>
63
+ <Chip
64
+ label='Student'
65
+ testId='test123'
66
+ />
67
+ </ThemeContextProvider>
68
+ );
69
+
70
+ expect(renderResult.container.firstChild).toMatchSnapshot();
71
+ });
72
+
73
+ test('checked prop: controlled state does not toggle without rerender', async () => {
74
+ const user = userEvent.setup();
75
+ const handleChange = vi.fn();
76
+
77
+ render(
78
+ <ThemeContextProvider>
79
+ <Chip
80
+ label='Student'
81
+ checked
82
+ onChange={handleChange}
83
+ />
84
+ </ThemeContextProvider>
85
+ );
86
+
87
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
88
+ expect(checkbox.checked).toBe(true);
89
+
90
+ await user.click(checkbox);
91
+ expect(handleChange).toHaveBeenCalledTimes(1);
92
+ expect(checkbox.checked).toBe(true);
93
+ });
94
+
95
+ test('defaultChecked prop: initializes as checked in uncontrolled mode', () => {
96
+ render(
97
+ <ThemeContextProvider>
98
+ <Chip
99
+ label='Student'
100
+ defaultChecked
101
+ />
102
+ </ThemeContextProvider>
103
+ );
104
+
105
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
106
+ expect(checkbox.checked).toBe(true);
107
+ });
108
+
109
+ test('test ID: default', () => {
110
+ render(
111
+ <ThemeContextProvider>
112
+ <Chip label='Student' />
113
+ </ThemeContextProvider>
114
+ );
115
+
116
+ const chip = screen.getByTestId('ucl-uikit-chip');
117
+ expect(chip).toBeDefined();
118
+ });
119
+
120
+ test('test ID: custom', () => {
121
+ render(
122
+ <ThemeContextProvider>
123
+ <Chip
124
+ label='Student'
125
+ testId='custom-test-id'
126
+ />
127
+ </ThemeContextProvider>
128
+ );
129
+
130
+ const chip = screen.getByTestId('custom-test-id');
131
+ expect(chip).toBeDefined();
132
+ });
133
+
134
+ test('click: uncontrolled via checkbox', async () => {
135
+ const user = userEvent.setup();
136
+ const handleChange = vi.fn();
137
+
138
+ render(
139
+ <ThemeContextProvider>
140
+ <Chip
141
+ label='Student'
142
+ onChange={handleChange}
143
+ />
144
+ </ThemeContextProvider>
145
+ );
146
+
147
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
148
+ expect(checkbox.checked).toBe(false);
149
+
150
+ await user.click(checkbox);
151
+ expect(checkbox.checked).toBe(true);
152
+ expect(handleChange).toHaveBeenCalledTimes(1);
153
+ });
154
+
155
+ test('click: label toggles checkbox', async () => {
156
+ const user = userEvent.setup();
157
+ const handleChange = vi.fn();
158
+
159
+ render(
160
+ <ThemeContextProvider>
161
+ <Chip
162
+ label='Student'
163
+ onChange={handleChange}
164
+ />
165
+ </ThemeContextProvider>
166
+ );
167
+
168
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
169
+ const label = screen.getByText('Student').closest('label');
170
+ expect(checkbox.checked).toBe(false);
171
+ expect(label).not.toBeNull();
172
+
173
+ await user.click(label!);
174
+ expect(checkbox.checked).toBe(true);
175
+ expect(handleChange).toHaveBeenCalledTimes(1);
176
+ });
177
+
178
+ test('click: disabled', async () => {
179
+ const user = userEvent.setup();
180
+ const handleChange = vi.fn();
181
+
182
+ render(
183
+ <ThemeContextProvider>
184
+ <Chip
185
+ label='Student'
186
+ disabled
187
+ onChange={handleChange}
188
+ />
189
+ </ThemeContextProvider>
190
+ );
191
+
192
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
193
+ const label = screen.getByText('Student').closest('label');
194
+ expect(checkbox.checked).toBe(false);
195
+ expect(label).not.toBeNull();
196
+
197
+ await user.click(label!);
198
+ expect(checkbox.checked).toBe(false);
199
+ expect(handleChange).not.toHaveBeenCalled();
200
+ });
201
+
202
+ test('click: disabled and checked remains checked', async () => {
203
+ const user = userEvent.setup();
204
+ const handleChange = vi.fn();
205
+
206
+ render(
207
+ <ThemeContextProvider>
208
+ <Chip
209
+ label='Student'
210
+ checked
211
+ disabled
212
+ onChange={handleChange}
213
+ />
214
+ </ThemeContextProvider>
215
+ );
216
+
217
+ const checkbox = screen.getByRole('checkbox') as HTMLInputElement;
218
+ const label = screen.getByText('Student').closest('label');
219
+ expect(checkbox.checked).toBe(true);
220
+ expect(label).not.toBeNull();
221
+
222
+ await user.click(label!);
223
+ expect(checkbox.checked).toBe(true);
224
+ expect(handleChange).not.toHaveBeenCalled();
225
+ });
226
+
227
+ test('uses BaseCheckbox', () => {
228
+ render(
229
+ <ThemeContextProvider>
230
+ <Chip label='Student' />
231
+ </ThemeContextProvider>
232
+ );
233
+
234
+ const root = screen.getByTestId('ucl-uikit-chip__root');
235
+
236
+ expect(root).toHaveClass('ucl-uikit-base-checkbox');
237
+ });
238
+
239
+ test('ariaLabel prop is applied to the input as aria-label', () => {
240
+ render(
241
+ <ThemeContextProvider>
242
+ <Chip
243
+ label='Student'
244
+ ariaLabel='Select student'
245
+ />
246
+ </ThemeContextProvider>
247
+ );
248
+
249
+ expect(screen.getByRole('checkbox')).toHaveAttribute(
250
+ 'aria-label',
251
+ 'Select student'
252
+ );
253
+ });
254
+
255
+ test('aria-label passed via spread props overrides ariaLabel', () => {
256
+ render(
257
+ <ThemeContextProvider>
258
+ <Chip
259
+ label='Student'
260
+ ariaLabel='Default label'
261
+ aria-label='Override label'
262
+ />
263
+ </ThemeContextProvider>
264
+ );
265
+
266
+ expect(screen.getByRole('checkbox')).toHaveAttribute(
267
+ 'aria-label',
268
+ 'Override label'
269
+ );
270
+ });
271
+
272
+ test('ref is forwarded to the underlying input element', () => {
273
+ const ref = createRef<HTMLInputElement>();
274
+
275
+ render(
276
+ <ThemeContextProvider>
277
+ <Chip
278
+ label='Student'
279
+ ref={ref}
280
+ />
281
+ </ThemeContextProvider>
282
+ );
283
+
284
+ expect(ref.current).toBeInstanceOf(HTMLInputElement);
285
+ expect(ref.current).toBe(screen.getByRole('checkbox'));
286
+ });
287
+ });