-
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
69d9b8e
e4abeb1
d183c29
ade3f96
dc389e7
501d5aa
7ec9744
f009bbb
83cc114
04a9361
40c31cc
02fae32
4dda667
1779ebc
93e1ac5
2a07718
ff28df1
204cc3c
084cf4c
f1aab51
a0ead09
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,252 @@ | ||
| /** | ||
| * 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 DropzoneDragLeaveDetail, | ||
| 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. Detail is a plain snapshot `{ clientX, clientY, relatedTarget }` captured | ||
| * synchronously from the native event before the timer 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(); | ||
|
|
||
| if (!event.dataTransfer) { | ||
| return; | ||
| } | ||
|
|
||
| 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;
}
Member
Author
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. Updated in aa81723 ✨ |
||
| SWC_DROPZONE_SHOULD_ACCEPT_EVENT, | ||
| { | ||
| bubbles: true, | ||
| cancelable: true, | ||
| composed: true, | ||
| detail: event, | ||
| } | ||
| ); | ||
|
|
||
| const accepted = this.dispatchEvent(shouldAcceptEvent); | ||
|
|
||
| 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(); | ||
|
|
||
| // Capture values synchronously; browsers recycle DragEvent objects after | ||
| // the synchronous handler returns, so reading them inside setTimeout is unsafe. | ||
| const { clientX, clientY, relatedTarget } = event; | ||
|
|
||
| this._dragLeaveTimer = setTimeout(() => { | ||
| this._dragLeaveTimer = null; | ||
| this.dragged = false; | ||
| this._onDragStateChange(false); | ||
|
|
||
| this.dispatchEvent( | ||
| new CustomEvent<DropzoneDragLeaveDetail>(SWC_DROPZONE_DRAGLEAVE_EVENT, { | ||
| bubbles: true, | ||
| composed: true, | ||
| detail: { clientX, clientY, relatedTarget }, | ||
| }) | ||
| ); | ||
| }, 100); | ||
| }; | ||
|
Comment on lines
+202
to
+210
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 }>
Member
Author
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. Got it! Updated in 645f876 ✨ |
||
|
|
||
| /** @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,76 @@ | ||
| /** | ||
| * 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'; | ||
|
|
||
| /** | ||
| * Detail emitted with `swc-dropzone-dragleave`. Values are captured synchronously | ||
| * from the native `DragEvent` before the 100 ms debounce timer fires, preventing | ||
| * stale reads from a recycled event object. | ||
| */ | ||
| export interface DropzoneDragLeaveDetail { | ||
| clientX: number; | ||
| clientY: number; | ||
| relatedTarget: EventTarget | null; | ||
| } | ||
|
|
||
| /** 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<DropzoneDragLeaveDetail>; | ||
| [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?Uh oh!
There was an error while loading. Please reload this page.
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.
If this ends up being consumer owned, it would be a consumer owned styling. We'd add styling if the component included a default illustration. ✨RSP doesn't set a default as it uses an illustration and illustrated message does not require a default. We can leave this as consumer-owned styling. ✨