uikit-react-public 0.30.1 → 0.32.4

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/dist/components/Heading/Heading.stories.d.ts +5 -7
  2. package/dist/components/MenuNew/Menu.context.d.ts +9 -0
  3. package/dist/components/MenuNew/Menu.d.ts +2 -1
  4. package/dist/components/MenuNew/MenuHeading.d.ts +1 -1
  5. package/dist/components/MenuNew/MenuItemText.d.ts +2 -1
  6. package/dist/components/MenuNew/PrimaryMenuItem.d.ts +1 -1
  7. package/dist/components/Select/Select.types.d.ts +1 -1
  8. package/dist/components/Sidebar/Sidebar.d.ts +12 -0
  9. package/dist/components/Sidebar/Sidebar.stories.d.ts +38 -0
  10. package/dist/components/Sidebar/__tests__/Sidebar.test.d.ts +1 -0
  11. package/dist/components/Sidebar/index.d.ts +2 -0
  12. package/dist/components/Snackbar/Snackbar.d.ts +9 -5
  13. package/dist/components/Snackbar/Snackbar.stories.d.ts +3 -1
  14. package/dist/components/Snackbar/Snackbars.d.ts +19 -0
  15. package/dist/components/Snackbar/Snackbars.stories.d.ts +52 -0
  16. package/dist/components/Snackbar/__tests__/Snackbars.test.d.ts +1 -0
  17. package/dist/components/Snackbar/index.d.ts +2 -0
  18. package/dist/components/index.d.ts +4 -1
  19. package/dist/index.js +5979 -5448
  20. package/lib/components/Dropdown/Dropdown.stories.tsx +69 -74
  21. package/lib/components/Heading/Documentation.mdx +4 -14
  22. package/lib/components/Heading/Heading.stories.tsx +16 -30
  23. package/lib/components/MenuNew/Menu.context.tsx +18 -0
  24. package/lib/components/MenuNew/Menu.tsx +14 -9
  25. package/lib/components/MenuNew/MenuDivider.tsx +12 -1
  26. package/lib/components/MenuNew/MenuHeading.tsx +6 -0
  27. package/lib/components/MenuNew/MenuItemText.tsx +27 -3
  28. package/lib/components/MenuNew/MenuSection.tsx +11 -0
  29. package/lib/components/MenuNew/PrimaryMenuItem.tsx +71 -2
  30. package/lib/components/Select/Select.types.ts +1 -7
  31. package/lib/components/Select/__tests__/Select.test.tsx +112 -0
  32. package/lib/components/Select/__tests__/__snapshots__/Select.test.tsx.snap +5 -0
  33. package/lib/components/Select/subcomponents/CustomSelect.tsx +75 -3
  34. package/lib/components/Select/subcomponents/FilterInput.tsx +1 -1
  35. package/lib/components/Sidebar/Sidebar.stories.tsx +94 -0
  36. package/lib/components/Sidebar/Sidebar.tsx +208 -0
  37. package/lib/components/Sidebar/__tests__/Sidebar.test.tsx +96 -0
  38. package/lib/components/Sidebar/__tests__/__snapshots__/Sidebar.test.tsx.snap +272 -0
  39. package/lib/components/Sidebar/index.ts +2 -0
  40. package/lib/components/Snackbar/Snackbar.stories.tsx +22 -0
  41. package/lib/components/Snackbar/Snackbar.tsx +168 -119
  42. package/lib/components/Snackbar/Snackbars.stories.tsx +141 -0
  43. package/lib/components/Snackbar/Snackbars.tsx +377 -0
  44. package/lib/components/Snackbar/__tests__/Snackbar.test.tsx +222 -10
  45. package/lib/components/Snackbar/__tests__/Snackbars.test.tsx +377 -0
  46. package/lib/components/Snackbar/__tests__/__snapshots__/Snackbar.test.tsx.snap +46 -25
  47. package/lib/components/Snackbar/index.ts +6 -0
  48. package/lib/components/index.ts +10 -1
  49. package/package.json +1 -1
