Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
69d9b8e
chore(dropzone): establish file structure
cdransf Jun 22, 2026
e4abeb1
chore(dropzone): address feedback
cdransf Jun 23, 2026
d183c29
chore(dropzone): full fidelity
cdransf Jun 23, 2026
ade3f96
chore(dropzone): pass sizes on to illustrated message
cdransf Jun 25, 2026
dc389e7
chore(dropzone): address temporary ui divergence in comment
cdransf Jun 25, 2026
501d5aa
chore(dropzone): remove exposed custom properties, reference exposed …
cdransf Jun 25, 2026
7ec9744
chore(dropzone): remove redundant forced color property assignment
cdransf Jun 25, 2026
f009bbb
chore(dropzone): add slotted css rule for p tags
cdransf Jun 25, 2026
83cc114
chore(illustrated-message): add filled-content slot
cdransf Jun 26, 2026
04a9361
chore(dropzone): slot method rename
cdransf Jun 26, 2026
40c31cc
fix(dropzone): conditionaly render slots based on this.filled
cdransf Jun 26, 2026
02fae32
fix(dropzone): adopt actions slot
cdransf Jun 26, 2026
4dda667
fix(dropzone): clean up styles
cdransf Jun 26, 2026
1779ebc
fix(dropzone): refine styles
cdransf Jun 26, 2026
93e1ac5
fix(dropzone): bug with drag event object recycling
cdransf Jul 6, 2026
2a07718
fix(dropzone): split out shorthand declaration
cdransf Jul 6, 2026
ff28df1
chore(dropzone): extracted shared visually hidden class
cdransf Jul 6, 2026
204cc3c
fix(dropzone): move guard before accepted event dispatch
cdransf Jul 6, 2026
084cf4c
chore(dropzone): deduplicate svgs in stories
cdransf Jul 6, 2026
f1aab51
chore(dropzone): address feedback
cdransf Jul 7, 2026
a0ead09
chore(dropzone): adopt ObserveSlotText mixin
cdransf Jul 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
252 changes: 252 additions & 0 deletions 2nd-gen/packages/core/components/dropzone/Dropzone.base.ts
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;

Copy link
Copy Markdown
Contributor

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 for filled+dragged compound state. is this a consumer own styling?

@cdransf cdransf Jul 6, 2026

Copy link
Copy Markdown
Member Author

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. ✨


/**
* 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>(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it is dispatching the event before checking dataTransfer. Do a null check before the event dispatch.

if (!event.dataTransfer) {
  return; 
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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.
1st gen still carries the same bug so lets fix this here.
You can capture values synchronously before the timeout and emit a plain snapshot.

const { clientX, clientY, relatedTarget } = event;
// inside setTimeout:
detail: { clientX, clientY, relatedTarget }
// update type: CustomEvent<{ clientX: number; clientY: number; relatedTarget: EventTarget | null }>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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;
}
}
}
76 changes: 76 additions & 0 deletions 2nd-gen/packages/core/components/dropzone/Dropzone.types.ts
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>;
}
}
13 changes: 13 additions & 0 deletions 2nd-gen/packages/core/components/dropzone/index.ts
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';
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PropertyValues } from 'lit';
import { property } from 'lit/decorators.js';

import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
import { ObserveSlotText } from '@spectrum-web-components/core/mixins/observe-slot-text.js';

import { SlotAttributePropagationController } from '../../controllers/slot-attribute-propagation/index.js';
import {
Expand All @@ -34,7 +35,10 @@ import {
* @slot description - Supporting description text
* @slot actions - Optional action controls displayed below the description, typically a button or button group. Receives `size` automatically from the illustrated message.
*/
export abstract class IllustratedMessageBase extends SpectrumElement {
export abstract class IllustratedMessageBase extends ObserveSlotText(
SpectrumElement,
''
) {
// ─────────────────────────
// API TO OVERRIDE
// ─────────────────────────
Expand Down Expand Up @@ -82,6 +86,15 @@ export abstract class IllustratedMessageBase extends SpectrumElement {
}
);

/**
* Whether the default (illustration) slot has assigned content, so
* rendering subclasses can collapse the illustration wrapper when no
* illustration is provided instead of reserving its fixed size.
*/
protected get hasIllustration(): boolean {
return this.slotHasContent;
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);

Expand Down
Loading
Loading