uikit-react-public 0.45.4 → 0.47.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 (45) hide show
  1. package/dist/components/DropZone/DropZone.d.ts +21 -0
  2. package/dist/components/DropZone/DropZone.stories.d.ts +52 -0
  3. package/dist/components/DropZone/__tests__/DropZone.test.d.ts +1 -0
  4. package/dist/components/DropZone/index.d.ts +2 -0
  5. package/dist/components/Label/Label.d.ts +1 -0
  6. package/dist/components/Label/Label.stories.d.ts +1 -0
  7. package/dist/components/Skeleton/Skeleton.d.ts +35 -0
  8. package/dist/components/Skeleton/Skeleton.stories.d.ts +27 -0
  9. package/dist/components/Skeleton/SkeletonAvatar.d.ts +7 -0
  10. package/dist/components/Skeleton/SkeletonInput.d.ts +4 -0
  11. package/dist/components/Skeleton/SkeletonTableRow.d.ts +9 -0
  12. package/dist/components/Skeleton/SkeletonText.d.ts +4 -0
  13. package/dist/components/Skeleton/__tests__/Skeleton.test.d.ts +1 -0
  14. package/dist/components/Skeleton/index.d.ts +6 -0
  15. package/dist/components/index.d.ts +4 -0
  16. package/dist/hooks/__tests__/useDraggable.test.d.ts +1 -0
  17. package/dist/hooks/index.d.ts +4 -0
  18. package/dist/hooks/useDraggable.d.ts +31 -0
  19. package/dist/hooks/useDropZone.d.ts +57 -0
  20. package/dist/index.js +7247 -6685
  21. package/lib/components/DropZone/DropZone.mdx +72 -0
  22. package/lib/components/DropZone/DropZone.stories.tsx +139 -0
  23. package/lib/components/DropZone/DropZone.tsx +213 -0
  24. package/lib/components/DropZone/__tests__/DropZone.test.tsx +378 -0
  25. package/lib/components/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap +46 -0
  26. package/lib/components/DropZone/index.ts +2 -0
  27. package/lib/components/Label/Label.stories.tsx +17 -0
  28. package/lib/components/Label/Label.tsx +4 -7
  29. package/lib/components/Label/__tests__/Label.test.tsx +15 -0
  30. package/lib/components/Label/__tests__/__snapshots__/Label.test.tsx.snap +1 -3
  31. package/lib/components/Skeleton/Skeleton.mdx +34 -0
  32. package/lib/components/Skeleton/Skeleton.stories.tsx +77 -0
  33. package/lib/components/Skeleton/Skeleton.tsx +158 -0
  34. package/lib/components/Skeleton/SkeletonAvatar.tsx +20 -0
  35. package/lib/components/Skeleton/SkeletonInput.tsx +13 -0
  36. package/lib/components/Skeleton/SkeletonTableRow.tsx +44 -0
  37. package/lib/components/Skeleton/SkeletonText.tsx +13 -0
  38. package/lib/components/Skeleton/__tests__/Skeleton.test.tsx +86 -0
  39. package/lib/components/Skeleton/index.ts +6 -0
  40. package/lib/components/index.ts +13 -0
  41. package/lib/hooks/__tests__/useDraggable.test.tsx +184 -0
  42. package/lib/hooks/index.ts +19 -0
  43. package/lib/hooks/useDraggable.ts +243 -0
  44. package/lib/hooks/useDropZone.ts +418 -0
  45. package/package.json +1 -1