@@ -0,0 +1,141 @@
1
+ import { useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { css } from '@emotion/css';
4
+ import Button from '../Button/Button';
5
+ import Snackbars, { type SnackbarsItem } from './Snackbars';
6
+
7
+ const meta = {
8
+ title: 'Components/Snackbars',
9
+ component: Snackbars,
10
+ parameters: {
11
+ layout: 'fullscreen',
12
+ docs: {
13
+ description: {
14
+ component: 'Manages a stack of Snackbar notifications.',
15
+ },
16
+ },
17
+ },
18
+ argTypes: {
19
+ items: { control: false },
20
+ onDismiss: { control: false },
21
+ duration: { control: { type: 'number' } },
22
+ transitionDuration: { control: { type: 'number' } },
23
+ maxVisible: { control: { type: 'number' } },
24
+ placement: {
25
+ control: { type: 'select' },
26
+ options: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
27
+ },
28
+ },
29
+ args: {
30
+ duration: 0,
31
+ transitionDuration: 200,
32
+ maxVisible: 3,
33
+ placement: 'bottom-right',
34
+ },
35
+ tags: ['autodocs'],
36
+ } satisfies Meta<typeof Snackbars>;
37
+
38
+ export default meta;
39
+
40
+ type Story = StoryObj<typeof meta>;
41
+
42
+ const previewStyle = css`
43
+ min-height: 360px;
44
+ padding: 24px;
45
+ `;
46
+
47
+ export const Interactive: Story = {
48
+ args: {
49
+ duration: 5000,
50
+ items: [],
51
+ onDismiss: () => undefined,
52
+ },
53
+ render: (args) => {
54
+ const snackbarIdCounterRef = useRef(0);
55
+ const [items, setItems] = useState<SnackbarsItem[]>([
56
+ {
57
+ id: 'initial',
58
+ children: 'Use the buttons to add more snackbars',
59
+ duration: 0,
60
+ },
61
+ ]);
62
+
63
+ const controlsStyle = css`
64
+ display: flex;
65
+ flex-wrap: wrap;
66
+ gap: 12px;
67
+ padding: 24px;
68
+ `;
69
+
70
+ const getNextSnackbarId = () => {
71
+ snackbarIdCounterRef.current += 1;
72
+ return `storybook-snackbar-${snackbarIdCounterRef.current}`;
73
+ };
74
+
75
+ const addSnackbar = (item: Omit<SnackbarsItem, 'id'>) => {
76
+ setItems((current) => [
77
+ ...current,
78
+ {
79
+ ...item,
80
+ id: getNextSnackbarId(),
81
+ },
82
+ ]);
83
+ };
84
+
85
+ return (
86
+ <div className={previewStyle}>
87
+ <div className={controlsStyle}>
88
+ <Button
89
+ onClick={() =>
90
+ addSnackbar({
91
+ children: `Snackbar ${items.length + 1}`,
92
+ })
93
+ }
94
+ >
95
+ Add snackbar
96
+ </Button>
97
+ <Button
98
+ onClick={() =>
99
+ addSnackbar({
100
+ action: 'undo',
101
+ children: 'Snackbar with undo',
102
+ onUndo: () => alert('Undo action triggered'),
103
+ })
104
+ }
105
+ >
106
+ Add snackbar with undo
107
+ </Button>
108
+ <Button
109
+ onClick={() =>
110
+ addSnackbar({
111
+ children: 'Error snackbar',
112
+ size: 'small',
113
+ variant: 'error',
114
+ })
115
+ }
116
+ >
117
+ Add error snackbar
118
+ </Button>
119
+ <Button
120
+ onClick={() =>
121
+ addSnackbar({
122
+ children: 'Success snackbar',
123
+ size: 'small',
124
+ variant: 'success',
125
+ })
126
+ }
127
+ >
128
+ Add success snackbar
129
+ </Button>
130
+ </div>
131
+ <Snackbars
132
+ {...args}
133
+ items={items}
134
+ onDismiss={(id) =>
135
+ setItems((current) => current.filter((item) => item.id !== id))
136
+ }
137
+ />
138
+ </div>
139
+ );
140
+ },
141
+ };
@@ -0,0 +1,377 @@
1
+ import {
2
+ type HTMLAttributes,
3
+ memo,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+ import { css, cx, keyframes } from '@emotion/css';
11
+ import useTheme from '../../theme/useTheme';
12
+ import Snackbar, { type SnackbarProps } from './Snackbar';
13
+
14
+ export const NAME = 'ucl-uikit-snackbars';
15
+
16
+ export type SnackbarsPlacement =
17
+ 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
18
+
19
+ const DEFAULT_AUTO_DISMISS_DURATION = 5000;
20
+
21
+ export interface SnackbarsItem extends SnackbarProps {
22
+ id: string;
23
+ duration?: number;
24
+ }
25
+
26
+ export interface SnackbarsProps extends Omit<
27
+ HTMLAttributes<HTMLDivElement>,
28
+ 'children'
29
+ > {
30
+ items: SnackbarsItem[];
31
+ onDismiss: (id: string) => void;
32
+ duration?: number;
33
+ transitionDuration?: number;
34
+ maxVisible?: number;
35
+ placement?: SnackbarsPlacement;
36
+ testId?: string;
37
+ }
38
+
39
+ const Snackbars = ({
40
+ items,
41
+ onDismiss,
42
+ duration = DEFAULT_AUTO_DISMISS_DURATION,
43
+ transitionDuration = 200,
44
+ maxVisible = 3,
45
+ placement = 'bottom-right',
46
+ testId = NAME,
47
+ className,
48
+ ...props
49
+ }: SnackbarsProps) => {
50
+ const [theme] = useTheme();
51
+ const [exitingIds, setExitingIds] = useState<Set<string>>(new Set());
52
+ const autoDismissTimersRef = useRef<Record<string, number>>({});
53
+ const autoDismissStartedAtRef = useRef<Record<string, number>>({});
54
+ const autoDismissRemainingRef = useRef<Record<string, number>>({});
55
+ const exitTimersRef = useRef<Record<string, number>>({});
56
+ const dismissRef = useRef(onDismiss);
57
+ const focusedIdsRef = useRef<Set<string>>(new Set());
58
+
59
+ useEffect(() => {
60
+ dismissRef.current = onDismiss;
61
+ }, [onDismiss]);
62
+
63
+ const visibleItems = useMemo(
64
+ () => items.slice(0, maxVisible),
65
+ [items, maxVisible]
66
+ );
67
+
68
+ const visibleItemIds = useMemo(
69
+ () => new Set(visibleItems.map((item) => item.id)),
70
+ [visibleItems]
71
+ );
72
+
73
+ const itemIds = useMemo(() => new Set(items.map((item) => item.id)), [items]);
74
+
75
+ const clearAutoDismissTimer = useCallback((id: string) => {
76
+ if (autoDismissTimersRef.current[id] === undefined) return;
77
+
78
+ window.clearTimeout(autoDismissTimersRef.current[id]);
79
+ delete autoDismissTimersRef.current[id];
80
+ delete autoDismissStartedAtRef.current[id];
81
+ }, []);
82
+
83
+ const beginDismiss = useCallback(
84
+ (id: string) => {
85
+ clearAutoDismissTimer(id);
86
+ delete autoDismissRemainingRef.current[id];
87
+ focusedIdsRef.current.delete(id);
88
+
89
+ setExitingIds((current) => {
90
+ if (current.has(id)) return current;
91
+
92
+ const next = new Set(current);
93
+ next.add(id);
94
+ return next;
95
+ });
96
+
97
+ if (exitTimersRef.current[id] !== undefined) return;
98
+
99
+ exitTimersRef.current[id] = window.setTimeout(() => {
100
+ delete exitTimersRef.current[id];
101
+ setExitingIds((current) => {
102
+ const next = new Set(current);
103
+ next.delete(id);
104
+ return next;
105
+ });
106
+ dismissRef.current(id);
107
+ }, transitionDuration);
108
+ },
109
+ [clearAutoDismissTimer, transitionDuration]
110
+ );
111
+
112
+ const startAutoDismissTimer = useCallback(
113
+ (id: string, timeoutDuration: number) => {
114
+ autoDismissStartedAtRef.current[id] = Date.now();
115
+ autoDismissTimersRef.current[id] = window.setTimeout(() => {
116
+ delete autoDismissTimersRef.current[id];
117
+ delete autoDismissStartedAtRef.current[id];
118
+ delete autoDismissRemainingRef.current[id];
119
+ beginDismiss(id);
120
+ }, timeoutDuration);
121
+ },
122
+ [beginDismiss]
123
+ );
124
+
125
+ const pauseAutoDismiss = useCallback(
126
+ (id: string) => {
127
+ if (autoDismissTimersRef.current[id] === undefined) return;
128
+
129
+ const startedAt = autoDismissStartedAtRef.current[id];
130
+ const remainingDuration = autoDismissRemainingRef.current[id];
131
+
132
+ if (startedAt !== undefined && remainingDuration !== undefined) {
133
+ autoDismissRemainingRef.current[id] = Math.max(
134
+ 0,
135
+ remainingDuration - (Date.now() - startedAt)
136
+ );
137
+ }
138
+
139
+ clearAutoDismissTimer(id);
140
+ },
141
+ [clearAutoDismissTimer]
142
+ );
143
+
144
+ const resumeAutoDismiss = useCallback(
145
+ (id: string) => {
146
+ if (focusedIdsRef.current.has(id)) return;
147
+ if (autoDismissTimersRef.current[id] !== undefined) return;
148
+
149
+ const remainingDuration = autoDismissRemainingRef.current[id];
150
+ if (remainingDuration === undefined) return;
151
+
152
+ if (remainingDuration <= 0) {
153
+ beginDismiss(id);
154
+ return;
155
+ }
156
+
157
+ startAutoDismissTimer(id, remainingDuration);
158
+ },
159
+ [beginDismiss, startAutoDismissTimer]
160
+ );
161
+
162
+ const getAutoDismissDuration = useCallback(
163
+ (item: SnackbarsItem) => {
164
+ const itemDuration = item.duration ?? duration;
165
+
166
+ if ((item.action ?? 'close') !== 'undo' || itemDuration > 0) {
167
+ return itemDuration;
168
+ }
169
+
170
+ return duration > 0 ? duration : DEFAULT_AUTO_DISMISS_DURATION;
171
+ },
172
+ [duration]
173
+ );
174
+
175
+ useEffect(() => {
176
+ visibleItems.forEach((item) => {
177
+ if (exitingIds.has(item.id)) return;
178
+ if (autoDismissTimersRef.current[item.id] !== undefined) return;
179
+
180
+ const itemDuration = getAutoDismissDuration(item);
181
+ if (itemDuration <= 0) return;
182
+
183
+ const remainingDuration =
184
+ autoDismissRemainingRef.current[item.id] ?? itemDuration;
185
+ autoDismissRemainingRef.current[item.id] = remainingDuration;
186
+
187
+ if (focusedIdsRef.current.has(item.id)) return;
188
+
189
+ startAutoDismissTimer(item.id, remainingDuration);
190
+ });
191
+ }, [
192
+ clearAutoDismissTimer,
193
+ exitingIds,
194
+ getAutoDismissDuration,
195
+ startAutoDismissTimer,
196
+ visibleItems,
197
+ ]);
198
+
199
+ useEffect(() => {
200
+ setExitingIds((current) => {
201
+ const next = new Set([...current].filter((id) => visibleItemIds.has(id)));
202
+ return next.size === current.size ? current : next;
203
+ });
204
+
205
+ Object.entries(exitTimersRef.current).forEach(([id, timerId]) => {
206
+ if (itemIds.has(id)) return;
207
+
208
+ window.clearTimeout(timerId);
209
+ delete exitTimersRef.current[id];
210
+ });
211
+
212
+ Object.entries(autoDismissTimersRef.current).forEach(([id, timerId]) => {
213
+ if (visibleItemIds.has(id)) return;
214
+
215
+ window.clearTimeout(timerId);
216
+ delete autoDismissTimersRef.current[id];
217
+ delete autoDismissStartedAtRef.current[id];
218
+ delete autoDismissRemainingRef.current[id];
219
+ focusedIdsRef.current.delete(id);
220
+ });
221
+
222
+ Object.keys(autoDismissRemainingRef.current).forEach((id) => {
223
+ if (visibleItemIds.has(id)) return;
224
+
225
+ delete autoDismissRemainingRef.current[id];
226
+ focusedIdsRef.current.delete(id);
227
+ });
228
+ }, [itemIds, visibleItemIds]);
229
+
230
+ useEffect(() => {
231
+ const autoDismissTimers = autoDismissTimersRef.current;
232
+ const exitTimers = exitTimersRef.current;
233
+
234
+ return () => {
235
+ Object.values(autoDismissTimers).forEach((timerId) =>
236
+ window.clearTimeout(timerId)
237
+ );
238
+ Object.values(exitTimers).forEach((timerId) =>
239
+ window.clearTimeout(timerId)
240
+ );
241
+ };
242
+ }, []);
243
+
244
+ const isBottomPlacement = placement.startsWith('bottom');
245
+ const isRightPlacement = placement.endsWith('right');
246
+ const stackedItems = isBottomPlacement
247
+ ? [...visibleItems].reverse()
248
+ : visibleItems;
249
+
250
+ const enterAnimation = keyframes`
251
+ from {
252
+ opacity: 0;
253
+ transform: translateY(
254
+ ${isBottomPlacement ? theme.margin.m8 : `-${theme.margin.m8}`}
255
+ );
256
+ }
257
+
258
+ to {
259
+ opacity: 1;
260
+ transform: translateY(0);
261
+ }
262
+ `;
263
+
264
+ const containerStyle = css`
265
+ position: fixed;
266
+ z-index: 9999;
267
+ display: flex;
268
+ flex-direction: column;
269
+ gap: ${theme.margin.m8};
270
+ pointer-events: none;
271
+ ${
272
+ isBottomPlacement
273
+ ? `bottom: ${theme.margin.m24};`
274
+ : `top: ${theme.margin.m24};`
275
+ }
276
+ right: ${theme.margin.m16};
277
+ left: ${theme.margin.m16};
278
+
279
+ @media (min-width: ${theme.breakpoints.tablet}px) {
280
+ ${
281
+ isRightPlacement
282
+ ? `right: ${theme.margin.m24}; left: auto;`
283
+ : `right: auto; left: ${theme.margin.m24};`
284
+ }
285
+ }
286
+ `;
287
+
288
+ const snackbarStyle = css`
289
+ position: static;
290
+ pointer-events: auto;
291
+ opacity: 1;
292
+ transform: translateY(0);
293
+ transition:
294
+ opacity ${transitionDuration}ms ease,
295
+ transform ${transitionDuration}ms ease;
296
+ animation: ${enterAnimation} ${transitionDuration}ms ease;
297
+
298
+ @media (prefers-reduced-motion: reduce) {
299
+ transition: none;
300
+ animation: none;
301
+ }
302
+ `;
303
+
304
+ const exitingStyle = css`
305
+ animation: none;
306
+ opacity: 0;
307
+ transform: translateY(
308
+ ${isBottomPlacement ? theme.margin.m8 : `-${theme.margin.m8}`}
309
+ );
310
+ `;
311
+
312
+ const style = cx(NAME, containerStyle, className);
313
+
314
+ return (
315
+ <div
316
+ className={style}
317
+ data-testid={testId}
318
+ {...props}
319
+ >
320
+ {stackedItems.map(({ id, ...item }) => {
321
+ const itemIsExiting = exitingIds.has(id);
322
+ delete item.duration;
323
+
324
+ return (
325
+ <Snackbar
326
+ key={id}
327
+ action={item.action ?? 'close'}
328
+ {...item}
329
+ actionDisabled={item.actionDisabled || itemIsExiting}
330
+ className={cx(
331
+ snackbarStyle,
332
+ itemIsExiting && exitingStyle,
333
+ item.className
334
+ )}
335
+ onClose={() => {
336
+ if (exitTimersRef.current[id] !== undefined) return;
337
+
338
+ item.onClose?.();
339
+ beginDismiss(id);
340
+ }}
341
+ onUndo={() => {
342
+ if (exitTimersRef.current[id] !== undefined) return;
343
+
344
+ item.onUndo?.();
345
+ beginDismiss(id);
346
+ }}
347
+ onFocus={(event) => {
348
+ item.onFocus?.(event);
349
+ focusedIdsRef.current.add(id);
350
+ pauseAutoDismiss(id);
351
+ }}
352
+ onBlur={(event) => {
353
+ item.onBlur?.(event);
354
+ const nextFocusedElement = event.relatedTarget;
355
+ if (
356
+ nextFocusedElement instanceof Node &&
357
+ event.currentTarget.contains(nextFocusedElement)
358
+ ) {
359
+ return;
360
+ }
361
+
362
+ focusedIdsRef.current.delete(id);
363
+ resumeAutoDismiss(id);
364
+ }}
365
+ size={item.size}
366
+ testId={item.testId ?? `${testId}-${id}`}
367
+ variant={item.variant}
368
+ >
369
+ {item.children}
370
+ </Snackbar>
371
+ );
372
+ })}
373
+ </div>
374
+ );
375
+ };
376
+
377
+ export default memo(Snackbars);