mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-03-12 16:40:05 +00:00
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:
131
src/renderer/extensions/linearMode/DropZone.stories.ts
Normal file
131
src/renderer/extensions/linearMode/DropZone.stories.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user