Skip to content

Commit 24819c3

Browse files
feat(drop-zone): add DropZone component with examples and documentation
Wraps react-aria-components DropZone with Antares styling and Flex layout. CSS module covers all RAC data-attribute states (hovered, focused, focus-visible, drop-target, disabled) with TODO markers for pending design specs. Includes SSR and browser tests, Storybook story, and barrel export from @godaddy/antares.
1 parent bbc46d5 commit 24819c3

12 files changed

Lines changed: 348 additions & 0 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
title: DropZone
3+
description: A standalone region that accepts drag-and-drop file interactions, rendering a clearly defined target area where users can drop files.
4+
---
5+
6+
import { Meta, ArgTypes, Source, Story } from '@storybook/addon-docs/blocks';
7+
import * as Stories from './drop-zone.stories.tsx';
8+
import DefaultExample from './examples/default.tsx?raw';
9+
import CustomContentExample from './examples/custom-content.tsx?raw';
10+
import DisabledExample from './examples/disabled.tsx?raw';
11+
12+
<Meta of={Stories} name="Overview" />
13+
14+
## Installation
15+
16+
```bash
17+
npm install --save @godaddy/antares
18+
```
19+
20+
## Props
21+
22+
<ArgTypes of={Stories.Props} />
23+
24+
## Examples
25+
26+
### Basic Usage
27+
28+
A minimal drop zone with the default instructional text. The `onDrop` callback receives the dropped items; use the exported `isFileDropItem` guard to narrow them to files.
29+
30+
<Story of={Stories.Default} inline />
31+
<Source language="tsx" code={DefaultExample} />
32+
33+
### Custom Content
34+
35+
Pass any React node as `children` to replace the default instructional text — for example, to describe the accepted file types and size limits.
36+
37+
<Story of={Stories.CustomContent} inline />
38+
<Source language="tsx" code={CustomContentExample} />
39+
40+
### Disabled
41+
42+
Set `isDisabled` to prevent the drop zone from accepting interactions. The zone is dimmed and the cursor indicates it is not interactive.
43+
44+
<Story of={Stories.Disabled} inline />
45+
<Source language="tsx" code={DisabledExample} />
46+
47+
## Accessibility
48+
49+
### Keyboard
50+
51+
| Key | Description |
52+
| --- | --- |
53+
| `Tab` | Moves focus to the drop zone |
54+
| `Enter` / `Space` | Opens the file picker so files can be selected without a pointer |
55+
56+
### Screen readers
57+
58+
The drop zone renders a visually hidden `<button>` inside the container. Assistive technology discovers and announces this button as an interactive element — activating it opens the native file picker without a pointer. The button's accessible name defaults to "DropZone"; override it with `aria-label` on the `DropZone` component to provide more specific context (e.g. `aria-label="Upload profile photo"`).
59+
60+
## Best Practices
61+
62+
- Use the drop zone alongside a button-based file picker — drag-and-drop is a convenience, never the only way to upload.
63+
- Replace the default text with `children` to communicate the accepted file types and size limits up front.
64+
- Let the drop zone fill its container; constrain it with the surrounding layout rather than setting a fixed width on the component itself.
65+
- Reflect upload state (validating, uploading, error) outside the drop zone so the target area stays clear and predictable.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
'use client';
2+
import { getMeta, getComponentDocs, getStory } from '@bento/storybook-addon-helpers';
3+
import { DropZone } from './src/index.tsx';
4+
import { DefaultExample } from './examples/default.tsx';
5+
import { CustomContentExample } from './examples/custom-content.tsx';
6+
import { DisabledExample } from './examples/disabled.tsx';
7+
import { PlaygroundExample, type PlaygroundExampleProps } from './examples/drop-zone-playground.tsx';
8+
9+
export default getMeta({
10+
title: 'components/DropZone'
11+
});
12+
13+
export const Props = getComponentDocs(DropZone);
14+
15+
export const Default = getStory(DefaultExample);
16+
17+
export const CustomContent = getStory(CustomContentExample);
18+
19+
export const Disabled = getStory(DisabledExample);
20+
21+
export const Playground = {
22+
render: (args: PlaygroundExampleProps) => <PlaygroundExample {...args} />,
23+
args: {
24+
isDisabled: false
25+
},
26+
argTypes: {
27+
isDisabled: {
28+
control: 'boolean',
29+
description: 'Whether the drop zone is disabled and cannot accept drops'
30+
}
31+
}
32+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { DropZone, isFileDropItem, Flex, Text } from '@godaddy/antares';
2+
3+
export function CustomContentExample() {
4+
return (
5+
<DropZone
6+
onDrop={function handleDrop(e) {
7+
const files = e.items.filter(isFileDropItem);
8+
console.log('Dropped files:', files);
9+
}}
10+
>
11+
<Flex direction="column" alignItems="center" gap="xs">
12+
<Text>Drag your images here</Text>
13+
<Text>PNG, JPG, or GIF up to 10MB</Text>
14+
</Flex>
15+
</DropZone>
16+
);
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { DropZone, type DropZoneProps, isFileDropItem } from '@godaddy/antares';
2+
3+
export function DefaultExample(props?: Partial<DropZoneProps>) {
4+
return (
5+
<DropZone
6+
onDrop={function handleDrop(e) {
7+
const files = e.items.filter(isFileDropItem);
8+
console.log('Dropped files:', files);
9+
}}
10+
{...props}
11+
/>
12+
);
13+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { DropZone } from '@godaddy/antares';
2+
3+
export function DisabledExample() {
4+
return <DropZone isDisabled />;
5+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { DropZone, type DropZoneProps, isFileDropItem } from '@godaddy/antares';
2+
3+
export type PlaygroundExampleProps = Pick<DropZoneProps, 'isDisabled'>;
4+
5+
export function PlaygroundExample({ isDisabled = false }: PlaygroundExampleProps) {
6+
return (
7+
<DropZone
8+
isDisabled={isDisabled}
9+
onDrop={function handleDrop(e) {
10+
const files = e.items.filter(isFileDropItem);
11+
console.log('Dropped files:', files);
12+
}}
13+
/>
14+
);
15+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
.dropZone {
2+
--drop-zone-border-color: var(--ux-f7kpiw, #00a4a6);
3+
--drop-zone-bg: rgba(216, 239, 239, 0.9);
4+
--drop-zone-bg-active: rgba(216, 239, 239, 1);
5+
--drop-zone-text-color: var(--ux-1leynsm, #111111);
6+
--drop-zone-radius: var(--ux-2jubes, 16px);
7+
8+
box-sizing: border-box;
9+
width: 100%;
10+
border: 1.5px dashed var(--drop-zone-border-color);
11+
border-radius: var(--drop-zone-radius);
12+
background: var(--drop-zone-bg);
13+
14+
/* Hovered with a mouse. TODO: awaiting design spec */
15+
&:where([data-hovered]) {
16+
}
17+
18+
/* Focused via mouse or keyboard. TODO: awaiting design spec */
19+
&:where([data-focused]) {
20+
}
21+
22+
/* Keyboard focused. */
23+
&:where([data-focus-visible]) {
24+
outline: 2px solid Highlight;
25+
outline-offset: 2px;
26+
}
27+
28+
/* Active drop target. */
29+
&:where([data-drop-target]) {
30+
border-style: solid;
31+
background: var(--drop-zone-bg-active);
32+
}
33+
34+
/* Disabled. */
35+
&:where([data-disabled]) {
36+
opacity: 0.4;
37+
cursor: not-allowed;
38+
}
39+
40+
/* Invalid drop (consumer-controlled). TODO: awaiting design spec */
41+
/* Processing drop (consumer-controlled). TODO: awaiting design spec */
42+
}
43+
44+
.text {
45+
font-size: var(--ux-18ime9a, 1.375rem);
46+
font-weight: 700;
47+
line-height: 1.25;
48+
color: var(--drop-zone-text-color);
49+
text-align: center;
50+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import type React from 'react';
2+
import { forwardRef } from 'react';
3+
import { cx } from 'cva';
4+
import {
5+
DropZone as RACDropZone,
6+
type DropZoneProps as RACDropZoneProps,
7+
isFileDropItem,
8+
isTextDropItem,
9+
isDirectoryDropItem
10+
} from 'react-aria-components';
11+
import { Flex } from '#components/layout/flex';
12+
import { Text } from '#components/text';
13+
import styles from './index.module.css';
14+
15+
export { isFileDropItem, isTextDropItem, isDirectoryDropItem };
16+
17+
/**
18+
* Props for the {@link DropZone} component.
19+
*/
20+
export interface DropZoneProps extends Omit<RACDropZoneProps, 'className' | 'children'> {
21+
/** Content displayed inside the drop zone. @default "Drop files to upload." */
22+
children?: React.ReactNode;
23+
24+
/** Additional CSS class names applied to the root element. */
25+
className?: string;
26+
}
27+
28+
/**
29+
* A standalone region that accepts drag-and-drop file interactions.
30+
*
31+
* @example
32+
* ```tsx
33+
* <DropZone onDrop={(e) => {
34+
* const files = e.items.filter(isFileDropItem);
35+
* // handle files
36+
* }} />
37+
* ```
38+
*
39+
* @param props - {@link DropZoneProps}
40+
*/
41+
export const DropZone = forwardRef<HTMLDivElement, DropZoneProps>(function DropZone(props, ref) {
42+
const { children, className, ...rest } = props;
43+
44+
return (
45+
<Flex
46+
{...rest}
47+
ref={ref}
48+
direction="column"
49+
alignItems="center"
50+
justifyContent="center"
51+
padding="lg"
52+
className={cx(styles.dropZone, className)}
53+
as={RACDropZone}
54+
>
55+
{children ?? <Text className={styles.text}>Drop files to upload.</Text>}
56+
</Flex>
57+
);
58+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
3+
exports[`@godaddy/antares > #DropZone > renders the default drop zone 1`] = `"<div class="box dropZone" style="padding:var(--sp-lg, 12px);display:flex;flex-direction:column;justify-content:center;align-items:center" data-rac=""><div style="border:0;clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap"><button type="button" tabindex="0" data-react-aria-pressable="true" id="react-aria-_R_0H1_" aria-label="DropZone" aria-labelledby="react-aria-_R_0H1_ react-aria-_R_0_"></button></div><span class="text text" id="react-aria-_R_0_" slot="label">Drop files to upload.</span></div>"`;
4+
5+
exports[`@godaddy/antares > #DropZone > renders the disabled drop zone 1`] = `"<div class="box dropZone" style="padding:var(--sp-lg, 12px);display:flex;flex-direction:column;justify-content:center;align-items:center" data-rac="" data-disabled="true"><div style="border:0;clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap"><button type="button" disabled="" data-react-aria-pressable="true" id="react-aria-_R_0H1_" aria-label="DropZone" aria-labelledby="react-aria-_R_0H1_ react-aria-_R_0_"></button></div><span class="text text" id="react-aria-_R_0_" slot="label">Drop files to upload.</span></div>"`;
6+
7+
exports[`@godaddy/antares > #DropZone > renders the drop zone with custom content 1`] = `"<div class="box dropZone" style="padding:var(--sp-lg, 12px);display:flex;flex-direction:column;justify-content:center;align-items:center" data-rac=""><div style="border:0;clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap"><button type="button" tabindex="0" data-react-aria-pressable="true" id="react-aria-_R_0H1_" aria-label="DropZone" aria-labelledby="react-aria-_R_0H1_ react-aria-_R_0_"></button></div><div class="box" style="display:flex;flex-direction:column;align-items:center;gap:var(--sp-xs, 2px)"><span class="text" id="react-aria-_R_0_" slot="label">Drag your images here</span><span class="text" id="react-aria-_R_0_" slot="label">PNG, JPG, or GIF up to 10MB</span></div></div>"`;
8+
9+
exports[`@godaddy/antares > #DropZone > renders the playground drop zone 1`] = `"<div class="box dropZone" style="padding:var(--sp-lg, 12px);display:flex;flex-direction:column;justify-content:center;align-items:center" data-rac=""><div style="border:0;clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px;white-space:nowrap"><button type="button" tabindex="0" data-react-aria-pressable="true" id="react-aria-_R_0H1_" aria-label="DropZone" aria-labelledby="react-aria-_R_0H1_ react-aria-_R_0_"></button></div><span class="text text" id="react-aria-_R_0_" slot="label">Drop files to upload.</span></div>"`;
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { render } from 'vitest-browser-react';
3+
import { DefaultExample } from '../examples/default.tsx';
4+
import { DisabledExample } from '../examples/disabled.tsx';
5+
import { CustomContentExample } from '../examples/custom-content.tsx';
6+
7+
describe('@godaddy/antares', function antares() {
8+
describe('#DropZone', function dropZoneTests() {
9+
it('renders the drop zone container', async function rendersContainer() {
10+
const { container } = await render(<DefaultExample />);
11+
const dropZone = container.firstElementChild as HTMLElement;
12+
13+
expect(dropZone).not.toBeNull();
14+
});
15+
16+
it('renders default instructional text', async function rendersDefaultText() {
17+
const { getByText } = await render(<DefaultExample />);
18+
19+
await expect.element(getByText('Drop files to upload.')).toBeVisible();
20+
});
21+
22+
it('renders custom children when provided', async function rendersCustomChildren() {
23+
const { getByText } = await render(<CustomContentExample />);
24+
25+
await expect.element(getByText('Drag your images here')).toBeVisible();
26+
await expect.element(getByText('PNG, JPG, or GIF up to 10MB')).toBeVisible();
27+
});
28+
29+
it('does not render default text when custom children are provided', async function noDefaultWithCustom() {
30+
const { container } = await render(<CustomContentExample />);
31+
32+
expect(container.textContent).not.toContain('Drop files to upload.');
33+
});
34+
35+
it('applies data-disabled when isDisabled is true', async function disabledAttribute() {
36+
const { container } = await render(<DisabledExample />);
37+
const dropZone = container.querySelector('[data-disabled]');
38+
39+
expect(dropZone).not.toBeNull();
40+
});
41+
});
42+
});

0 commit comments

Comments
 (0)