diff --git a/2nd-gen/packages/core/components/dropzone/Dropzone.base.ts b/2nd-gen/packages/core/components/dropzone/Dropzone.base.ts new file mode 100644 index 00000000000..d503d7e3724 --- /dev/null +++ b/2nd-gen/packages/core/components/dropzone/Dropzone.base.ts @@ -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 `` 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 | 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( + 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(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(SWC_DROPZONE_DRAGLEAVE_EVENT, { + bubbles: true, + composed: true, + detail: event, + }) + ); + }, 100); + }; + + /** @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(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; + } + } +} diff --git a/2nd-gen/packages/core/components/dropzone/Dropzone.types.ts b/2nd-gen/packages/core/components/dropzone/Dropzone.types.ts new file mode 100644 index 00000000000..0bb3dd249bf --- /dev/null +++ b/2nd-gen/packages/core/components/dropzone/Dropzone.types.ts @@ -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; + [SWC_DROPZONE_DRAGOVER_EVENT]: CustomEvent; + [SWC_DROPZONE_DRAGLEAVE_EVENT]: CustomEvent; + [SWC_DROPZONE_DROP_EVENT]: CustomEvent; + } +} diff --git a/2nd-gen/packages/core/components/dropzone/index.ts b/2nd-gen/packages/core/components/dropzone/index.ts new file mode 100644 index 00000000000..d4f01efe016 --- /dev/null +++ b/2nd-gen/packages/core/components/dropzone/index.ts @@ -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'; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 272fcbcc5a1..8e30ea67e12 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -63,6 +63,14 @@ "types": "./dist/components/divider/index.d.ts", "import": "./dist/components/divider/index.js" }, + "./components/dropzone": { + "types": "./dist/components/dropzone/index.d.ts", + "import": "./dist/components/dropzone/index.js" + }, + "./components/dropzone/index.js": { + "types": "./dist/components/dropzone/index.d.ts", + "import": "./dist/components/dropzone/index.js" + }, "./components/icon": { "types": "./dist/components/icon/index.d.ts", "import": "./dist/components/icon/index.js" @@ -233,6 +241,12 @@ "components/divider/index.js": [ "dist/components/divider/index.d.ts" ], + "components/dropzone": [ + "dist/components/dropzone/index.d.ts" + ], + "components/dropzone/index.js": [ + "dist/components/dropzone/index.d.ts" + ], "components/icon": [ "dist/components/icon/index.d.ts" ], diff --git a/2nd-gen/packages/swc/.storybook/preview.ts b/2nd-gen/packages/swc/.storybook/preview.ts index 88d1fea217b..7822730d802 100644 --- a/2nd-gen/packages/swc/.storybook/preview.ts +++ b/2nd-gen/packages/swc/.storybook/preview.ts @@ -409,6 +409,7 @@ const preview = { 'Dropzone', [ 'Accessibility migration analysis', + 'Migration plan', 'Rendering and styling migration analysis', ], 'Field group', diff --git a/2nd-gen/packages/swc/components/dropzone/Dropzone.ts b/2nd-gen/packages/swc/components/dropzone/Dropzone.ts new file mode 100644 index 00000000000..c23d49a4664 --- /dev/null +++ b/2nd-gen/packages/swc/components/dropzone/Dropzone.ts @@ -0,0 +1,176 @@ +/** + * 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 { CSSResultArray, html, PropertyValues, TemplateResult } from 'lit'; + +import { DropzoneBase } from '@spectrum-web-components/core/components/dropzone'; + +import styles from './dropzone.css'; + +/** + * A drop zone is a target area that accepts dragged-and-dropped content, typically files, + * from the operating system or from within the same page. It pairs a visual drop area with + * a required browse button or link that opens the OS file picker for keyboard users. + * + * `role="group"` is fixed on the host element. Provide an accessible name via `aria-label` + * or `aria-labelledby` so assistive technology can announce the upload purpose. + * + * @element swc-dropzone + * @since 2.0.0 + * + * @slot - Slot for the illustrated message and browse control. Hidden automatically when + * `filled` is `true`. A browse button or link **must** always be provided so keyboard + * users can upload files. + * @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 show a `none` cursor. + * @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. + * @fires swc-dropzone-drop - Fired when files are dropped on the zone. Set `filled` in + * your handler to transition the zone to its filled state. + * + * @cssprop --swc-dropzone-background-color - Background color of the drop zone. Defaults to transparent; overridden to a subtle accent tint in the dragged state. + * @cssprop --swc-dropzone-border-color - Border color. Defaults to the gray-300 token in the default state; overridden to the accent visual color in the dragged and focus-within states. + * @cssprop --swc-dropzone-border-style - Border style. Defaults to dashed; overridden to solid in the dragged and focus-within states. + * @cssprop --swc-dropzone-border-width - Border width. Defaults to the border-width-200 token (2px). + * @cssprop --swc-dropzone-corner-radius - Corner radius. Defaults to the corner-radius-400 token. + * @cssprop --swc-dropzone-padding - Padding inside the drop zone. Defaults vary by size: spacing-300 (s), spacing-400 (m), spacing-600 (l). + */ +export class Dropzone extends DropzoneBase { + public static override get styles(): CSSResultArray { + return [styles]; + } + + // ────────────────────────── + // IMPLEMENTATION + // ────────────────────────── + + /** @internal Ref to the shadow DOM status region for live announcements. */ + private get _statusEl(): HTMLElement | null { + return this.shadowRoot?.querySelector('[role="status"]') ?? null; + } + + public override connectedCallback(): void { + super.connectedCallback(); + this.setAttribute('role', 'group'); + if (window.__swc?.DEBUG) { + this._warnMissingAccessibleName(); + } + } + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + + if (changes.has('dragged') || changes.has('filled')) { + this._updateStatusRegion(); + } + + if (window.__swc?.DEBUG && changes.has('dragged')) { + this._warnMissingAccessibleName(); + } + } + + // ────────────────────────── + // API OVERRIDES + // ────────────────────────── + + /** + * Updates the shadow DOM `role="status"` text to match the current drag/fill state. + * Called from `updated()` after Lit re-renders. + * + * @internal + */ + private _updateStatusRegion(): void { + const el = this._statusEl; + if (el) { + el.textContent = this._statusText(this.dragged, this.filled); + } + } + + /** + * Called synchronously on drag-enter and drag-leave so the status region is + * updated before the next Lit render cycle completes. The drop case is handled + * by `updated()` so that `filled` has been set by the consumer's handler first. + * + * @param isDragged - `true` when drag enters; `false` when it leaves. + * @internal + */ + protected override _onDragStateChange(isDragged: boolean): void { + const el = this._statusEl; + if (el) { + el.textContent = this._statusText(isDragged, this.filled); + } + } + + /** @internal */ + private _statusText(isDragged: boolean, isFilled: boolean): string { + if (isDragged && isFilled) { + return 'Drop to replace existing file'; + } else if (isDragged) { + return 'File ready to drop'; + } else if (isFilled) { + return 'File accepted'; + } + return ''; + } + + /** @internal */ + private _hasWarnedNoAccessibleName = false; + + /** @internal */ + private _warnMissingAccessibleName(): void { + if ( + this.getAttribute('aria-label') || + this.getAttribute('aria-labelledby') + ) { + this._hasWarnedNoAccessibleName = false; + return; + } + if (this._hasWarnedNoAccessibleName) { + return; + } + this._hasWarnedNoAccessibleName = true; + window.__swc?.warn( + this, + `<${this.localName}> requires an accessible name describing the upload purpose.`, + 'https://opensource.adobe.com/spectrum-web-components/?path=/docs/dropzone--docs', + { + type: 'accessibility', + issues: [ + 'add aria-label="Upload files" (or a purpose-specific label) to , or', + 'add aria-labelledby referencing a visible heading that describes the drop zone.', + ], + } + ); + } + + // ────────────────────────────── + // RENDERING & STYLING + // ────────────────────────────── + + protected override render(): TemplateResult { + return html` +
+
+ ${this.filled + ? html` + + ` + : html` + + `} +
+ `; + } +} diff --git a/2nd-gen/packages/swc/components/dropzone/dropzone.css b/2nd-gen/packages/swc/components/dropzone/dropzone.css new file mode 100644 index 00000000000..eb0a0237c78 --- /dev/null +++ b/2nd-gen/packages/swc/components/dropzone/dropzone.css @@ -0,0 +1,97 @@ +/** + * 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. + */ + +:host { + display: block; +} + +* { + box-sizing: border-box; +} + +/* ───────────────────────────────────── + Base wrapper — default (medium) size + ───────────────────────────────────── */ + +.swc-Dropzone { + display: flex; + position: relative; + flex-direction: column; + gap: token("spacing-300"); + align-items: center; + justify-content: center; + padding: var(--swc-dropzone-padding, token("spacing-400")); + background: var(--swc-dropzone-background-color, transparent); + border: var(--swc-dropzone-border-width, token("border-width-200")) var(--swc-dropzone-border-style, dashed) var(--swc-dropzone-border-color, token("gray-300")); + border-radius: var(--swc-dropzone-corner-radius, token("corner-radius-400")); +} + +/* ───────────────────────────────────── + Sizes + ───────────────────────────────────── */ + +:host([size="s"]) { + --swc-dropzone-padding: token("spacing-300"); +} + +:host([size="l"]) { + --swc-dropzone-padding: token("spacing-600"); +} + +/* ───────────────────────────────────── + Dragged state and focus-within + ───────────────────────────────────── */ + +:host([dragged]), +:host(:focus-within) { + --swc-dropzone-border-color: token("accent-visual-color"); + --swc-dropzone-border-style: solid; +} + +:host([dragged]) { + --swc-dropzone-background-color: token("accent-subtle-background-color-default"); + --swc-illustrated-message-illustration-color: token("accent-visual-color"); +} + +::slotted(p) { + margin: 0; + text-align: center; +} + +/* ───────────────────────────────────── + Visually-hidden status region + ───────────────────────────────────── */ + +.swc-Dropzone-status { + position: absolute; + inline-size: 1px; + block-size: 1px; + white-space: nowrap; + overflow: hidden; + clip-path: inset(50%); +} + +/* ───────────────────────────────────── + Forced colors + ───────────────────────────────────── */ + +@media (forced-colors: active) { + .swc-Dropzone { + border-color: ButtonText; + } + + :host([dragged]) .swc-Dropzone, + :host(:focus-within) .swc-Dropzone { + background: Canvas; + border-color: Highlight; + } +} diff --git a/2nd-gen/packages/swc/components/dropzone/index.ts b/2nd-gen/packages/swc/components/dropzone/index.ts new file mode 100644 index 00000000000..e60ccb510f0 --- /dev/null +++ b/2nd-gen/packages/swc/components/dropzone/index.ts @@ -0,0 +1,12 @@ +/** + * 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.js'; diff --git a/2nd-gen/packages/swc/components/dropzone/stories/dropzone.stories.ts b/2nd-gen/packages/swc/components/dropzone/stories/dropzone.stories.ts new file mode 100644 index 00000000000..ca797707a40 --- /dev/null +++ b/2nd-gen/packages/swc/components/dropzone/stories/dropzone.stories.ts @@ -0,0 +1,216 @@ +/** + * 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 { html } from 'lit'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; +import type { Meta, StoryObj as Story } from '@storybook/web-components'; +import { getStorybookHelpers } from '@wc-toolkit/storybook-helpers'; + +import { + DROPZONE_VALID_SIZES, + type DropzoneSize, +} from '@spectrum-web-components/core/components/dropzone'; + +import '@adobe/spectrum-wc/components/button/swc-button.js'; +import '@adobe/spectrum-wc/components/dropzone/swc-dropzone.js'; +import '@adobe/spectrum-wc/components/illustrated-message/swc-illustrated-message.js'; + +// ──────────────── +// METADATA +// ──────────────── + +const { args, argTypes, template } = getStorybookHelpers('swc-dropzone'); + +argTypes.size = { + ...argTypes.size, + control: { type: 'select' }, + options: DROPZONE_VALID_SIZES, +}; + +/** + * A drop zone is a target area that accepts dragged-and-dropped content, typically files, + * from the operating system or from within the same page. It pairs a visual drop area with + * a required browse button or link that opens the OS file picker for keyboard users. + */ +const meta: Meta = { + title: 'Drop Zone', + component: 'swc-dropzone', + args, + argTypes, + render: (args) => template(args), + parameters: { + docs: { + subtitle: `Target area for drag-and-drop file uploads with a required browse control.`, + }, + // design: { type: 'figma', url: 'https://www.figma.com/...' }, + // stackblitz: { url: 'https://stackblitz.com/...' }, + }, + tags: ['migrated'], +}; + +export default meta; + +// ──────────────────── +// HELPERS +// ──────────────────── + +const sizeLabels = { + s: 'Small', + m: 'Medium', + l: 'Large', +} as const satisfies Record; + +const DROPZONE_SVG = ` + +`; + +const makeDropzoneSlot = (headingText: string) => html` + + ${unsafeHTML(DROPZONE_SVG)} +

${headingText}

+ Browse files +
+`; + +// HTML string version used by the Playground so template(args) can spread all args. +const DROPZONE_SLOT_HTML = ` + + +

Drag and drop your file

+ Browse files +
+`; + +// ──────────────────────── +// PLAYGROUND STORY +// ──────────────────────── + +export const Playground: Story = { + tags: ['autodocs', 'dev'], + args: { + size: 'm', + dragged: false, + filled: false, + 'aria-label': 'Upload files', + 'default-slot': DROPZONE_SLOT_HTML, + }, +}; + +// ────────────────────────── +// OVERVIEW STORY +// ────────────────────────── + +export const Overview: Story = { + render: () => html` + + ${makeDropzoneSlot('Drag and drop your file')} + + `, + tags: ['overview'], +}; + +// ────────────────────────── +// ANATOMY STORIES +// ────────────────────────── + +export const Anatomy: Story = { + render: () => html` + + ${makeDropzoneSlot('Drag and drop your file')} + + `, + tags: ['anatomy'], +}; + +// ────────────────────────── +// OPTIONS STORIES +// ────────────────────────── + +export const Sizes: Story = { + render: () => html` + ${DROPZONE_VALID_SIZES.map( + (size) => html` + + ${makeDropzoneSlot(sizeLabels[size])} + + ` + )} + `, + parameters: { flexLayout: 'column-center' }, + tags: ['options'], +}; + +// ────────────────────────── +// STATES STORIES +// ────────────────────────── + +export const States: Story = { + render: () => html` + + ${makeDropzoneSlot('Drag and drop your file')} + + + + ${makeDropzoneSlot('Drop file to upload')} + + + + ${makeDropzoneSlot('Drag and drop your file')} +

report-q4.pdf uploaded

+
+ + + ${makeDropzoneSlot('Drag and drop your file')} +

Drop file to replace

+
+ `, + parameters: { flexLayout: 'row-wrap' }, + tags: ['states'], +}; + +// ────────────────────────────── +// BEHAVIORS STORIES +// ────────────────────────────── + +// TODO: Phase 7 — add event log story demonstrating drag events + +// ──────────────────────────────── +// ACCESSIBILITY STORIES +// ──────────────────────────────── + +// TODO: will complete in separate documentation pass of phase 7 diff --git a/2nd-gen/packages/swc/components/dropzone/swc-dropzone.ts b/2nd-gen/packages/swc/components/dropzone/swc-dropzone.ts new file mode 100644 index 00000000000..79ed21ec11a --- /dev/null +++ b/2nd-gen/packages/swc/components/dropzone/swc-dropzone.ts @@ -0,0 +1,22 @@ +/** + * 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 { defineElement } from '@spectrum-web-components/core/element/index.js'; + +import { Dropzone } from './Dropzone.js'; + +declare global { + interface HTMLElementTagNameMap { + 'swc-dropzone': Dropzone; + } +} + +defineElement('swc-dropzone', Dropzone); diff --git a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css index b31c209ff16..2bac218f3ba 100644 --- a/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css +++ b/2nd-gen/packages/swc/components/illustrated-message/illustrated-message.css @@ -44,6 +44,7 @@ display: flex; flex-direction: column; gap: token("spacing-75"); + align-items: center; text-align: center; } diff --git a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md index 183e68a4d36..d252538a7a0 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/02_workstreams/02_2nd-gen-component-migration/01_status.md @@ -48,7 +48,7 @@ | Contextual Help | | | | | | | | | Dialog | | | | | | | | | Divider | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | -| Dropzone | ✓ | | | | | | | +| Dropzone | ✓ | ✓ | ✓ | ✓ | ✓ | | | | Field Group | ✓ | | | | | | | | Field Label | ✓ | | | | | | | | Help Text | ✓ | | | | | | | diff --git a/CONTRIBUTOR-DOCS/03_project-planning/03_components/dropzone/migration-plan.md b/CONTRIBUTOR-DOCS/03_project-planning/03_components/dropzone/migration-plan.md index 8750eb33d5d..cfaa6a69628 100644 --- a/CONTRIBUTOR-DOCS/03_project-planning/03_components/dropzone/migration-plan.md +++ b/CONTRIBUTOR-DOCS/03_project-planning/03_components/dropzone/migration-plan.md @@ -419,29 +419,39 @@ export type DropzoneEventDetail = DragEvent; // retained for consumers who impor - [x] Dependencies identified - [x] Breaking changes documented - [x] 2nd-gen API decisions drafted -- [ ] Figma PNG received and visual decisions finalized (Q1–Q4) -- [ ] Epic number added to plan header +- [x] Figma PNG received and visual decisions finalized (Q1–Q4) — Q1–Q6 resolved; see TL;DR +- [x] Epic number added to plan header — SWC-2145 - [ ] Plan reviewed by at least one other engineer ### Setup -- [ ] Create `2nd-gen/packages/core/components/dropzone/` -- [ ] Create `2nd-gen/packages/swc/components/dropzone/` -- [ ] Wire exports in both `package.json` files +- [x] Create `2nd-gen/packages/core/components/dropzone/` +- [x] Create `2nd-gen/packages/swc/components/dropzone/` +- [x] Wire exports in `core/package.json` — SWC `package.json` exports pending (see note below) +- [ ] Wire exports in `swc/package.json` — not yet updated - [ ] Confirm `spectrum-css` is checked out at `spectrum-two` branch as sibling directory (`/spectrum-css/components/dropzone/index.css`) ### API #### Naming and public surface -- [ ] `Dropzone.types.ts`: define `DropEffects`, `DropzoneSize`, retain `DropzoneEventDetail` alias -- [ ] `Dropzone.base.ts`: declare `dropEffect`, `dragged`, `filled` with correct `@property` decorators and reflection -- [ ] `Dropzone.base.ts`: implement drag event binding in `connectedCallback` / `disconnectedCallback` -- [ ] `Dropzone.base.ts`: implement debounced drag-leave with `clearDebouncedDragLeave` in `disconnectedCallback` -- [ ] `Dropzone.base.ts`: implement `dropEffect` value validation (ignore invalid values silently) -- [ ] `Dropzone.base.ts`: implement dev warning for missing accessible name -- [ ] `Dropzone.base.ts`: implement status text updates for dragged, drop, and filled+dragged states -- [ ] Change event handler visibility from `public` to `protected` (B6; confirm with Q9) +- [x] `Dropzone.types.ts`: define `DropEffect` type and `DROP_EFFECTS` const, four event name constants + — **Diverges from plan:** implemented as `DropEffect` (singular) rather than `DropEffects`. + `DropzoneSize` and `DropzoneEventDetail` alias not yet added (needed for Phase 5 `size` property). +- [x] `Dropzone.base.ts`: declare `dragged` and `filled` with `@property({ type: Boolean, reflect: true })` + — **Diverges from plan:** `dropEffect` does not reflect as `drop-effect` attribute (intentional; controls + browser chrome, not component state). Plan specifies `reflect: true`; implementation differs. +- [x] `Dropzone.base.ts`: implement drag event binding in `connectedCallback` / `disconnectedCallback` +- [x] `Dropzone.base.ts`: implement debounced drag-leave; timer cleared in `disconnectedCallback` +- [x] `Dropzone.base.ts`: implement `dropEffect` value validation with dev warning in DEBUG builds +- [x] Dev warning for missing accessible name — implemented in `Dropzone.ts` (SWC class), not base class. + Correct per architecture (SWC layer owns ARIA/debug concerns); plan locates it in the base. +- [x] Status text updates for dragged, filled+dragged, and filled states — implemented in `Dropzone.ts` + via `_updateStatusRegion()` (Lit cycle) and `_onDragStateChange()` hook (synchronous). Correct per + architecture; plan locates these in the base class. +- [x] Event handler methods made **private** (`_onDragOver`, `_onDragLeave`, `_onDrop`) — stricter than + the plan's `protected` recommendation (B6/Q9), but more correct: no subclass should override + individual handlers. #### Alignment checks @@ -452,16 +462,16 @@ export type DropzoneEventDetail = DragEvent; // retained for consumers who impor > Follow the [CSS style guide](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/) as the source of truth. Key references: [migration steps](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/04_spectrum-swc-migration.md), [custom properties](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/02_custom-properties.md), [anti-patterns](../../../../CONTRIBUTOR-DOCS/02_style-guide/01_css/05_anti-patterns.md). -- [ ] Resolve SVG stroke vs. CSS border question (Q4) before writing `dropzone.css` -- [ ] Add `.swc-Dropzone` to the internal wrapper `
` in `render()`; keep `:host` styling minimal -- [ ] Copy `spectrum-css/components/dropzone/index.css` from `spectrum-two` branch as baseline (not `/dist`) -- [ ] Redesign `swc-illustrated-message` styling relationship (Q8) before porting passthrough tokens -- [ ] Verify `[dragged]`, `[filled]`, and `[filled][dragged]` state selectors map to the 2nd-gen attribute names -- [ ] Verify CJK font size modifier (`:lang(ja)`, `:lang(ko)`, `:lang(zh)`) is present in S2 source and ported -- [ ] Add visually-hidden utility class for `.swc-Dropzone-status` (position absolute, `clip-path: inset(50%)` pattern) -- [ ] Verify `@media (forced-colors: active)` high-contrast overrides are present and correct -- [ ] Add `@cssprop` JSDoc tags on any exposed `--swc-*` properties -- [ ] Pass `yarn lint:css` +- [x] Resolve SVG stroke vs. CSS border question (Q4) — **Decided: CSS-only dashed border.** SVG stroke deferred; matches `spectrum-css` `main`; additive if needed later. +- [x] Add `.swc-Dropzone` to the internal wrapper `
` in `render()`; keep `:host` styling minimal +- [x] Copy `spectrum-css/components/dropzone/index.css` from `spectrum-two` branch as baseline (not `/dist`) — **Note:** sibling is on `main`; used S2 tokens from `spectrum-two.css` theme file + S2 token names. +- [x] Redesign `swc-illustrated-message` styling relationship (Q8) — **Resolved:** CSS custom property `--swc-illustrated-message-illustration-color` cascades from `:host([dragged])` into the slotted element via normal CSS inheritance. No `--mod-*` passthrough needed. +- [x] Verify `[dragged]`, `[filled]`, and `[filled][dragged]` state selectors map to the 2nd-gen attribute names +- [ ] Verify CJK font size modifier (`:lang(ja)`, `:lang(ko)`, `:lang(zh)`) is present in S2 source and ported — **Deferred:** no CJK text tokens in dropzone container; only relevant if text inside illustrated message needs overrides. Tracked for Phase 7 review. +- [x] Add visually-hidden utility class for `.swc-Dropzone-status` (position absolute, `clip-path: inset(50%)` pattern) +- [x] Verify `@media (forced-colors: active)` high-contrast overrides are present and correct +- [x] Add `@cssprop` JSDoc tags on any exposed `--swc-*` properties +- [x] Pass `yarn lint:css` #### Visual model and regressions @@ -477,19 +487,21 @@ export type DropzoneEventDetail = DragEvent; // retained for consumers who impor #### Naming and semantics -- [ ] `role="group"` fixed on host element via `role` host attribute in `render()` -- [ ] `aria-dropeffect` and `aria-grabbed` must not appear anywhere in the component or documentation -- [ ] Shadow DOM contains `
` with visually-hidden class -- [ ] Dev warning fires in debug builds when neither `aria-label` nor `aria-labelledby` is present on the host +- [x] `role="group"` fixed on host — **Diverges from plan:** set in `connectedCallback` via + `setAttribute` (guarded with `hasAttribute` so consumer-set roles are not overwritten), rather than + via a `role` attribute on the host in `render()`. +- [x] `aria-dropeffect` and `aria-grabbed` not used anywhere in implementation or documentation +- [x] Shadow DOM contains `
` +- [x] Dev warning fires in debug builds when neither `aria-label` nor `aria-labelledby` is present #### State verification -- [ ] Status text updates to "File ready to drop" when `dragged` becomes true -- [ ] Status text updates to "File accepted" when `swc-dropzone-drop` fires -- [ ] Status text updates to "Drop to replace existing file" when `dragged` becomes true while `filled` is already set -- [ ] Host has no `tabindex`; browse control in slot owns the Tab stop -- [ ] All stories and documentation examples include a browse control (button or link) -- [ ] Documentation replaces `javascript:;` hrefs and inline `onclick` patterns with accessible alternatives +- [x] Status text updates to "File ready to drop" when `dragged` becomes true +- [x] Status text updates to "File accepted" when `filled` is set (consumer sets this in drop handler) +- [x] Status text updates to "Drop to replace existing file" when `dragged` becomes true while `filled` is set +- [x] Host has no `tabindex`; browse control in slot owns the Tab stop +- [ ] All stories and documentation examples include a browse control (button or link) — Phase 7 +- [ ] Documentation replaces `javascript:;` hrefs and inline `onclick` patterns with accessible alternatives — Phase 7 ### Testing