@@ -0,0 +1,158 @@
1
+ import { HTMLAttributes, Ref } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme, type ThemeType } from '../../theme';
4
+ import marginsStyle, { MarginProps } from '../common/marginsStyle';
5
+ import SkeletonAvatar from './SkeletonAvatar';
6
+ import SkeletonInput from './SkeletonInput';
7
+ import SkeletonTableRow from './SkeletonTableRow';
8
+ import SkeletonText from './SkeletonText';
9
+
10
+ export const NAME = 'ucl-uikit-skeleton';
11
+
12
+ export type SkeletonVariant = 'text' | 'circle' | 'rect';
13
+
14
+ export interface SkeletonBaseProps extends HTMLAttributes<HTMLDivElement> {
15
+ /** Placeholder shape. @default 'text' */
16
+ variant?: SkeletonVariant;
17
+ /** Overrides the bar width. Numbers are treated as pixels. */
18
+ width?: number | string;
19
+ /** Overrides the bar height. Numbers are treated as pixels. */
20
+ height?: number | string;
21
+ /** Number of bars to render. @default 1 */
22
+ count?: number;
23
+ /** Optional border-radius token. Ignored for the `circle` variant. */
24
+ radius?: keyof ThemeType['radius'];
25
+ testId?: string;
26
+ ref?: Ref<HTMLDivElement>;
27
+ }
28
+
29
+ export type SkeletonProps = SkeletonBaseProps & MarginProps;
30
+
31
+ const toCss = (value?: number | string) =>
32
+ typeof value === 'number' ? `${value}px` : value;
33
+
34
+ export const getSkeletonShimmerBackground = (theme: ThemeType) => {
35
+ return `
36
+ linear-gradient(
37
+ 270deg,
38
+ color-mix(in srgb, ${theme.colour.bg.default} 0%, transparent) 0%,
39
+ color-mix(in srgb, ${theme.colour.bg.default} 55%, transparent) 50%,
40
+ color-mix(in srgb, ${theme.colour.bg.default} 0%, transparent) 100%
41
+ ),
42
+ linear-gradient(
43
+ 270deg,
44
+ color-mix(in srgb, ${theme.colour.surface.secondary} 0%, transparent) 0%,
45
+ color-mix(in srgb, ${theme.colour.fill.subtle} 30%, transparent) 25%,
46
+ color-mix(in srgb, ${theme.colour.fill.subtle} 75%, transparent) 50%,
47
+ color-mix(in srgb, ${theme.colour.fill.subtle} 40%, transparent) 75%,
48
+ color-mix(in srgb, ${theme.colour.surface.secondary} 0%, transparent) 100%
49
+ )
50
+ `;
51
+ };
52
+
53
+ const Skeleton = ({
54
+ variant = 'text',
55
+ width,
56
+ height,
57
+ count = 1,
58
+ radius,
59
+ testId = NAME,
60
+ className,
61
+ ref,
62
+ ...props
63
+ }: SkeletonProps) => {
64
+ const [theme] = useTheme();
65
+
66
+ const variantDefaults = {
67
+ text: { width: '100%', height: '8px' },
68
+ circle: { width: '72px', height: '72px' },
69
+ rect: { width: '100%', height: '96px' },
70
+ }[variant];
71
+
72
+ const wrapperStyle = css`
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: ${variant === 'text' ? theme.padding.p16 : 0};
76
+ `;
77
+
78
+ const barStyle = css`
79
+ position: relative;
80
+ display: block;
81
+ box-sizing: border-box;
82
+ width: ${toCss(width) ?? variantDefaults.width};
83
+ height: ${toCss(height) ?? variantDefaults.height};
84
+ overflow: hidden;
85
+ border-radius: ${
86
+ variant === 'circle' ? '9999px' : radius ? theme.radius[radius] : 0
87
+ };
88
+ background-color: ${theme.colour.surface.secondary};
89
+
90
+ &::after {
91
+ position: absolute;
92
+ top: 0;
93
+ left: -175px;
94
+ width: 175px;
95
+ height: 100%;
96
+ content: '';
97
+ background: ${getSkeletonShimmerBackground(theme)};
98
+ animation: ${NAME}-sweep 1.4s ease-in-out infinite;
99
+ }
100
+
101
+ @keyframes ${NAME}-sweep {
102
+ from {
103
+ left: -175px;
104
+ }
105
+
106
+ to {
107
+ left: 100%;
108
+ }
109
+ }
110
+
111
+ @media (prefers-reduced-motion: reduce) {
112
+ &::after {
113
+ display: none;
114
+ animation: none;
115
+ }
116
+ }
117
+ `;
118
+
119
+ const style = cx(NAME, wrapperStyle, marginsStyle(props, theme), className);
120
+ const isRaggedLastLine = (index: number) =>
121
+ variant === 'text' && count > 1 && index === count - 1;
122
+
123
+ return (
124
+ <div
125
+ {...props}
126
+ ref={ref}
127
+ aria-hidden='true'
128
+ data-testid={testId}
129
+ className={style}
130
+ >
131
+ {Array.from({ length: count }, (_, index) => (
132
+ <span
133
+ key={index}
134
+ className={barStyle}
135
+ style={isRaggedLastLine(index) ? { width: '61.8%' } : undefined}
136
+ />
137
+ ))}
138
+ </div>
139
+ );
140
+ };
141
+
142
+ export interface SkeletonSubcomponents {
143
+ Text: typeof SkeletonText;
144
+ Avatar: typeof SkeletonAvatar;
145
+ Input: typeof SkeletonInput;
146
+ TableRow: typeof SkeletonTableRow;
147
+ }
148
+
149
+ const SkeletonWithSubcomponents = Skeleton as typeof Skeleton &
150
+ SkeletonSubcomponents;
151
+
152
+ SkeletonWithSubcomponents.Text = SkeletonText;
153
+ SkeletonWithSubcomponents.Avatar = SkeletonAvatar;
154
+ SkeletonWithSubcomponents.Input = SkeletonInput;
155
+ SkeletonWithSubcomponents.TableRow = SkeletonTableRow;
156
+
157
+ export { Skeleton };
158
+ export default SkeletonWithSubcomponents;
@@ -0,0 +1,20 @@
1
+ import { Skeleton, SkeletonProps } from './Skeleton';
2
+
3
+ export interface SkeletonAvatarProps extends Omit<
4
+ SkeletonProps,
5
+ 'variant' | 'width' | 'height' | 'count'
6
+ > {
7
+ /** Matches the `Avatar` size options. @default 72 */
8
+ size?: 32 | 48 | 56 | 72 | 80;
9
+ }
10
+
11
+ const SkeletonAvatar = ({ size = 72, ...props }: SkeletonAvatarProps) => (
12
+ <Skeleton
13
+ variant='circle'
14
+ width={size}
15
+ height={size}
16
+ {...props}
17
+ />
18
+ );
19
+
20
+ export default SkeletonAvatar;
@@ -0,0 +1,13 @@
1
+ import { Skeleton, SkeletonProps } from './Skeleton';
2
+
3
+ export type SkeletonInputProps = Omit<SkeletonProps, 'variant' | 'count'>;
4
+
5
+ const SkeletonInput = ({ height = 48, ...props }: SkeletonInputProps) => (
6
+ <Skeleton
7
+ variant='rect'
8
+ height={height}
9
+ {...props}
10
+ />
11
+ );
12
+
13
+ export default SkeletonInput;
@@ -0,0 +1,44 @@
1
+ import { HTMLAttributes } from 'react';
2
+ import { css, cx } from '@emotion/css';
3
+ import { useTheme } from '../../theme';
4
+ import { Skeleton } from './Skeleton';
5
+
6
+ export const NAME = 'ucl-uikit-skeleton__table-row';
7
+
8
+ export interface SkeletonTableRowProps extends HTMLAttributes<HTMLTableRowElement> {
9
+ /** Number of cells to render. @default 4 */
10
+ columns?: number;
11
+ testId?: string;
12
+ }
13
+
14
+ const SkeletonTableRow = ({
15
+ columns = 4,
16
+ testId = NAME,
17
+ className,
18
+ ...props
19
+ }: SkeletonTableRowProps) => {
20
+ const [theme] = useTheme();
21
+
22
+ const cellStyle = css`
23
+ padding: ${theme.padding.p12} ${theme.padding.p16};
24
+ `;
25
+
26
+ return (
27
+ <tr
28
+ className={cx(NAME, className)}
29
+ data-testid={testId}
30
+ {...props}
31
+ >
32
+ {Array.from({ length: columns }, (_, index) => (
33
+ <td
34
+ key={index}
35
+ className={cellStyle}
36
+ >
37
+ <Skeleton variant='text' />
38
+ </td>
39
+ ))}
40
+ </tr>
41
+ );
42
+ };
43
+
44
+ export default SkeletonTableRow;
@@ -0,0 +1,13 @@
1
+ import { Skeleton, SkeletonProps } from './Skeleton';
2
+
3
+ export type SkeletonTextProps = Omit<SkeletonProps, 'variant'>;
4
+
5
+ const SkeletonText = ({ count = 4, ...props }: SkeletonTextProps) => (
6
+ <Skeleton
7
+ variant='text'
8
+ count={count}
9
+ {...props}
10
+ />
11
+ );
12
+
13
+ export default SkeletonText;
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from 'vitest';
2
+ import { render } from '@testing-library/react';
3
+ import { ThemeContextProvider } from '../../../theme/useTheme';
4
+ import darkTheme from '../../../theme/dark/darkTheme';
5
+ import lightTheme from '../../../theme/light/lightTheme';
6
+ import Skeleton, { getSkeletonShimmerBackground } from '../Skeleton';
7
+
8
+ const wrap = (ui: React.ReactElement) =>
9
+ render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
10
+
11
+ describe('Skeleton', () => {
12
+ test('renders one bar per count', () => {
13
+ const { getByTestId } = wrap(
14
+ <Skeleton
15
+ count={4}
16
+ testId='skeleton'
17
+ />
18
+ );
19
+
20
+ expect(getByTestId('skeleton').childElementCount).toBe(4);
21
+ });
22
+
23
+ test('is hidden from assistive technology', () => {
24
+ const { getByTestId } = wrap(<Skeleton testId='skeleton' />);
25
+
26
+ expect(getByTestId('skeleton')).toHaveAttribute('aria-hidden', 'true');
27
+ });
28
+
29
+ test('applies a custom class name', () => {
30
+ const { getByTestId } = wrap(
31
+ <Skeleton
32
+ className='custom-class'
33
+ testId='skeleton'
34
+ />
35
+ );
36
+
37
+ expect(getByTestId('skeleton')).toHaveClass('custom-class');
38
+ });
39
+
40
+ test('renders a 61.8% final line for multiline text', () => {
41
+ const { getByTestId } = wrap(
42
+ <Skeleton
43
+ count={4}
44
+ testId='skeleton'
45
+ />
46
+ );
47
+
48
+ expect(getByTestId('skeleton').lastElementChild).toHaveStyle({
49
+ width: '61.8%',
50
+ });
51
+ });
52
+
53
+ test('does not shorten a single text line', () => {
54
+ const { getByTestId } = wrap(<Skeleton testId='skeleton' />);
55
+
56
+ expect(getByTestId('skeleton').firstElementChild).not.toHaveAttribute(
57
+ 'style'
58
+ );
59
+ });
60
+
61
+ test('Skeleton.TableRow renders one cell per column', () => {
62
+ const { getByTestId } = wrap(
63
+ <table>
64
+ <tbody>
65
+ <Skeleton.TableRow
66
+ columns={4}
67
+ testId='skeleton-row'
68
+ />
69
+ </tbody>
70
+ </table>
71
+ );
72
+
73
+ expect(getByTestId('skeleton-row').querySelectorAll('td')).toHaveLength(4);
74
+ });
75
+
76
+ test.each([
77
+ ['light', lightTheme],
78
+ ['dark', darkTheme],
79
+ ])('uses semantic theme colours for the %s shimmer', (_, theme) => {
80
+ const shimmer = getSkeletonShimmerBackground(theme);
81
+
82
+ expect(shimmer).toContain(theme.colour.bg.default);
83
+ expect(shimmer).toContain(theme.colour.surface.secondary);
84
+ expect(shimmer).toContain(theme.colour.fill.subtle);
85
+ });
86
+ });
@@ -0,0 +1,6 @@
1
+ export { default } from './Skeleton';
2
+ export type { SkeletonProps, SkeletonVariant } from './Skeleton';
3
+ export type { SkeletonAvatarProps } from './SkeletonAvatar';
4
+ export type { SkeletonInputProps } from './SkeletonInput';
5
+ export type { SkeletonTableRowProps } from './SkeletonTableRow';
6
+ export type { SkeletonTextProps } from './SkeletonText';
@@ -56,6 +56,16 @@ export type {
56
56
  ProgressBarVariant,
57
57
  } from './ProgressBar';
