Skip to content

Commit 5a49cde

Browse files
committed
chore(dropzone): cleanup + add missing property decorator
1 parent 027fac5 commit 5a49cde

5 files changed

Lines changed: 114 additions & 20 deletions

File tree

2nd-gen/packages/core/components/dropzone/Dropzone.base.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { SlotAttributePropagationController } from '../../controllers/slot-attri
1919
import {
2020
DROP_EFFECTS,
2121
type DropEffect,
22+
DROPZONE_DEFAULT_DROP_EFFECT,
2223
DROPZONE_VALID_SIZES,
2324
type DropzoneDragLeaveDetail,
2425
type DropzoneSize,
@@ -79,19 +80,30 @@ export abstract class DropzoneBase extends SizedMixin(SpectrumElement, {
7980

8081
/**
8182
* The OS drag-cursor feedback shown while a file is held over the zone.
82-
* Maps directly to `DataTransfer.dropEffect`. Does not reflect as an
83-
* attribute because it controls browser chrome, not component state.
83+
* Maps directly to `DataTransfer.dropEffect`. Settable via the `drop-effect`
84+
* attribute, but property changes do not reflect back to the attribute
85+
* because it controls browser chrome, not component state.
8486
*
87+
* @attr drop-effect
8588
* @type {'copy' | 'move' | 'link' | 'none'}
8689
* @default 'copy'
8790
*/
91+
@property({ type: String, attribute: 'drop-effect' })
8892
public get dropEffect(): DropEffect {
8993
return this._dropEffect;
9094
}
9195

92-
public set dropEffect(value: DropEffect) {
93-
if ((DROP_EFFECTS as readonly string[]).includes(value)) {
96+
public set dropEffect(value: DropEffect | null) {
97+
// Lit passes `null` here when the `drop-effect` attribute is removed;
98+
// treat that as "reset to the default" rather than an invalid value.
99+
if (value === null) {
100+
const oldValue = this._dropEffect;
101+
this._dropEffect = DROPZONE_DEFAULT_DROP_EFFECT;
102+
this.requestUpdate('dropEffect', oldValue);
103+
} else if ((DROP_EFFECTS as readonly string[]).includes(value)) {
104+
const oldValue = this._dropEffect;
94105
this._dropEffect = value;
106+
this.requestUpdate('dropEffect', oldValue);
95107
} else if (window.__swc?.DEBUG) {
96108
window.__swc?.warn(
97109
this,
@@ -102,7 +114,7 @@ export abstract class DropzoneBase extends SizedMixin(SpectrumElement, {
102114
}
103115
}
104116

105-
private _dropEffect: DropEffect = 'copy';
117+
private _dropEffect: DropEffect = DROPZONE_DEFAULT_DROP_EFFECT;
106118

107119
// ──────────────────────────
108120
// IMPLEMENTATION

2nd-gen/packages/core/components/dropzone/Dropzone.types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ export const DROP_EFFECTS = [
3636
'none',
3737
] as const satisfies readonly DropEffect[];
3838

39+
export const DROPZONE_DEFAULT_DROP_EFFECT =
40+
'copy' as const satisfies DropEffect;
41+
3942
// ──────────────────
4043
// EVENTS
4144
// ──────────────────

2nd-gen/packages/swc/components/dropzone/migration-guide.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ Spectrum 2 exposes a small set of public CSS custom properties on `<swc-dropzone
278278

279279
None of the other ~30 `--mod-drop-zone-*` / `--mod-illustrated-message-*` properties have a replacement above; retoken or remove them. CJK-specific font-size overrides (e.g. `--mod-illustrated-message-title-font-size` under `:lang(ja|ko|zh)`) need no replacement: `swc-illustrated-message` now applies CJK sizing automatically.
280280

281-
`dropEffect` gained a development-mode console warning for invalid values (it was silently ignored before); no markup or code change is required.
281+
`dropEffect` gained a development-mode console warning for invalid values (it was silently ignored before) and now reacts to the `drop-effect` attribute, both at parse time and at runtime; it was JavaScript-property-only in Spectrum 1. No markup change is required for consumers already setting `element.dropEffect` in JavaScript; consumers using `drop-effect="..."` in markup will see it take effect for the first time.
282282

283283
<div
284284
style={{

2nd-gen/packages/swc/components/dropzone/test/dropzone.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import '@adobe/spectrum-wc/components/button/swc-button.js';
2828
import '@adobe/spectrum-wc/components/dropzone/swc-dropzone.js';
2929

3030
import {
31+
fixture,
3132
getComponent,
3233
getComponents,
3334
withWarningSpy,
@@ -747,6 +748,72 @@ export const DropEffectTest: Story = {
747748
).toBeGreaterThan(0);
748749
})
749750
);
751+
752+
await step(
753+
'reflects the drop-effect attribute at initial parse',
754+
async () => {
755+
const fresh = await fixture<Dropzone>(html`
756+
<swc-dropzone aria-label="Upload files" drop-effect="move">
757+
<swc-button variant="accent">Browse files</swc-button>
758+
</swc-dropzone>
759+
`);
760+
expect(
761+
fresh.dropEffect,
762+
'drop-effect attribute parsed into dropEffect on connect'
763+
).toBe('move');
764+
fresh.parentElement?.remove();
765+
}
766+
);
767+
768+
await step(
769+
'reacts to drop-effect attribute changes at runtime',
770+
async () => {
771+
dropzone.setAttribute('drop-effect', 'link');
772+
await dropzone.updateComplete;
773+
expect(
774+
dropzone.dropEffect,
775+
'dropEffect updates when the attribute changes'
776+
).toBe('link');
777+
}
778+
);
779+
780+
await step(
781+
'rejects an invalid drop-effect attribute and emits a warning',
782+
() =>
783+
withWarningSpy(async (warnCalls) => {
784+
dropzone.setAttribute('drop-effect', 'link');
785+
await dropzone.updateComplete;
786+
dropzone.setAttribute('drop-effect', 'invalid-effect');
787+
await dropzone.updateComplete;
788+
expect(
789+
dropzone.dropEffect,
790+
'invalid attribute value rejected; stays at prior valid value'
791+
).toBe('link');
792+
expect(
793+
warnCalls.length,
794+
'warning emitted for invalid drop-effect attribute'
795+
).toBeGreaterThan(0);
796+
})
797+
);
798+
799+
await step(
800+
'removing the drop-effect attribute resets to the default without a warning',
801+
() =>
802+
withWarningSpy(async (warnCalls) => {
803+
dropzone.setAttribute('drop-effect', 'move');
804+
await dropzone.updateComplete;
805+
dropzone.removeAttribute('drop-effect');
806+
await dropzone.updateComplete;
807+
expect(
808+
dropzone.dropEffect,
809+
'dropEffect resets to the default when the attribute is removed'
810+
).toBe('copy');
811+
expect(
812+
warnCalls.length,
813+
'no warning emitted when the attribute is removed'
814+
).toBe(0);
815+
})
816+
);
750817
},
751818
};
752819

CONTRIBUTOR-DOCS/03_project-planning/03_components/dropzone/migration-plan.md

Lines changed: 26 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,11 @@ No sequencing, shared-base, or inheritance decisions require explicit user confi
236236
| **B2** | JS property rename: `isDragged``dragged` | `element.isDragged` | `element.dragged` | Update JS references; HTML attribute `dragged` is unchanged. Lit template `?dragged=…` bindings are unaffected. |
237237
| **B3** | JS property rename: `isFilled``filled` | `element.isFilled` | `element.filled` | Update JS references; HTML attribute `filled` is unchanged. Lit template `?filled=…` bindings are unaffected. |
238238
| **B4** | Fix `filled` reflection | Setting `element.isFilled = true` did not update the attribute; styles never applied via JS | Setting `element.filled = true` now reflects to `[filled]` attribute; styles apply correctly | No consumer migration needed. This is a silent bug fix. |
239-
| **B5** | `dropEffect` becomes a proper `@property` | Manual getter/setter; attribute changes at runtime not reactive | `@property({ type: String, attribute: 'drop-effect', reflect: true })`; fully reactive | No migration needed for consumers using the attribute. JS consumers using `element.dropEffect = 'move'` are unaffected. |
239+
| **B5** | `dropEffect` becomes a proper `@property` | Manual getter/setter; the `drop-effect` attribute had no effect at parse time or at runtime | `@property({ type: String, attribute: 'drop-effect' })` (no `reflect`; controls browser drag chrome, not visual state); fully reactive to the attribute at parse time and at runtime | No migration needed for consumers using the attribute: it now works where it silently did nothing before. JS consumers using `element.dropEffect = 'move'` are unaffected. |
240240
| **B6** | Public methods `onDragOver`, `onDragLeave`, `onDrop` visibility | `public` | `protected` | Consumers who subclass `Dropzone` and override these methods must update visibility. **Inferred** as low-risk (see Q9). |
241241
| **B7** | Event prefix | `sp-dropzone-should-accept`, `sp-dropzone-dragover`, `sp-dropzone-dragleave`, `sp-dropzone-drop` | **Confirmed.** Rename to `swc-dropzone-should-accept`, `swc-dropzone-dragover`, `swc-dropzone-dragleave`, `swc-dropzone-drop`. Consistent with all other migrated 2nd-gen components. | Update all `addEventListener` calls. |
242242
| **B17** | `DropzoneEventDetail` type removed | `export type DropzoneEventDetail = DragEvent;` exported from `src/index.ts` | Not exported. Clean break, same posture as other migrated components (no retained type aliases elsewhere in 2nd-gen). | Replace `DropzoneEventDetail` imports with `DragEvent` directly; the two types were always structurally identical. |
243+
| **B18** | New `filled-content` slot; `filled` swaps slots instead of restyling in place | Setting `isFilled`/`filled` only changed styling; the same slotted content stayed in the DOM and consumers updated it in place. | **Shipped.** A dedicated `filled-content` slot holds uploaded-state content. `render()` conditionally renders either the default slot or the `filled-content` slot based on `filled`, so the entire default slot (illustrated message, browse control) is unslotted while `filled` is `true`. Supersedes the narrower "replace" slot discussed in [A4](#additive--ships-when-ready-zero-breakage-for-consumers-already-on-2nd-gen), which is resolved by this broader slot rather than deferred. | Move uploaded-state content into `slot="filled-content"` instead of mutating the default slot's content in place. Keep a reachable control (e.g. "Replace file") in `filled-content` since the default slot's browse control is hidden while `filled` is `true`. See `migration-guide.mdx`. |
243244

244245
#### Styling and visuals
245246

@@ -267,7 +268,7 @@ No sequencing, shared-base, or inheritance decisions require explicit user confi
267268
| **A1** | Error state | **Figma shows no error state.** Deferred. Create a follow-up Jira ticket when design spec is available. |
268269
| **A2** | ~~Hover state visually distinct from keyboard focus~~ | **Resolved by Figma.** The "Hover" state in Figma is the same as the drag-over state; no separate pointer-hover treatment exists. The same accent border applies to `:focus-visible` on the browse control. No additive work needed; this is fully in scope as part of B10. |
269270
| **A3** | ~~Illustration accent color passthrough when dragged~~ | **Resolved by Figma.** The icon/illustration switches to the accent/gradient treatment in the "Hover" (dragged) state. This is confirmed Must-ship and is absorbed into the styling phase; see the B8/size matrix in the visual matrix section. No separate additive ticket needed. |
270-
| **A4** | Customizable replace-state overlay content | The filled+dragged state shows "Drop file to replace." A named slot (e.g. `replace`) could allow consumer-provided replace-state content. Deferred; the default announcement covers a11y requirements without a slot. |
271+
| **A4** | ~~Customizable replace-state overlay content~~ | **Resolved during implementation.** A `filled-content` slot shipped as Must-ship (see [B18](#must-ship--breaking-or-a11y-required)), broader than the "replace" slot originally scoped here: it holds all uploaded-state content, not just the filled+dragged overlay text. No separate additive ticket needed. |
271272

272273
---
273274

@@ -287,7 +288,7 @@ Use lightweight confidence labels:
287288

288289
| Property | Type | Default | Attribute | Reflected | Confidence | Notes |
289290
| -------- | ---- | ------- | --------- | --------- | ---------- | ----- |
290-
| `dropEffect` | `DropEffects` | `'copy'` | `drop-effect` | Yes | Confirmed | Proper `@property` declaration. Values validated: `'copy' \| 'move' \| 'link' \| 'none'`. |
291+
| `dropEffect` | `DropEffects` | `'copy'` | `drop-effect` | No | Confirmed | Proper `@property` declaration; reactive to the attribute at parse time and at runtime. Not reflected back to the attribute (intentional; controls browser drag chrome, not visual component state). Values validated: `'copy' \| 'move' \| 'link' \| 'none'`. |
291292
| `dragged` | `boolean` | `false` | `dragged` | Yes | Confirmed | Renamed from `isDragged`. Attribute unchanged. |
292293
| `filled` | `boolean` | `false` | `filled` | **Yes** | Confirmed | Renamed from `isFilled`. Attribute unchanged. Reflection is a bug fix. |
293294
| `size` | `'s' \| 'm' \| 'l'` | `'m'` | `size` | Yes | Confirmed | **New in 2nd-gen.** Figma shows Small, Medium, Large sizes. Maps to `'s'`, `'m'`, `'l'` per SWC conventions. Not present in 1st-gen; additive, not breaking. |
@@ -319,7 +320,8 @@ Figma state labels and their component attribute equivalents:
319320

320321
| Slot | Content | Notes |
321322
| ---- | ------- | ----- |
322-
| default | `swc-illustrated-message` with the browse control placed in its `button-group` slot. This is the canonical pattern and aligns with the React Spectrum `DropZone` + `FileTrigger` model. When `filled`, the consumer replaces this content with the uploaded state. | **Confirmed.** `swc-dropzone` renders no built-in content. A browse control is required in every usage for WCAG 2.1.1 compliance. Note: `swc-illustrated-message`'s `button-group` slot is additive (A3 in that component's migration plan) and not yet implemented; examples must use a temporary pattern (browse control alongside `swc-illustrated-message` in the dropzone slot) until it ships. |
323+
| default | `swc-illustrated-message` with the browse control placed in its `button-group` slot. This is the canonical pattern and aligns with the React Spectrum `DropZone` + `FileTrigger` model. | **Confirmed.** `swc-dropzone` renders no built-in content. A browse control is required in every usage for WCAG 2.1.1 compliance. Note: `swc-illustrated-message`'s `button-group` slot is additive (A3 in that component's migration plan) and not yet implemented; examples must use a temporary pattern (browse control alongside `swc-illustrated-message` in the dropzone slot) until it ships. Rendered only while `filled` is `false`; see `filled-content` below. |
324+
| `filled-content` | Uploaded-state content (e.g. a file name or image preview), shown once a file has been accepted. | **Shipped (B18).** Not in the original plan; added during implementation. `render()` swaps to this slot instead of restyling the default slot in place; the default slot is fully unslotted while `filled` is `true`. See [B18](#must-ship--breaking-or-a11y-required). |
323325

324326
#### CSS custom properties (2nd-gen)
325327

@@ -429,8 +431,10 @@ No `DropzoneEventDetail` alias is exported. 2nd-gen is a clean break from 1st-ge
429431

430432
- [x] Create `2nd-gen/packages/core/components/dropzone/`
431433
- [x] Create `2nd-gen/packages/swc/components/dropzone/`
432-
- [x] Wire exports in `core/package.json` — SWC `package.json` exports pending (see note below)
433-
- [ ] Wire exports in `swc/package.json` — not yet updated
434+
- [x] Wire exports in `core/package.json`
435+
- [x] Wire exports in `swc/package.json`**N/A.** No SWC component has an explicit named
436+
package.json export; every component (including dropzone) resolves through the existing
437+
`"./*"``dist/components/*/index.js` wildcard, so no dropzone-specific entry is needed.
434438
- [ ] Confirm `spectrum-css` is checked out at `spectrum-two` branch as sibling directory (`/spectrum-css/components/dropzone/index.css`)
435439

436440
### API
@@ -441,9 +445,11 @@ No `DropzoneEventDetail` alias is exported. 2nd-gen is a clean break from 1st-ge
441445
**Diverges from plan:** implemented as `DropEffect` (singular) rather than `DropEffects`.
442446
`DropzoneSize` added (needed for Phase 5 `size` property). `DropzoneEventDetail` alias
443447
intentionally **not** shipped (see [B17](#api-and-naming)) — clean break, no retained type alias.
444-
- [x] `Dropzone.base.ts`: declare `dragged` and `filled` with `@property({ type: Boolean, reflect: true })`
445-
**Diverges from plan:** `dropEffect` does not reflect as `drop-effect` attribute (intentional; controls
446-
browser chrome, not component state). Plan specifies `reflect: true`; implementation differs.
448+
- [x] `Dropzone.base.ts`: declare `dragged` and `filled` with `@property({ type: Boolean, reflect: true })`;
449+
`dropEffect` declared with `@property({ type: String, attribute: 'drop-effect' })` per B5, fully reactive
450+
to the attribute at parse time and at runtime. — **Diverges from plan:** no `reflect: true` on `dropEffect`
451+
(intentional; it controls browser drag chrome, not visual component state, so there is no need to write
452+
it back to the DOM). Plan specifies `reflect: true`; implementation differs on the write direction only.
447453
- [x] `Dropzone.base.ts`: implement drag event binding in `connectedCallback` / `disconnectedCallback`
448454
- [x] `Dropzone.base.ts`: implement debounced drag-leave; timer cleared in `disconnectedCallback`
449455
- [x] `Dropzone.base.ts`: implement `dropEffect` value validation with dev warning in DEBUG builds
@@ -490,9 +496,10 @@ No `DropzoneEventDetail` alias is exported. 2nd-gen is a clean break from 1st-ge
490496

491497
#### Naming and semantics
492498

493-
- [x] `role="group"` fixed on host — **Diverges from plan:** set in `connectedCallback` via
494-
`setAttribute` (guarded with `hasAttribute` so consumer-set roles are not overwritten), rather than
495-
via a `role` attribute on the host in `render()`.
499+
- [x] `role="group"` fixed on host — **Diverges from plan:** set unconditionally in `connectedCallback`
500+
via `setAttribute('role', 'group')`, rather than via a `role` attribute on the host in `render()`.
501+
There is no `hasAttribute` guard: per B11, the role is fixed and not author-overridable, so a
502+
consumer-set `role` is intentionally replaced rather than preserved.
496503
- [x] `aria-dropeffect` and `aria-grabbed` not used anywhere in implementation or documentation
497504
- [x] Shadow DOM contains `<div role="status" aria-live="polite" class="visually-hidden">`
498505
- [x] Dev warning fires in debug builds when neither `aria-label` nor `aria-labelledby` is present
@@ -514,6 +521,11 @@ No `DropzoneEventDetail` alias is exported. 2nd-gen is a clean break from 1st-ge
514521
#### Behavior
515522

516523
- [x] `dropEffect` defaults to `'copy'`; invalid values are silently ignored
524+
- [x] `dropEffect` reacts to the `drop-effect` attribute at initial parse and at runtime; invalid
525+
attribute values are rejected with a dev warning, same as the JS-property path (B5 regression guard)
526+
- [x] Removing the `drop-effect` attribute resets `dropEffect` to the default `'copy'` with no dev
527+
warning, distinct from setting an invalid value (Lit passes `null` for a removed attribute, which
528+
is not itself an invalid value)
517529
- [x] `dragover` without `dataTransfer` always calls `event.preventDefault()` (cross-platform drop support)
518530
- [x] `dragover` with `dataTransfer` sets `dragged = true` and fires the dragover event
519531
- [x] Cancelling `swc-dropzone-should-accept` sets `dataTransfer.dropEffect = 'none'` and prevents `dragged = true`
@@ -572,8 +584,8 @@ No `DropzoneEventDetail` alias is exported. 2nd-gen is a clean break from 1st-ge
572584

573585
### Review
574586

575-
- [ ] `yarn lint:2nd-gen` passes (ESLint, Stylelint, Prettier)
576-
- [ ] Status table in workstream doc updated
587+
- [x] `yarn lint:2nd-gen` passes (ESLint, Stylelint, Prettier)
588+
- [x] Status table in workstream doc updated
577589
- [ ] PR created with description referencing Epic SWC-2145
578590
- [ ] Peer engineer sign-off
579591

0 commit comments

Comments
 (0)