-
Notifications
You must be signed in to change notification settings - Fork 254
chore(dropzone): full fidelity #6446
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: cdransf/s2-dropzone-migration
Are you sure you want to change the base?
Changes from all commits
f5efd18
45c95a3
cc28c56
66fb685
6c74268
32663a4
4839134
a32b450
d87f17e
5f348e8
726f09a
65c7ab4
bc44c85
261364c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| import { property } from 'lit/decorators.js'; | ||
|
|
||
| import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; | ||
| import { SizedMixin } from '@spectrum-web-components/core/mixins/index.js'; | ||
|
|
||
| import { SlotAttributePropagationController } from '../../controllers/slot-attribute-propagation/index.js'; | ||
| import { | ||
| DROP_EFFECTS, | ||
| type DropEffect, | ||
| DROPZONE_VALID_SIZES, | ||
| type DropzoneSize, | ||
| SWC_DROPZONE_DRAGLEAVE_EVENT, | ||
| SWC_DROPZONE_DRAGOVER_EVENT, | ||
| SWC_DROPZONE_DROP_EVENT, | ||
| SWC_DROPZONE_SHOULD_ACCEPT_EVENT, | ||
| } from './Dropzone.types.js'; | ||
|
|
||
| /** | ||
| * Base class for the `<swc-dropzone>` drop zone component. | ||
| * | ||
| * Encapsulates all drag-and-drop event handling, state management, and | ||
| * validation logic. Rendering, ARIA, and CSS are provided by the concrete | ||
| * SWC subclass. | ||
| * | ||
| * @slot - Slot for the illustrated message and browse control. Hidden automatically when `filled` is true. | ||
| * @slot filled-content - Slot for the uploaded-state content (e.g. an image preview). Shown automatically when `filled` is true; hidden otherwise. | ||
| * | ||
| * @fires swc-dropzone-should-accept - Cancelable event fired on `dragover`. Cancel to | ||
| * reject the dragged payload and set the cursor to `none`. | ||
| * @fires swc-dropzone-dragover - Fired when dragged files are over the zone and accepted. | ||
| * @fires swc-dropzone-dragleave - Fired when dragged files leave the zone after a 100 ms | ||
| * debounce. `event.detail.dataTransfer` may be `null` if the browser has already ended | ||
| * the drag session by the time the event fires. | ||
| * @fires swc-dropzone-drop - Fired when files are dropped on the zone. `element.dragged` | ||
| * is still `true` when this event fires; it transitions to `false` after dispatch. | ||
| */ | ||
| export abstract class DropzoneBase extends SizedMixin(SpectrumElement, { | ||
| validSizes: DROPZONE_VALID_SIZES, | ||
| }) { | ||
| declare public size: DropzoneSize; | ||
|
|
||
| // ────────────────────────── | ||
| // SHARED API | ||
| // ────────────────────────── | ||
|
|
||
| /** | ||
| * Whether files are currently being dragged over the drop zone. | ||
| * Set automatically by the component; also settable to reflect programmatic state. | ||
| * | ||
| * @attr dragged | ||
| * @default false | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public dragged = false; | ||
|
|
||
| /** | ||
| * Whether the drop zone has received a file and is in the filled state. | ||
| * Set by consuming code after a successful drop or browse-file selection to | ||
| * switch the zone to its filled visual. | ||
| * | ||
| * @attr filled | ||
| * @default false | ||
| */ | ||
| @property({ type: Boolean, reflect: true }) | ||
| public filled = false; | ||
|
|
||
| /** | ||
| * The OS drag-cursor feedback shown while a file is held over the zone. | ||
| * Maps directly to `DataTransfer.dropEffect`. Does not reflect as an | ||
| * attribute because it controls browser chrome, not component state. | ||
| * | ||
| * @type {'copy' | 'move' | 'link' | 'none'} | ||
| * @default 'copy' | ||
| */ | ||
| public get dropEffect(): DropEffect { | ||
| return this._dropEffect; | ||
| } | ||
|
|
||
| public set dropEffect(value: DropEffect) { | ||
| if ((DROP_EFFECTS as readonly string[]).includes(value)) { | ||
| this._dropEffect = value; | ||
| } else if (window.__swc?.DEBUG) { | ||
| window.__swc?.warn( | ||
| this, | ||
| `<${this.localName}> "dropEffect" received an invalid value: "${value}". Must be one of: ${DROP_EFFECTS.join(', ')}.`, | ||
| 'https://opensource.adobe.com/spectrum-web-components/?path=/docs/dropzone--docs', | ||
| { type: 'api' } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| private _dropEffect: DropEffect = 'copy'; | ||
|
|
||
| // ────────────────────────── | ||
| // IMPLEMENTATION | ||
| // ────────────────────────── | ||
|
|
||
| private readonly _sizePropagation = new SlotAttributePropagationController( | ||
| this, | ||
| { | ||
| attribute: 'size', | ||
| getValue: () => this.size, | ||
| selector: 'swc-illustrated-message', | ||
| } | ||
| ); | ||
|
|
||
| protected handleDefaultSlotChange(): void { | ||
| this._sizePropagation.propagate(); | ||
| } | ||
|
|
||
| /** Timer ID for debounced dragleave — prevents flickering on child drag events. */ | ||
| private _dragLeaveTimer: ReturnType<typeof setTimeout> | null = null; | ||
|
|
||
| public override connectedCallback(): void { | ||
| super.connectedCallback(); | ||
| this.addEventListener('drop', this._onDrop); | ||
| this.addEventListener('dragover', this._onDragOver); | ||
| this.addEventListener('dragleave', this._onDragLeave); | ||
| } | ||
|
|
||
| public override disconnectedCallback(): void { | ||
| super.disconnectedCallback(); | ||
| this.removeEventListener('drop', this._onDrop); | ||
| this.removeEventListener('dragover', this._onDragOver); | ||
| this.removeEventListener('dragleave', this._onDragLeave); | ||
| this._clearDragLeaveTimer(); | ||
| } | ||
|
|
||
| /** @internal */ | ||
| private readonly _onDragOver = (event: DragEvent): void => { | ||
| event.preventDefault(); | ||
|
|
||
| const shouldAcceptEvent = new CustomEvent<DragEvent>( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. it is dispatching the event before checking if (!event.dataTransfer) {
return;
} |
||
| SWC_DROPZONE_SHOULD_ACCEPT_EVENT, | ||
| { | ||
| bubbles: true, | ||
| cancelable: true, | ||
| composed: true, | ||
| detail: event, | ||
| } | ||
| ); | ||
|
|
||
| const accepted = this.dispatchEvent(shouldAcceptEvent); | ||
|
|
||
| if (!event.dataTransfer) { | ||
| return; | ||
| } | ||
|
|
||
| if (!accepted) { | ||
| event.dataTransfer.dropEffect = 'none'; | ||
| return; | ||
| } | ||
|
|
||
| this._clearDragLeaveTimer(); | ||
|
|
||
| if (!this.dragged) { | ||
| this.dragged = true; | ||
| this._onDragStateChange(true); | ||
| } | ||
|
|
||
| event.dataTransfer.dropEffect = this._dropEffect; | ||
|
|
||
| this.dispatchEvent( | ||
| new CustomEvent<DragEvent>(SWC_DROPZONE_DRAGOVER_EVENT, { | ||
| bubbles: true, | ||
| composed: true, | ||
| detail: event, | ||
| }) | ||
| ); | ||
| }; | ||
|
|
||
| /** @internal */ | ||
| private readonly _onDragLeave = (event: DragEvent): void => { | ||
| if (event.relatedTarget && this.contains(event.relatedTarget as Node)) { | ||
| return; | ||
| } | ||
|
|
||
| this._clearDragLeaveTimer(); | ||
|
|
||
| this._dragLeaveTimer = setTimeout(() => { | ||
| this._dragLeaveTimer = null; | ||
| this.dragged = false; | ||
| this._onDragStateChange(false); | ||
|
|
||
| this.dispatchEvent( | ||
| new CustomEvent<DragEvent>(SWC_DROPZONE_DRAGLEAVE_EVENT, { | ||
| bubbles: true, | ||
| composed: true, | ||
| detail: event, | ||
| }) | ||
| ); | ||
| }, 100); | ||
| }; | ||
|
Comment on lines
+197
to
+205
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This 100 ms async boundary, browser may recycle the object before the consumer reads it. Browsers pool and recycle drag event objects after every synchronous handler returns; by the time the timer fires, event.dataTransfer may be null and coordinate properties may be zeroed. const { clientX, clientY, relatedTarget } = event;
// inside setTimeout:
detail: { clientX, clientY, relatedTarget }
// update type: CustomEvent<{ clientX: number; clientY: number; relatedTarget: EventTarget | null }> |
||
|
|
||
| /** @internal */ | ||
| private readonly _onDrop = (event: DragEvent): void => { | ||
| event.preventDefault(); | ||
|
|
||
| if (!this.dragged) { | ||
| return; | ||
| } | ||
|
|
||
| this._clearDragLeaveTimer(); | ||
| // Dispatch before clearing `dragged`; `updated()` handles the status region after `filled` settles. | ||
| this.dispatchEvent( | ||
| new CustomEvent<DragEvent>(SWC_DROPZONE_DROP_EVENT, { | ||
| bubbles: true, | ||
| composed: true, | ||
| detail: event, | ||
| }) | ||
| ); | ||
| this.dragged = false; | ||
| }; | ||
|
|
||
| // ────────────────────────────── | ||
| // API TO OVERRIDE | ||
| // ────────────────────────────── | ||
|
|
||
| /** | ||
| * Called when the `dragged` state changes. Subclasses implement this to | ||
| * update the shadow DOM status region for accessibility announcements. | ||
| * | ||
| * @param isDragged - `true` when drag enters; `false` when it leaves. | ||
| * @internal | ||
| */ | ||
| protected abstract _onDragStateChange(isDragged: boolean): void; | ||
|
|
||
| /** @internal */ | ||
| private _clearDragLeaveTimer(): void { | ||
| if (this._dragLeaveTimer !== null) { | ||
| clearTimeout(this._dragLeaveTimer); | ||
| this._dragLeaveTimer = null; | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
|
|
||
| // ────────────────── | ||
| // SIZES | ||
| // ────────────────── | ||
|
|
||
| export type DropzoneSize = 's' | 'm' | 'l'; | ||
|
|
||
| export const DROPZONE_VALID_SIZES = [ | ||
| 's', | ||
| 'm', | ||
| 'l', | ||
| ] as const satisfies readonly DropzoneSize[]; | ||
|
|
||
| // ────────────────── | ||
| // DROP EFFECT | ||
| // ────────────────── | ||
|
|
||
| /** Valid OS drag-cursor feedback values for the drop zone. */ | ||
| export type DropEffect = 'copy' | 'move' | 'link' | 'none'; | ||
|
|
||
| export const DROP_EFFECTS = [ | ||
| 'copy', | ||
| 'move', | ||
| 'link', | ||
| 'none', | ||
| ] as const satisfies readonly DropEffect[]; | ||
|
|
||
| // ────────────────── | ||
| // EVENTS | ||
| // ────────────────── | ||
|
|
||
| /** Fired when files are dragged over the drop zone; cancelable to reject. */ | ||
| export const SWC_DROPZONE_SHOULD_ACCEPT_EVENT = 'swc-dropzone-should-accept'; | ||
|
|
||
| /** Fired when files are dragged over the drop zone and accepted. */ | ||
| export const SWC_DROPZONE_DRAGOVER_EVENT = 'swc-dropzone-dragover'; | ||
|
|
||
| /** Fired when dragged files leave the drop zone without being dropped. */ | ||
| export const SWC_DROPZONE_DRAGLEAVE_EVENT = 'swc-dropzone-dragleave'; | ||
|
|
||
| /** Fired when files are dropped on the drop zone. */ | ||
| export const SWC_DROPZONE_DROP_EVENT = 'swc-dropzone-drop'; | ||
|
|
||
| /** Type alias retained for consumers who imported `DropzoneEventDetail` from the 1st-gen package. */ | ||
| export type DropzoneEventDetail = DragEvent; | ||
|
|
||
| declare global { | ||
| interface GlobalEventHandlersEventMap { | ||
| [SWC_DROPZONE_SHOULD_ACCEPT_EVENT]: CustomEvent<DragEvent>; | ||
| [SWC_DROPZONE_DRAGOVER_EVENT]: CustomEvent<DragEvent>; | ||
| [SWC_DROPZONE_DRAGLEAVE_EVENT]: CustomEvent<DragEvent>; | ||
| [SWC_DROPZONE_DROP_EVENT]: CustomEvent<DragEvent>; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| /** | ||
| * Copyright 2026 Adobe. All rights reserved. | ||
| * This file is licensed to you under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. You may obtain a copy | ||
| * of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software distributed under | ||
| * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS | ||
| * OF ANY KIND, either express or implied. See the License for the specific language | ||
| * governing permissions and limitations under the License. | ||
| */ | ||
| export * from './Dropzone.base.js'; | ||
| export * from './Dropzone.types.js'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How are you styling this property. I didnt see
:host([filled])rule anywhere. Also no css forfilled+draggedcompound state. is this a consumer own styling?