uikit-react-public 0.45.5 → 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 (39) 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/Skeleton/Skeleton.d.ts +35 -0
  6. package/dist/components/Skeleton/Skeleton.stories.d.ts +27 -0
  7. package/dist/components/Skeleton/SkeletonAvatar.d.ts +7 -0
  8. package/dist/components/Skeleton/SkeletonInput.d.ts +4 -0
  9. package/dist/components/Skeleton/SkeletonTableRow.d.ts +9 -0
  10. package/dist/components/Skeleton/SkeletonText.d.ts +4 -0
  11. package/dist/components/Skeleton/__tests__/Skeleton.test.d.ts +1 -0
  12. package/dist/components/Skeleton/index.d.ts +6 -0
  13. package/dist/components/index.d.ts +4 -0
  14. package/dist/hooks/__tests__/useDraggable.test.d.ts +1 -0
  15. package/dist/hooks/index.d.ts +4 -0
  16. package/dist/hooks/useDraggable.d.ts +31 -0
  17. package/dist/hooks/useDropZone.d.ts +57 -0
  18. package/dist/index.js +7229 -6665
  19. package/lib/components/DropZone/DropZone.mdx +72 -0
  20. package/lib/components/DropZone/DropZone.stories.tsx +139 -0
  21. package/lib/components/DropZone/DropZone.tsx +213 -0
  22. package/lib/components/DropZone/__tests__/DropZone.test.tsx +378 -0
  23. package/lib/components/DropZone/__tests__/__snapshots__/DropZone.test.tsx.snap +46 -0
  24. package/lib/components/DropZone/index.ts +2 -0
  25. package/lib/components/Skeleton/Skeleton.mdx +34 -0
  26. package/lib/components/Skeleton/Skeleton.stories.tsx +77 -0
  27. package/lib/components/Skeleton/Skeleton.tsx +158 -0
  28. package/lib/components/Skeleton/SkeletonAvatar.tsx +20 -0
  29. package/lib/components/Skeleton/SkeletonInput.tsx +13 -0
  30. package/lib/components/Skeleton/SkeletonTableRow.tsx +44 -0
  31. package/lib/components/Skeleton/SkeletonText.tsx +13 -0
  32. package/lib/components/Skeleton/__tests__/Skeleton.test.tsx +86 -0
  33. package/lib/components/Skeleton/index.ts +6 -0
  34. package/lib/components/index.ts +13 -0
  35. package/lib/hooks/__tests__/useDraggable.test.tsx +184 -0
  36. package/lib/hooks/index.ts +19 -0
  37. package/lib/hooks/useDraggable.ts +243 -0
  38. package/lib/hooks/useDropZone.ts +418 -0
  39. package/package.json +1 -1