58
58
 
59
+ export { default as Skeleton } from './Skeleton';
60
+ export type {
61
+ SkeletonProps,
62
+ SkeletonVariant,
63
+ SkeletonTextProps,
64
+ SkeletonAvatarProps,
65
+ SkeletonInputProps,
66
+ SkeletonTableRowProps,
67
+ } from './Skeleton';
68
+
59
69
  export { default as Textarea } from './Textarea';
60
70
  export type { TextareaProps } from './Textarea';
61
71
 
@@ -215,6 +225,9 @@ export type { CalendarProps, CalendarStatuses } from './Calendar';
215
225
 
216
226
  export { default as WeekPicker } from './WeekPicker';
217
227
 
228
+ export { default as DropZone } from './DropZone';
229
+ export type { DropZoneProps, DropZoneRenderProps } from './DropZone';
230
+
218
231
  export { default as FileInput } from './FileInput';
219
232
  export type { FileInputProps } from './FileInput';
220
233
 
@@ -0,0 +1,184 @@
1
+ import { afterEach, describe, expect, test, vitest } from 'vitest';
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import { UIKIT_DND_DATA_TYPE, useDraggable } from '..';
4
+ import { clearActiveKeyboardDrag, getActiveKeyboardDrag } from '../useDropZone';
5
+
6
+ const TABLE_CELL_TYPE = 'application/x-ucl-uikit-table-cell';
7
+
8
+ const createDataTransfer = () =>
9
+ ({
10
+ effectAllowed: 'all',
11
+ setData: vitest.fn(),
12
+ setDragImage: vitest.fn(),
13
+ }) as unknown as DataTransfer;
14
+
15
+ const DraggableDemo = () => {
16
+ const { getDragProps, isDragging } = useDraggable({
17
+ data: {
18
+ column: 'Status',
19
+ row: 'Research proposal',
20
+ value: 'Approved',
21
+ },
22
+ type: TABLE_CELL_TYPE,
23
+ });
24
+
25
+ return (
26
+ <div
27
+ data-testid='drag-source'
28
+ {...getDragProps()}
29
+ >
30
+ {isDragging ? 'Dragging' : 'Ready'}
31
+ </div>
32
+ );
33
+ };
34
+
35
+ const StringDraggableDemo = () => {
36
+ const { getDragProps } = useDraggable({
37
+ data: '123',
38
+ type: TABLE_CELL_TYPE,
39
+ });
40
+
41
+ return (
42
+ <div
43
+ data-testid='string-drag-source'
44
+ {...getDragProps()}
45
+ />
46
+ );
47
+ };
48
+
49
+ const DisabledDraggableDemo = ({ disabled }: { disabled: boolean }) => {
50
+ const { getDragProps } = useDraggable({
51
+ data: 'Approved',
52
+ disabled,
53
+ type: TABLE_CELL_TYPE,
54
+ });
55
+
56
+ return (
57
+ <div
58
+ data-testid='disabled-drag-source'
59
+ {...getDragProps()}
60
+ />
61
+ );
62
+ };
63
+
64
+ describe('useDraggable', () => {
65
+ afterEach(() => {
66
+ clearActiveKeyboardDrag();
67
+ });
68
+
69
+ test('sets draggable data on drag start', () => {
70
+ const dataTransfer = createDataTransfer();
71
+
72
+ render(<DraggableDemo />);
73
+
74
+ fireEvent.dragStart(screen.getByTestId('drag-source'), {
75
+ dataTransfer,
76
+ });
77
+
78
+ expect(dataTransfer.effectAllowed).toBe('move');
79
+ expect(dataTransfer.setData).toHaveBeenCalledWith(
80
+ TABLE_CELL_TYPE,
81
+ JSON.stringify({
82
+ column: 'Status',
83
+ row: 'Research proposal',
84
+ value: 'Approved',
85
+ })
86
+ );
87
+ expect(dataTransfer.setData).toHaveBeenCalledWith(
88
+ UIKIT_DND_DATA_TYPE,
89
+ JSON.stringify({
90
+ data: JSON.stringify({
91
+ column: 'Status',
92
+ row: 'Research proposal',
93
+ value: 'Approved',
94
+ }),
95
+ type: TABLE_CELL_TYPE,
96
+ })
97
+ );
98
+ expect(screen.getByText('Dragging')).toBeInTheDocument();
99
+ });
100
+
101
+ test('clears dragging state on drag end', () => {
102
+ render(<DraggableDemo />);
103
+
104
+ fireEvent.dragStart(screen.getByTestId('drag-source'), {
105
+ dataTransfer: createDataTransfer(),
106
+ });
107
+ fireEvent.dragEnd(screen.getByTestId('drag-source'));
108
+
109
+ expect(screen.getByText('Ready')).toBeInTheDocument();
110
+ });
111
+
112
+ test('serializes strings as JSON strings', () => {
113
+ const dataTransfer = createDataTransfer();
114
+
115
+ render(<StringDraggableDemo />);
116
+
117
+ fireEvent.dragStart(screen.getByTestId('string-drag-source'), {
118
+ dataTransfer,
119
+ });
120
+
121
+ expect(dataTransfer.setData).toHaveBeenCalledWith(
122
+ TABLE_CELL_TYPE,
123
+ JSON.stringify('123')
124
+ );
125
+ });
126
+
127
+ test('supports keyboard drag start and cancel', () => {
128
+ render(<DraggableDemo />);
129
+
130
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: 'Enter' });
131
+
132
+ expect(screen.getByText('Dragging')).toBeInTheDocument();
133
+
134
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: 'Escape' });
135
+
136
+ expect(screen.getByText('Ready')).toBeInTheDocument();
137
+ });
138
+
139
+ test('does not replace an active keyboard drag', () => {
140
+ render(
141
+ <>
142
+ <DraggableDemo />
143
+ <StringDraggableDemo />
144
+ </>
145
+ );
146
+
147
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: 'Enter' });
148
+ fireEvent.keyDown(screen.getByTestId('string-drag-source'), {
149
+ key: 'Enter',
150
+ });
151
+
152
+ expect(getActiveKeyboardDrag()?.data).toEqual({
153
+ column: 'Status',
154
+ row: 'Research proposal',
155
+ value: 'Approved',
156
+ });
157
+ });
158
+
159
+ test('clears an owned keyboard drag on unmount', () => {
160
+ const { unmount } = render(<DraggableDemo />);
161
+
162
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: 'Enter' });
163
+
164
+ expect(getActiveKeyboardDrag()).not.toBeNull();
165
+
166
+ unmount();
167
+
168
+ expect(getActiveKeyboardDrag()).toBeNull();
169
+ });
170
+
171
+ test('clears an owned keyboard drag when disabled', () => {
172
+ const { rerender } = render(<DisabledDraggableDemo disabled={false} />);
173
+
174
+ fireEvent.keyDown(screen.getByTestId('disabled-drag-source'), {
175
+ key: 'Enter',
176
+ });
177
+
178
+ expect(getActiveKeyboardDrag()).not.toBeNull();
179
+
180
+ rerender(<DisabledDraggableDemo disabled />);
181
+
182
+ expect(getActiveKeyboardDrag()).toBeNull();
183
+ });
184
+ });
@@ -1,2 +1,21 @@
1
1
  export { useFocusTrap } from './useFocusTrap';
2
+ export { default as useDraggable } from './useDraggable';
3
+ export type {
4
+ DraggableProps,
5
+ UseDraggableOptions,
6
+ UseDraggableReturn,
7
+ } from './useDraggable';
8
+ export { default as useDropZone, UIKIT_DND_DATA_TYPE } from './useDropZone';
9
+ export type {
10
+ DragData,
11
+ DropZoneAcceptedTypes,
12
+ DropZoneDrop,
13
+ DropZoneEvent,
14
+ DropZoneRejection,
15
+ DropZoneRejectionReason,
16
+ DropZoneRootProps,
17
+ KeyboardDragData,
18
+ UseDropZoneOptions,
19
+ UseDropZoneReturn,
20
+ } from './useDropZone';
2
21
  export { default as useMediaQuery } from './useMediaQuery';