feat: add DropZone Storybook coverage for file upload states (#9690)

## Summary
- align the linear-mode `DropZone` upload indicator with the Figma file
upload states
- add a co-located Storybook story for the default and hover variants
- add a `forceHovered` preview prop so Storybook can render the hover
state deterministically

## Validation
- `pnpm typecheck` (run in the original workspace with dependencies
installed)
- `pnpm lint` (passes with one pre-existing warning in
`src/lib/litegraph/src/ContextMenu.ts`)
- Storybook smoke check is currently blocked by an existing workspace
issue: `vite-plugin-inspect` fails with `Can not found environment
context for client`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9690-feat-add-DropZone-Storybook-coverage-for-file-upload-states-31f6d73d365081ae9eabdde6b5915f26)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
Dante
2026-03-12 15:39:08 +09:00
committed by GitHub
parent c111fb7758
commit f1fc5fa9b3
2 changed files with 187 additions and 20 deletions

View File

@@ -0,0 +1,131 @@
import type {
ComponentPropsAndSlots,
Meta,
StoryObj
} from '@storybook/vue3-vite'
import { ref } from 'vue'
import DropZone from './DropZone.vue'
type StoryArgs = ComponentPropsAndSlots<typeof DropZone>
const defaultLabel = 'Click to browse or drag an image'
const defaultIconClass = 'icon-[lucide--image]'
function createFileInput(onFile: (file: File) => void) {
const input = document.createElement('input')
input.type = 'file'
input.accept = 'image/*'
input.addEventListener('change', () => {
const file = input.files?.[0]
if (file) onFile(file)
})
return input
}
function fileToObjectUrl(file: File): string {
return URL.createObjectURL(file)
}
function extractDroppedImageFile(e: DragEvent): File | undefined {
return Array.from(e.dataTransfer?.files ?? []).find((f) =>
f.type.startsWith('image/')
)
}
const renderStory = (args: StoryArgs) => ({
components: { DropZone },
setup() {
const imageUrl = ref<string | undefined>(undefined)
const hovered = ref(false)
function handleFile(file: File) {
imageUrl.value = fileToObjectUrl(file)
}
const onDragOver = (e: DragEvent) => {
if (!e.dataTransfer?.items) return false
return Array.from(e.dataTransfer.items).some(
(item) => item.kind === 'file' && item.type.startsWith('image/')
)
}
const onDragDrop = (e: DragEvent) => {
const file = extractDroppedImageFile(e)
if (file) handleFile(file)
return !!file
}
const onClick = () => {
createFileInput(handleFile).click()
}
const dropIndicator = ref({
...args.dropIndicator,
onClick
})
return { args, onDragOver, onDragDrop, dropIndicator, imageUrl, hovered }
},
template: `
<div
@mouseenter="hovered = true"
@mouseleave="hovered = false"
>
<DropZone
v-bind="args"
:on-drag-over="onDragOver"
:on-drag-drop="onDragDrop"
:force-hovered="hovered"
:drop-indicator="{
...dropIndicator,
imageUrl: imageUrl ?? dropIndicator.imageUrl
}"
/>
</div>
`
})
const meta: Meta<StoryArgs> = {
title: 'Components/FileUpload',
component: DropZone,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: {
description: {
component:
'Linear mode drag-and-drop target with a file upload indicator. Click to browse or drag an image file to upload.'
}
}
},
argTypes: {
onDragOver: { table: { disable: true } },
onDragDrop: { table: { disable: true } },
dropIndicator: { control: false },
forceHovered: { table: { disable: true } }
},
args: {
dropIndicator: {
label: defaultLabel,
iconClass: defaultIconClass
}
},
decorators: [
(story) => ({
components: { story },
template: `
<div class="w-[440px] rounded-xl bg-component-node-background p-4">
<story />
</div>
`
})
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: renderStory
}

View File

@@ -1,10 +1,15 @@
<script setup lang="ts">
import { useDropZone } from '@vueuse/core'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const props = defineProps<{
const {
onDragOver,
onDragDrop,
dropIndicator,
forceHovered = false
} = defineProps<{
onDragOver?: (e: DragEvent) => boolean
onDragDrop?: (e: DragEvent) => Promise<boolean> | boolean
dropIndicator?: {
@@ -13,6 +18,7 @@ const props = defineProps<{
label?: string
onClick?: (e: MouseEvent) => void
}
forceHovered?: boolean
}>()
const dropZoneRef = ref<HTMLElement | null>(null)
@@ -23,53 +29,83 @@ const { isOverDropZone } = useDropZone(dropZoneRef, {
// Stop propagation to prevent global handlers from creating a new node
event?.stopPropagation()
if (props.onDragDrop && event) {
props.onDragDrop(event)
if (onDragDrop && event) {
onDragDrop(event)
}
canAcceptDrop.value = false
},
onOver: (_, event) => {
if (props.onDragOver && event) {
canAcceptDrop.value = props.onDragOver(event)
if (onDragOver && event) {
canAcceptDrop.value = onDragOver(event)
}
},
onLeave: () => {
canAcceptDrop.value = false
}
})
const isHovered = computed(
() => forceHovered || (canAcceptDrop.value && isOverDropZone.value)
)
const indicatorTag = computed(() => (dropIndicator?.onClick ? 'button' : 'div'))
</script>
<template>
<div
v-if="onDragOver && onDragDrop"
ref="dropZoneRef"
data-slot="drop-zone"
:class="
cn(
'rounded-lg ring-primary-500 ring-inset',
canAcceptDrop && isOverDropZone && 'bg-primary-500/10 ring-4'
'rounded-lg transition-colors',
isHovered && 'bg-component-node-widget-background-hovered'
)
"
>
<slot />
<div
<component
:is="indicatorTag"
v-if="dropIndicator"
:type="dropIndicator?.onClick ? 'button' : undefined"
:aria-label="dropIndicator?.onClick ? dropIndicator.label : undefined"
data-slot="drop-zone-indicator"
:class="
cn(
'm-3 flex h-25 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border-subtle py-2',
'm-3 block w-[calc(100%-1.5rem)] appearance-none overflow-hidden rounded-lg border border-node-component-border bg-transparent p-1 text-left text-component-node-foreground-secondary transition-colors',
dropIndicator?.onClick && 'cursor-pointer'
)
"
@click.prevent="dropIndicator?.onClick?.($event)"
>
<img
v-if="dropIndicator?.imageUrl"
class="h-23"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i v-if="dropIndicator.iconClass" :class="dropIndicator.iconClass" />
</template>
</div>
<div
:class="
cn(
'flex min-h-23 w-full flex-col items-center justify-center gap-2 rounded-[7px] p-6 text-center text-sm/tight transition-colors',
isHovered &&
!dropIndicator?.imageUrl &&
'border border-dashed border-component-node-foreground-secondary bg-component-node-widget-background-hovered'
)
"
>
<img
v-if="dropIndicator?.imageUrl"
class="max-h-23 rounded-md object-contain"
:alt="dropIndicator?.label ?? ''"
:src="dropIndicator?.imageUrl"
/>
<template v-else>
<span v-if="dropIndicator.label" v-text="dropIndicator.label" />
<i
v-if="dropIndicator.iconClass"
:class="
cn(
'size-4 text-component-node-foreground-secondary',
dropIndicator.iconClass
)
"
/>
</template>
</div>
</component>
</div>
<slot v-else />
</template>