@@ -0,0 +1,378 @@
1
+ import { ReactElement } from 'react';
2
+ import { afterEach, describe, expect, test, vitest } from 'vitest';
3
+ import { fireEvent, render, screen } from '@testing-library/react';
4
+ import DropZone from '../DropZone';
5
+ import { ThemeContextProvider } from '../../../theme/useTheme';
6
+ import { useDraggable, useDropZone } from '../../../hooks';
7
+ import {
8
+ clearActiveKeyboardDrag,
9
+ getActiveKeyboardDrag,
10
+ } from '../../../hooks/useDropZone';
11
+
12
+ const TABLE_CELL_TYPE = 'application/x-ucl-uikit-table-cell';
13
+
14
+ const wrap = (ui: ReactElement) =>
15
+ render(<ThemeContextProvider>{ui}</ThemeContextProvider>);
16
+
17
+ const createDataTransfer = (data: Record<string, string>) =>
18
+ ({
19
+ dropEffect: 'none',
20
+ files: [],
21
+ getData: vitest.fn((type: string) => data[type] ?? ''),
22
+ setData: vitest.fn(),
23
+ types: Object.keys(data),
24
+ }) as unknown as DataTransfer;
25
+
26
+ describe('DropZone', () => {
27
+ afterEach(() => {
28
+ clearActiveKeyboardDrag();
29
+ });
30
+
31
+ test('snapshot: no props', () => {
32
+ const renderResult = wrap(<DropZone />);
33
+
34
+ expect(renderResult.container.firstChild).toMatchSnapshot();
35
+ });
36
+
37
+ test('renders with default test id and labelled group role', () => {
38
+ wrap(<DropZone />);
39
+
40
+ expect(screen.getByTestId('ucl-uikit-drop-zone')).toBeInTheDocument();
41
+ expect(
42
+ screen.getByRole('group', { name: 'Drop zone' })
43
+ ).toBeInTheDocument();
44
+ });
45
+
46
+ test('falls back to generic copy when accepted types are empty', () => {
47
+ wrap(<DropZone acceptedTypes={[]} />);
48
+
49
+ expect(screen.getByText('Drop an item here')).toBeInTheDocument();
50
+ });
51
+
52
+ test('does not add a role when using the hook directly', () => {
53
+ const HookDropZone = () => {
54
+ const { getRootProps } = useDropZone();
55
+
56
+ return (
57
+ <section
58
+ data-testid='hook-drop-zone'
59
+ {...getRootProps()}
60
+ />
61
+ );
62
+ };
63
+
64
+ render(<HookDropZone />);
65
+
66
+ expect(screen.getByTestId('hook-drop-zone')).not.toHaveAttribute('role');
67
+ });
68
+
69
+ test('sets drag state data attributes during an accepted drag', () => {
70
+ wrap(<DropZone acceptedTypes={TABLE_CELL_TYPE} />);
71
+
72
+ const dropZone = screen.getByTestId('ucl-uikit-drop-zone');
73
+
74
+ fireEvent.dragEnter(dropZone, {
75
+ dataTransfer: createDataTransfer({
76
+ [TABLE_CELL_TYPE]: JSON.stringify({
77
+ column: 'Status',
78
+ row: 'Research proposal',
79
+ value: 'Approved',
80
+ }),
81
+ }),
82
+ });
83
+
84
+ expect(dropZone).toHaveAttribute('data-drag-active', 'true');
85
+ expect(dropZone).toHaveAttribute('data-drag-accept', 'true');
86
+ expect(dropZone).toHaveAttribute('data-drag-reject', 'false');
87
+ });
88
+
89
+ test('calls onDrop with parsed app data when a matching type is dropped', () => {
90
+ const onDrop = vitest.fn();
91
+ const dataTransfer = createDataTransfer({
92
+ [TABLE_CELL_TYPE]: JSON.stringify({
93
+ column: 'Status',
94
+ row: 'Research proposal',
95
+ value: 'Approved',
96
+ }),
97
+ });
98
+
99
+ wrap(
100
+ <DropZone
101
+ acceptedTypes={TABLE_CELL_TYPE}
102
+ onDrop={onDrop}
103
+ />
104
+ );
105
+
106
+ fireEvent.drop(screen.getByTestId('ucl-uikit-drop-zone'), {
107
+ dataTransfer,
108
+ });
109
+
110
+ expect(onDrop).toHaveBeenCalledWith(
111
+ expect.objectContaining({
112
+ data: {
113
+ column: 'Status',
114
+ row: 'Research proposal',
115
+ value: 'Approved',
116
+ },
117
+ dataTransfer,
118
+ rawData: JSON.stringify({
119
+ column: 'Status',
120
+ row: 'Research proposal',
121
+ value: 'Approved',
122
+ }),
123
+ type: TABLE_CELL_TYPE,
124
+ }),
125
+ expect.any(Object)
126
+ );
127
+ });
128
+
129
+ test('calls onReject when a dropped type is not accepted', () => {
130
+ const onDrop = vitest.fn();
131
+ const onReject = vitest.fn();
132
+
133
+ wrap(
134
+ <DropZone
135
+ acceptedTypes={TABLE_CELL_TYPE}
136
+ onDrop={onDrop}
137
+ onReject={onReject}
138
+ />
139
+ );
140
+
141
+ fireEvent.drop(screen.getByTestId('ucl-uikit-drop-zone'), {
142
+ dataTransfer: createDataTransfer({
143
+ 'text/plain': 'Approved',
144
+ }),
145
+ });
146
+
147
+ expect(onDrop).not.toHaveBeenCalled();
148
+ expect(onReject).toHaveBeenCalledWith(
149
+ {
150
+ drop: null,
151
+ reason: 'invalid-type',
152
+ },
153
+ expect.any(Object)
154
+ );
155
+ });
156
+
157
+ test('shows rejection copy before release copy for rejected drags', () => {
158
+ wrap(<DropZone acceptedTypes={TABLE_CELL_TYPE} />);
159
+
160
+ fireEvent.dragEnter(screen.getByTestId('ucl-uikit-drop-zone'), {
161
+ dataTransfer: createDataTransfer({
162
+ 'text/plain': 'Approved',
163
+ }),
164
+ });
165
+
166
+ expect(
167
+ screen.getByText('This item cannot be dropped here')
168
+ ).toBeInTheDocument();
169
+ expect(screen.queryByText('Release to drop')).not.toBeInTheDocument();
170
+ });
171
+
172
+ test('calls onReject when canDrop returns false', () => {
173
+ const onReject = vitest.fn();
174
+
175
+ wrap(
176
+ <DropZone<{ locked: boolean }>
177
+ acceptedTypes={TABLE_CELL_TYPE}
178
+ canDrop={(drop) => !drop.data.locked}
179
+ onReject={onReject}
180
+ />
181
+ );
182
+
183
+ fireEvent.drop(screen.getByTestId('ucl-uikit-drop-zone'), {
184
+ dataTransfer: createDataTransfer({
185
+ [TABLE_CELL_TYPE]: JSON.stringify({ locked: true }),
186
+ }),
187
+ });
188
+
189
+ expect(onReject).toHaveBeenCalledWith(
190
+ {
191
+ drop: expect.objectContaining({
192
+ data: { locked: true },
193
+ type: TABLE_CELL_TYPE,
194
+ }),
195
+ reason: 'cannot-drop',
196
+ },
197
+ expect.any(Object)
198
+ );
199
+ });
200
+
201
+ test('does not call onDrop when disabled', () => {
202
+ const onDrop = vitest.fn();
203
+
204
+ wrap(
205
+ <DropZone
206
+ acceptedTypes={TABLE_CELL_TYPE}
207
+ disabled
208
+ onDrop={onDrop}
209
+ />
210
+ );
211
+
212
+ fireEvent.drop(screen.getByTestId('ucl-uikit-drop-zone'), {
213
+ dataTransfer: createDataTransfer({
214
+ [TABLE_CELL_TYPE]: 'Approved',
215
+ }),
216
+ });
217
+
218
+ expect(onDrop).not.toHaveBeenCalled();
219
+ });
220
+
221
+ test('passes drag state to render-prop children', () => {
222
+ wrap(
223
+ <DropZone acceptedTypes={TABLE_CELL_TYPE}>
224
+ {({ isDragActive }) => (
225
+ <span>{isDragActive ? 'Release to drop' : 'Drop a table cell'}</span>
226
+ )}
227
+ </DropZone>
228
+ );
229
+
230
+ const dropZone = screen.getByTestId('ucl-uikit-drop-zone');
231
+
232
+ expect(screen.getByText('Drop a table cell')).toBeInTheDocument();
233
+
234
+ fireEvent.dragEnter(dropZone, {
235
+ dataTransfer: createDataTransfer({
236
+ [TABLE_CELL_TYPE]: 'Approved',
237
+ }),
238
+ });
239
+
240
+ expect(screen.getByText('Release to drop')).toBeInTheDocument();
241
+ });
242
+
243
+ test('supports keyboard drag and drop between UIKit hooks', () => {
244
+ const onDrop = vitest.fn();
245
+
246
+ const KeyboardDndDemo = () => {
247
+ const { getDragProps } = useDraggable({
248
+ data: { value: 'Approved' },
249
+ type: TABLE_CELL_TYPE,
250
+ });
251
+ const { getRootProps } = useDropZone<{ value: string }>({
252
+ acceptedTypes: TABLE_CELL_TYPE,
253
+ onDrop,
254
+ });
255
+
256
+ return (
257
+ <>
258
+ <button
259
+ data-testid='drag-source'
260
+ type='button'
261
+ {...getDragProps()}
262
+ >
263
+ Approved
264
+ </button>
265
+ <div
266
+ data-testid='drop-target'
267
+ {...getRootProps()}
268
+ />
269
+ </>
270
+ );
271
+ };
272
+
273
+ render(<KeyboardDndDemo />);
274
+
275
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: ' ' });
276
+ fireEvent.keyDown(screen.getByTestId('drop-target'), { key: 'Enter' });
277
+
278
+ expect(onDrop).toHaveBeenCalledWith(
279
+ expect.objectContaining({
280
+ data: { value: 'Approved' },
281
+ dataTransfer: null,
282
+ files: [],
283
+ rawData: JSON.stringify({ value: 'Approved' }),
284
+ type: TABLE_CELL_TYPE,
285
+ }),
286
+ expect.any(Object)
287
+ );
288
+ });
289
+
290
+ test('honours keyboardDrop false on the DropZone component', () => {
291
+ const onDrop = vitest.fn();
292
+
293
+ const KeyboardDndDemo = () => {
294
+ const { getDragProps } = useDraggable({
295
+ data: { value: 'Approved' },
296
+ type: TABLE_CELL_TYPE,
297
+ });
298
+
299
+ return (
300
+ <>
301
+ <button
302
+ data-testid='drag-source'
303
+ type='button'
304
+ {...getDragProps()}
305
+ >
306
+ Approved
307
+ </button>
308
+ <DropZone
309
+ acceptedTypes={TABLE_CELL_TYPE}
310
+ keyboardDrop={false}
311
+ onDrop={onDrop}
312
+ testId='drop-target'
313
+ />
314
+ </>
315
+ );
316
+ };
317
+
318
+ wrap(<KeyboardDndDemo />);
319
+
320
+ fireEvent.keyDown(screen.getByTestId('drag-source'), { key: 'Enter' });
321
+ fireEvent.keyDown(screen.getByTestId('drop-target'), { key: 'Enter' });
322
+
323
+ expect(onDrop).not.toHaveBeenCalled();
324
+ expect(screen.getByTestId('drop-target')).not.toHaveAttribute(
325
+ 'keyboardDrop'
326
+ );
327
+ });
328
+
329
+ test('supports keyboard drop when drag and drop props are composed on one item', () => {
330
+ const onDrop = vitest.fn();
331
+ const onReject = vitest.fn();
332
+
333
+ const ReorderItem = ({ id }: { id: string }) => {
334
+ const { getDragProps } = useDraggable({
335
+ data: { id },
336
+ type: TABLE_CELL_TYPE,
337
+ });
338
+ const { getRootProps } = useDropZone<{ id: string }>({
339
+ acceptedTypes: TABLE_CELL_TYPE,
340
+ canDrop: (drop) => drop.data.id !== id,
341
+ onDrop,
342
+ onReject,
343
+ });
344
+
345
+ return (
346
+ <li
347
+ data-testid={`item-${id}`}
348
+ {...getRootProps<HTMLLIElement>(getDragProps<HTMLLIElement>())}
349
+ >
350
+ {id}
351
+ </li>
352
+ );
353
+ };
354
+
355
+ render(
356
+ <ul>
357
+ <ReorderItem id='first' />
358
+ <ReorderItem id='second' />
359
+ </ul>
360
+ );
361
+
362
+ fireEvent.keyDown(screen.getByTestId('item-first'), { key: 'Enter' });
363
+
364
+ expect(onDrop).not.toHaveBeenCalled();
365
+ expect(onReject).not.toHaveBeenCalled();
366
+ expect(getActiveKeyboardDrag()?.data).toEqual({ id: 'first' });
367
+
368
+ fireEvent.keyDown(screen.getByTestId('item-second'), { key: 'Enter' });
369
+
370
+ expect(onDrop).toHaveBeenCalledWith(
371
+ expect.objectContaining({
372
+ data: { id: 'first' },
373
+ type: TABLE_CELL_TYPE,
374
+ }),
375
+ expect.any(Object)
376
+ );
377
+ });
378
+ });
@@ -0,0 +1,46 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`DropZone > snapshot: no props 1`] = `
4
+ <div
5
+ aria-label="Drop zone"
6
+ class="ucl-uikit-drop-zone css-vpriu7"
7
+ data-drag-accept="false"
8
+ data-drag-active="false"
9
+ data-drag-reject="false"
10
+ data-testid="ucl-uikit-drop-zone"
11
+ role="group"
12
+ tabindex="0"
13
+ >
14
+ <svg
15
+ aria-hidden="true"
16
+ class="ucl-uikit-icon css-17h2vfb"
17
+ data-testid="ucl-uikit-icon"
18
+ fill="none"
19
+ focusable="false"
20
+ height="24"
21
+ stroke="currentColor"
22
+ stroke-linecap="round"
23
+ stroke-linejoin="round"
24
+ stroke-width="2"
25
+ viewBox="0 0 24 24"
26
+ width="24"
27
+ xmlns="http://www.w3.org/2000/svg"
28
+ >
29
+ <path
30
+ d="m5 9-3 3 3 3M9 5l3-3 3 3m0 14-3 3-3-3M19 9l3 3-3 3M2 12h20M12 2v20"
31
+ />
32
+ </svg>
33
+ <div
34
+ class="ucl-uikit-paragraph css-12ufxmf"
35
+ data-testid="ucl-uikit-paragraph"
36
+ >
37
+ Drop an item here
38
+ </div>
39
+ <span
40
+ class="ucl-uikit-text css-v0obke"
41
+ data-testid="ucl-uikit-text"
42
+ >
43
+ Drag a compatible item into this area
44
+ </span>
45
+ </div>
46
+ `;
@@ -0,0 +1,2 @@
1
+ export { default } from './DropZone';
2
+ export type { DropZoneProps, DropZoneRenderProps } from './DropZone';
@@ -0,0 +1,34 @@
1
+ import * as SkeletonStories from "./Skeleton.stories";
2
+ import { Meta, Title, Subtitle, Canvas, Controls } from "@storybook/addon-docs/blocks";
3
+
4
+ <Meta of={SkeletonStories} />
5
+ <Title />
6
+ <Subtitle>A placeholder that mirrors the shape of content while it loads.</Subtitle>
7
+
8
+ Use `<Skeleton>` when a region's layout is known but its data is still loading. Numeric `width` and `height` values are treated as pixels; CSS strings such as `40%` are passed through.
9
+
10
+ ## Variants
11
+
12
+ ### Circle
13
+ The default 72px circle matches the Figma avatar placeholder.
14
+
15
+ <Canvas of={SkeletonStories.Circle} />
16
+
17
+ ### Rectangle
18
+ Use explicit dimensions to reserve the final content's space. Rectangles have square corners by default; pass a `radius` token when the content will be rounded.
19
+
20
+ <Canvas of={SkeletonStories.Landscape} />
21
+
22
+ ### Text
23
+ Text placeholders are 8px high with a 16px gap. With multiple lines, the final line is 61.8% wide.
24
+
25
+ <Canvas of={SkeletonStories.TextLines} />
26
+
27
+ ## Accessibility
28
+
29
+ Skeleton shapes are decorative and hidden from assistive technology. Set `aria-busy="true"` on the region whose content is loading and provide one visually hidden loading message for that region. The shimmer is removed when the user prefers reduced motion.
30
+
31
+ ## Props
32
+
33
+ <Canvas of={SkeletonStories.Default} sourceState="hidden" />
34
+ <Controls of={SkeletonStories.Default} />
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
+ import { css } from '@emotion/css';
3
+ import Skeleton from './Skeleton';
4
+
5
+ const meta = {
6
+ title: 'Components/Skeleton',
7
+ component: Skeleton,
8
+ parameters: { layout: 'padded' },
9
+ args: {
10
+ variant: 'text',
11
+ },
12
+ argTypes: {
13
+ variant: {
14
+ options: ['text', 'circle', 'rect'],
15
+ control: { type: 'radio' },
16
+ },
17
+ },
18
+ } satisfies Meta<typeof Skeleton>;
19
+
20
+ export default meta;
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const Default: Story = {};
24
+
25
+ export const Circle: Story = {
26
+ args: { variant: 'circle', width: 72, height: 72 },
27
+ };
28
+
29
+ export const Landscape: Story = {
30
+ args: { variant: 'rect', width: 637, height: 359 },
31
+ };
32
+
33
+ export const Portrait: Story = {
34
+ args: { variant: 'rect', width: 358, height: 620.69 },
35
+ };
36
+
37
+ export const TextLines: Story = {
38
+ args: { variant: 'text', width: 461, count: 4 },
39
+ };
40
+
41
+ export const FigmaExamples: Story = {
42
+ parameters: { layout: 'padded' },
43
+ render: () => (
44
+ <div
45
+ className={css`
46
+ position: relative;
47
+ width: 677px;
48
+ height: 1504px;
49
+ `}
50
+ >
51
+ <Skeleton
52
+ variant='circle'
53
+ width={72}
54
+ height={72}
55
+ style={{ position: 'absolute', top: 20, left: 20 }}
56
+ />
57
+ <Skeleton
58
+ variant='rect'
59
+ width={637}
60
+ height={359}
61
+ style={{ position: 'absolute', top: 139, left: 20 }}
62
+ />
63
+ <Skeleton
64
+ variant='rect'
65
+ width={358}
66
+ height={620.69}
67
+ style={{ position: 'absolute', top: 545, left: 20 }}
68
+ />
69
+ <Skeleton
70
+ variant='text'
71
+ width={461}
72
+ count={4}
73
+ style={{ position: 'absolute', top: 1246, left: 20 }}
74
+ />
75
+ </div>
76
+ ),
77
+ };
@@ -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;