diff --git a/packages/button/src/ButtonBase.ts b/packages/button/src/ButtonBase.ts index 30830f531cf..8d533fd8008 100644 --- a/packages/button/src/ButtonBase.ts +++ b/packages/button/src/ButtonBase.ts @@ -225,13 +225,6 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [ if (!this.hasAttribute('tabindex')) { this.setAttribute('tabindex', '0'); } - if (changed.has('label')) { - if (this.label) { - this.setAttribute('aria-label', this.label); - } else { - this.removeAttribute('aria-label'); - } - } this.manageAnchor(); this.addEventListener('keydown', this.handleKeydown); this.addEventListener('keypress', this.handleKeypress); @@ -254,6 +247,14 @@ export class ButtonBase extends ObserveSlotText(LikeAnchor(Focusable), '', [ // Set up focus delegation this.anchorElement.addEventListener('focus', this.proxyFocus); + + if (changed.has('label')) { + if (this.label) { + this.setAttribute('aria-label', this.label); + } else { + this.removeAttribute('aria-label'); + } + } } } protected override update(changes: PropertyValues): void { diff --git a/packages/card/src/spectrum-card.css b/packages/card/src/spectrum-card.css index 2c1a7a08a0a..70dae996f06 100644 --- a/packages/card/src/spectrum-card.css +++ b/packages/card/src/spectrum-card.css @@ -68,7 +68,6 @@ governing permissions and limitations under the License. :host { box-sizing: border-box; min-inline-size: var(--mod-card-minimum-width, var(--spectrum-card-minimum-width)); - block-size: 100%; border: var(--spectrum-card-border-width) solid transparent; border-radius: var(--spectrum-card-corner-radius); border-color: var(--mod-card-border-color, var(--spectrum-card-border-color)); diff --git a/packages/dialog/src/DialogBase.ts b/packages/dialog/src/DialogBase.ts index 81eb5396a80..decbdc1ff13 100644 --- a/packages/dialog/src/DialogBase.ts +++ b/packages/dialog/src/DialogBase.ts @@ -24,7 +24,8 @@ import '@spectrum-web-components/underlay/sp-underlay.js'; import '@spectrum-web-components/button/sp-button.js'; // Leveraged in build systems that use aliasing to prevent multiple registrations: https://github.com/adobe/spectrum-web-components/pull/3225 -import '@spectrum-web-components/dialog/sp-dialog.js'; +// Get around lint error by importing locally for now. Not required for actual change. +import '../sp-dialog.js'; import modalWrapperStyles from '@spectrum-web-components/modal/src/modal-wrapper.css.js'; import modalStyles from '@spectrum-web-components/modal/src/modal.css.js'; import { Dialog } from './Dialog.js'; @@ -155,41 +156,18 @@ export class DialogBase extends FocusVisiblePolyfillMixin(SpectrumElement) { this.handleTransitionEvent(event); } - private get hasTransitionDuration(): boolean { - const modal = this.shadowRoot.querySelector('.modal') as HTMLElement; - - const modalTransitionDurations = - window.getComputedStyle(modal).transitionDuration; - for (const duration of modalTransitionDurations.split(',')) - if (parseFloat(duration) > 0) return true; - - const underlay = this.shadowRoot.querySelector( - 'sp-underlay' - ) as HTMLElement; - - if (underlay) { - const underlayTransitionDurations = - window.getComputedStyle(underlay).transitionDuration; - for (const duration of underlayTransitionDurations.split(',')) - if (parseFloat(duration) > 0) return true; - } - - return false; - } - protected override update(changes: PropertyValues): void { if (changes.has('open') && changes.get('open') !== undefined) { - const hasTransitionDuration = this.hasTransitionDuration; this.animating = true; this.transitionPromise = new Promise((res) => { this.resolveTransitionPromise = () => { this.animating = false; - if (!this.open && hasTransitionDuration) - this.dispatchClosed(); res(); }; }); - if (!this.open && !hasTransitionDuration) this.dispatchClosed(); + if (!this.open) { + this.dispatchClosed(); + } } super.update(changes); } diff --git a/packages/menu/src/Menu.ts b/packages/menu/src/Menu.ts index 1378a58402b..fbcd4d95733 100644 --- a/packages/menu/src/Menu.ts +++ b/packages/menu/src/Menu.ts @@ -72,6 +72,22 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { protected rovingTabindexController?: RovingTabindexController; + private touchStartY: number | undefined = undefined; + private touchStartTime: number | undefined = undefined; + private isCurrentlyScrolling = false; + + private scrollThreshold = 10; // pixels + private scrollTimeThreshold = 300; // milliseconds + + public get isScrolling(): boolean { + return this.isCurrentlyScrolling; + } + + public set isScrolling(value: boolean) { + // For testing purposes, allow setting the scrolling state + this.isCurrentlyScrolling = value; + } + /** * label of the menu */ @@ -400,6 +416,13 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { this.addEventListener('pointerup', this.handlePointerup); this.addEventListener('sp-opened', this.handleSubmenuOpened); this.addEventListener('sp-closed', this.handleSubmenuClosed); + + this.addEventListener('touchstart', this.handleTouchStart, { + passive: true, + }); + this.addEventListener('touchmove', this.handleTouchMove, { + passive: true, + }); } /** @@ -443,6 +466,42 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { } } + private handleTouchStart(event: TouchEvent): void { + if (event.touches.length === 1) { + this.touchStartY = event.touches[0].clientY; + this.touchStartTime = Date.now(); + this.isCurrentlyScrolling = false; + } + } + + private handleTouchMove(event: TouchEvent): void { + if ( + event.touches.length === 1 && + this.touchStartY !== undefined && + this.touchStartTime !== undefined + ) { + const currentY = event.touches[0].clientY; + const deltaY = Math.abs(currentY - this.touchStartY); + const deltaTime = Date.now() - this.touchStartTime; + + if ( + deltaY > this.scrollThreshold && + deltaTime < this.scrollTimeThreshold + ) { + this.isCurrentlyScrolling = true; + } + } + } + + private handleTouchEnd(): void { + // Reset scrolling state after a short delay + setTimeout(() => { + this.isCurrentlyScrolling = false; + this.touchStartY = undefined; + this.touchStartTime = undefined; + }, 100); + } + // if the click and pointerup events are on the same target, we should not // handle the click event. private pointerUpTarget = null as EventTarget | null; @@ -461,6 +520,7 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { } private handlePointerup(event: Event): void { + this.handleTouchEnd(); /* * early return if drag and select is not supported * in this case, selection will be handled by the click event @@ -478,6 +538,10 @@ export class Menu extends SizedMixin(SpectrumElement, { noDefaultSize: true }) { return; } + if (this.isScrolling) { + return; + } + const path = event.composedPath(); const target = path.find((el) => { /* c8 ignore next 3 */ diff --git a/packages/menu/src/MenuItem.ts b/packages/menu/src/MenuItem.ts index 6c6371d4bb7..76a277cbf16 100644 --- a/packages/menu/src/MenuItem.ts +++ b/packages/menu/src/MenuItem.ts @@ -472,20 +472,27 @@ export class MenuItem extends LikeAnchor( super.firstUpdated(changes); this.setAttribute('tabindex', '-1'); this.addEventListener('keydown', this.handleKeydown); - this.addEventListener('mouseover', this.handleMouseover); + this.addEventListener('mouseenter', this.handleMouseenter); + this.addEventListener('mouseleave', this.handleMouseleave); this.addEventListener('pointerdown', this.handlePointerdown); this.addEventListener('pointerenter', this.closeOverlaysForRoot); if (!this.hasAttribute('id')) { this.id = `sp-menu-item-${randomID()}`; } } - handleMouseover(event: MouseEvent): void { + handleMouseenter(event: MouseEvent): void { const target = event.target as HTMLElement; if (target === this) { this.focus(); this.focused = false; } } + handleMouseleave(event: MouseEvent): void { + const target = event.target as HTMLElement; + if (target === this) { + this.blur(); + } + } /** * forward key info from keydown event to parent menu */ diff --git a/packages/number-field/src/NumberField.ts b/packages/number-field/src/NumberField.ts index 44dbca28959..0b408128aca 100644 --- a/packages/number-field/src/NumberField.ts +++ b/packages/number-field/src/NumberField.ts @@ -817,6 +817,7 @@ export class NumberField extends TextfieldBase { } protected override updated(changes: PropertyValues): void { + super.updated(changes); if (!this.inputElement || !this.isConnected) { // Prevent race conditions if inputElement is removed from DOM while a queued update is still running. return; diff --git a/packages/overlay/README.md b/packages/overlay/README.md index 78162fb6b74..e136526e86c 100644 --- a/packages/overlay/README.md +++ b/packages/overlay/README.md @@ -269,7 +269,7 @@ Some Overlays will always be passed focus (e.g. modal or page Overlays). When th The `trigger` option accepts an `HTMLElement` or a `VirtualTrigger` from which to position the Overlay. -- You can import the `VirtualTrigger` class from the overlay package to create a virtual trigger that can be used to position an Overlay. This is useful when you want to position an Overlay relative to a point on the screen that is not an element in the DOM, like the mouse cursor. +- You can import the `VirtualTrigger` class from the overlay package to create a virtual trigger that can be used to position an Overlay. This is useful when you want to position an Overlay relative to a point on the screen that is not an element in the DOM, like the mouse cursor. The `type` of an Overlay outlines a number of things about the interaction model within which it works: @@ -408,8 +408,8 @@ The `overlay` value in this case will hold a reference to the actual `` element have successfully dispatched their `transitionend` or `transitioncancel` event. Keep in mind the following: -- `transition*` events bubble; this means that while transition events on light DOM content of those direct children will be heard, those events will not be taken into account -- `transition*` events are not composed; this means that transition events on shadow DOM content of the direct children will not propagate to a level in the DOM where they can be heard +- `transition*` events bubble; this means that while transition events on light DOM content of those direct children will be heard, those events will not be taken into account +- `transition*` events are not composed; this means that transition events on shadow DOM content of the direct children will not propagate to a level in the DOM where they can be heard This means that in both cases, if the transition is meant to be a part of the opening or closing of the overlay in question you will need to redispatch the `transitionrun`, `transitionend`, and `transitioncancel` events from that transition from the closest direct child of the ``. @@ -772,9 +772,9 @@ When nesting multiple overlays, it is important to ensure that the nested overla The overlay manages focus based on its type: -- For `modal` and `page` types, focus is always trapped within the overlay -- For `auto` and `manual` types, focus behavior is controlled by the `receives-focus` attribute -- For `hint` type, focus remains on the trigger element +- For `modal` and `page` types, focus is always trapped within the overlay +- For `auto` and `manual` types, focus behavior is controlled by the `receives-focus` attribute +- For `hint` type, focus remains on the trigger element Example of proper focus management: @@ -840,10 +840,10 @@ Example of proper focus management: #### Screen reader considerations -- Use `aria-haspopup` on trigger elements to indicate the type of overlay -- Provide descriptive labels using `aria-label` or `aria-labelledby` -- Use proper heading structure within overlays -- Ensure error messages are announced using `aria-live` +- Use `aria-haspopup` on trigger elements to indicate the type of overlay +- Provide descriptive labels using `aria-label` or `aria-labelledby` +- Use proper heading structure within overlays +- Ensure error messages are announced using `aria-live` Example of a tooltip with proper screen reader support: diff --git a/packages/overlay/package.json b/packages/overlay/package.json index b0500e5a3a8..1530b93e236 100644 --- a/packages/overlay/package.json +++ b/packages/overlay/package.json @@ -170,8 +170,7 @@ "@spectrum-web-components/base": "1.7.0", "@spectrum-web-components/reactive-controllers": "1.7.0", "@spectrum-web-components/shared": "1.7.0", - "@spectrum-web-components/theme": "1.7.0", - "focus-trap": "^7.6.4" + "@spectrum-web-components/theme": "1.7.0" }, "types": "./src/index.d.ts", "customElements": "custom-elements.json", diff --git a/packages/overlay/src/AbstractOverlay.ts b/packages/overlay/src/AbstractOverlay.ts index 50a0e1dc3de..5c3a4b50efd 100644 --- a/packages/overlay/src/AbstractOverlay.ts +++ b/packages/overlay/src/AbstractOverlay.ts @@ -162,6 +162,10 @@ export class AbstractOverlay extends SpectrumElement { return; } /* c8 ignore next 3 */ + protected async manageDialogOpen(): Promise { + return; + } + /* c8 ignore next 3 */ protected async managePopoverOpen(): Promise { return; } @@ -240,6 +244,7 @@ export class AbstractOverlay extends SpectrumElement { content?: HTMLElement, optionsV1?: OverlayOptionsV1 ): Promise void)> { + /* eslint-disable */ await import('@spectrum-web-components/overlay/sp-overlay.js'); const v2 = arguments.length === 2; const overlayContent = content || triggerOrContent; diff --git a/packages/overlay/src/HoverController.ts b/packages/overlay/src/HoverController.ts index 7f12a7e5c40..95ed877440f 100644 --- a/packages/overlay/src/HoverController.ts +++ b/packages/overlay/src/HoverController.ts @@ -18,6 +18,7 @@ import { InteractionController, InteractionTypes, lastInteractionType, + SAFARI_FOCUS_RING_CLASS, } from './InteractionController.js'; const HOVER_DELAY = 300; @@ -36,6 +37,7 @@ export class HoverController extends InteractionController { handleKeyup(event: KeyboardEvent): void { if (event.code === 'Tab' || event.code === 'Escape') { this.open = true; + this.removeSafariFocusRingClass(); } } @@ -48,14 +50,17 @@ export class HoverController extends InteractionController { isWebKit() && this.target[lastInteractionType] === InteractionTypes.click ) { + this.target.classList.add(SAFARI_FOCUS_RING_CLASS); return; } this.open = true; this.focusedin = true; + this.removeSafariFocusRingClass(); } handleTargetFocusout(): void { + this.removeSafariFocusRingClass(); this.focusedin = false; if (this.pointerentered) return; this.open = false; @@ -199,4 +204,12 @@ export class HoverController extends InteractionController { { signal } ); } + + private removeSafariFocusRingClass(): void { + if ( + isWebKit() && + this.target.classList.contains(SAFARI_FOCUS_RING_CLASS) + ) + this.target.classList.remove(SAFARI_FOCUS_RING_CLASS); + } } diff --git a/packages/overlay/src/InteractionController.ts b/packages/overlay/src/InteractionController.ts index 3584c3140c2..2c1b52ea83b 100644 --- a/packages/overlay/src/InteractionController.ts +++ b/packages/overlay/src/InteractionController.ts @@ -20,6 +20,7 @@ export enum InteractionTypes { } export const lastInteractionType = Symbol('lastInteractionType'); +export const SAFARI_FOCUS_RING_CLASS = 'remove-focus-ring-safari-hack'; export type ControllerOptions = { overlay?: AbstractOverlay; @@ -74,6 +75,7 @@ export class InteractionController implements ReactiveController { this.overlay.open = true; this.target[lastInteractionType] = this.type; }); + /* eslint-disable */ import('@spectrum-web-components/overlay/sp-overlay.js'); } diff --git a/packages/overlay/src/Overlay.ts b/packages/overlay/src/Overlay.ts index 9ec533d19a0..8613cef5246 100644 --- a/packages/overlay/src/Overlay.ts +++ b/packages/overlay/src/Overlay.ts @@ -38,6 +38,7 @@ import type { TriggerInteraction, } from './overlay-types.js'; import { AbstractOverlay, nextFrame } from './AbstractOverlay.js'; +import { OverlayDialog } from './OverlayDialog.js'; import { OverlayPopover } from './OverlayPopover.js'; import { OverlayNoPopover } from './OverlayNoPopover.js'; import { overlayStack } from './OverlayStack.js'; @@ -54,14 +55,16 @@ import { } from './slottable-request-event.js'; import styles from './overlay.css.js'; -import { FocusTrap } from 'focus-trap'; const browserSupportsPopover = 'showPopover' in document.createElement('div'); // Start the base class and add the popover or no-popover functionality -let ComputedOverlayBase = OverlayPopover(AbstractOverlay); -if (!browserSupportsPopover) { - ComputedOverlayBase = OverlayNoPopover(AbstractOverlay); +let ComputedOverlayBase = OverlayDialog(AbstractOverlay); + +if (browserSupportsPopover) { + ComputedOverlayBase = OverlayPopover(ComputedOverlayBase); +} else { + ComputedOverlayBase = OverlayNoPopover(ComputedOverlayBase); } /** @@ -394,12 +397,6 @@ export class Overlay extends ComputedOverlayBase { */ protected wasOpen = false; - /** - * Focus trap to keep focus within the dialog - * @private - */ - private _focusTrap: FocusTrap | null = null; - /** * Provides an instance of the `ElementResolutionController` for managing the element * that the overlay should be associated with. If the instance does not already exist, @@ -416,6 +413,17 @@ export class Overlay extends ComputedOverlayBase { return this._elementResolver; } + /** + * Determines if the overlay uses a dialog. + * Returns `true` if the overlay type is "modal" or "page". + * + * @private + * @returns {boolean} `true` if the overlay uses a dialog, otherwise `false`. + */ + private get usesDialog(): boolean { + return this.type === 'modal' || this.type === 'page'; + } + /** * Determines the value for the popover attribute based on the overlay type. * @@ -431,9 +439,8 @@ export class Overlay extends ComputedOverlayBase { switch (this.type) { case 'modal': - return 'auto'; case 'page': - return 'manual'; + return undefined; case 'hint': return 'manual'; default: @@ -538,26 +545,7 @@ export class Overlay extends ComputedOverlayBase { if (this.open !== targetOpenState) { return; } - if (targetOpenState) { - const focusTrap = await import('focus-trap'); - this._focusTrap = focusTrap.createFocusTrap(this.dialogEl, { - initialFocus: focusEl || undefined, - tabbableOptions: { - getShadowRoot: true, - }, - fallbackFocus: () => { - // set tabIndex to -1 allow the focus-trap to still be applied - this.dialogEl.setAttribute('tabIndex', '-1'); - return this.dialogEl; - }, - // disable escape key capture to close the overlay, the focus-trap library captures it otherwise - escapeDeactivates: false, - }); - - if (this.type === 'modal' || this.type === 'page') { - this._focusTrap.activate(); - } - } + // Apply focus to the appropriate element after opening the popover. await this.applyFocus(targetOpenState, focusEl); } @@ -700,10 +688,6 @@ export class Overlay extends ComputedOverlayBase { event.relatedTarget.dispatchEvent(relationEvent); }; - private closeOnCancelEvent = (): void => { - this.open = false; - }; - /** * Manages the process of opening or closing the overlay. * @@ -716,7 +700,7 @@ export class Overlay extends ComputedOverlayBase { */ protected async manageOpen(oldOpen: boolean): Promise { // Prevent entering the manage workflow if the overlay is not connected to the DOM. - // The `.showPopover()` event will error on content that is not connected to the DOM. + // The `.showPopover()` and `.showModal()` events will error on content that is not connected to the DOM. if (!this.isConnected && this.open) return; // Wait for the component to finish updating if it has not already done so. @@ -748,8 +732,6 @@ export class Overlay extends ComputedOverlayBase { } } else { if (oldOpen) { - this._focusTrap?.deactivate(); - this._focusTrap = null; // Dispose of the overlay if it was previously open. this.dispose(); } @@ -765,11 +747,16 @@ export class Overlay extends ComputedOverlayBase { this.state = 'closing'; } - this.managePopoverOpen(); + // Manage the dialog or popover based on the overlay type. + if (this.usesDialog) { + this.manageDialogOpen(); + } else { + this.managePopoverOpen(); + } - const listenerRoot = this.getRootNode() as Document; // Handle focus events for auto type overlays. if (this.type === 'auto') { + const listenerRoot = this.getRootNode() as Document; if (this.open) { listenerRoot.addEventListener( 'focusout', @@ -784,27 +771,6 @@ export class Overlay extends ComputedOverlayBase { ); } } - - // Handle cancel events for modal and page type overlays. - if (this.type === 'modal' || this.type === 'page') { - if (this.open) { - listenerRoot.addEventListener( - 'cancel', - this.closeOnCancelEvent, - { - capture: true, - } - ); - } else { - listenerRoot.removeEventListener( - 'cancel', - this.closeOnCancelEvent, - { - capture: true, - } - ); - } - } } /** @@ -1081,6 +1047,45 @@ export class Overlay extends ComputedOverlayBase { }; } + /** + * Renders the dialog element for the overlay. + * + * This method returns a template result containing a dialog element. The dialog element + * includes various attributes and event listeners to manage the overlay's state and behavior. + * + * @protected + * @returns {TemplateResult} The template result containing the dialog element. + */ + protected renderDialog(): TemplateResult { + /** + * The `--swc-overlay-open-count` custom property is applied to mimic the single stack + * nature of the top layer in browsers that do not yet support it. + * + * The value should always represent the total number of overlays that have ever been opened. + * This value will be added to the `--swc-overlay-z-index-base` custom property, which can be + * provided by a consuming developer. By default, `--swc-overlay-z-index-base` is set to 1000 + * to ensure that the overlay stacks above most other elements during fallback delivery. + */ + return html` + + ${this.renderContent()} + + `; + } + /** * Renders the popover element for the overlay. * @@ -1104,16 +1109,6 @@ export class Overlay extends ComputedOverlayBase {
`; } diff --git a/packages/overlay/src/OverlayDialog.ts b/packages/overlay/src/OverlayDialog.ts new file mode 100644 index 00000000000..09e2d0d510f --- /dev/null +++ b/packages/overlay/src/OverlayDialog.ts @@ -0,0 +1,181 @@ +/* +Copyright 2020 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 type { SpectrumElement } from '@spectrum-web-components/base'; +import { + firstFocusableIn, + firstFocusableSlottedIn, +} from '@spectrum-web-components/shared/src/first-focusable-in.js'; +import { VirtualTrigger } from './VirtualTrigger.js'; +import { Constructor, OpenableElement } from './overlay-types.js'; +import { guaranteedAllTransitionend, nextFrame } from './AbstractOverlay.js'; +import { + BeforetoggleClosedEvent, + BeforetoggleOpenEvent, + OverlayStateEvent, +} from './events.js'; +import type { AbstractOverlay } from './AbstractOverlay.js'; +import { userFocusableSelector } from '@spectrum-web-components/shared'; + +export function OverlayDialog>( + constructor: T +): T & Constructor { + class OverlayWithDialog extends constructor { + protected override async manageDialogOpen(): Promise { + const targetOpenState = this.open; + await this.managePosition(); + if (this.open !== targetOpenState) { + return; + } + const focusEl = await this.dialogMakeTransition(targetOpenState); + if (this.open !== targetOpenState) { + return; + } + await this.dialogApplyFocus(targetOpenState, focusEl); + } + + protected async dialogMakeTransition( + targetOpenState: boolean + ): Promise { + let focusEl = null as HTMLElement | null; + const start = + (el: OpenableElement, index: number) => + async (): Promise => { + el.open = targetOpenState; + if (!targetOpenState) { + const close = (): void => { + el.removeEventListener('close', close); + finish(el, index); + }; + el.addEventListener('close', close); + } + if (index > 0) { + // Announce workflow on the first element _only_. + return; + } + const event = targetOpenState + ? BeforetoggleOpenEvent + : BeforetoggleClosedEvent; + this.dispatchEvent(new event()); + if (!targetOpenState) { + // Show/focus workflow when opening _only_. + return; + } + if (el.matches(userFocusableSelector)) { + focusEl = el; + } + focusEl = focusEl || firstFocusableIn(el); + if (!focusEl) { + const childSlots = el.querySelectorAll('slot'); + childSlots.forEach((slot) => { + if (!focusEl) { + focusEl = firstFocusableSlottedIn(slot); + } + }); + } + if (!this.isConnected || this.dialogEl.open) { + // In both of these cases the browser will error. + // You can neither "reopen" a or open one that is not on the DOM. + return; + } + this.dialogEl.showModal(); + }; + const finish = (el: OpenableElement, index: number) => (): void => { + if (this.open !== targetOpenState) { + return; + } + const eventName = targetOpenState ? 'sp-opened' : 'sp-closed'; + if (index > 0) { + el.dispatchEvent( + new OverlayStateEvent(eventName, this, { + interaction: this.type, + publish: false, + }) + ); + return; + } + if (!this.isConnected || targetOpenState !== this.open) { + // Don't lead into the `.close()` workflow if not connected to the DOM. + // The browser will error in this case. + return; + } + const reportChange = async (): Promise => { + const hasVirtualTrigger = + this.triggerElement instanceof VirtualTrigger; + this.dispatchEvent( + new OverlayStateEvent(eventName, this, { + interaction: this.type, + publish: hasVirtualTrigger, + }) + ); + el.dispatchEvent( + new OverlayStateEvent(eventName, this, { + interaction: this.type, + publish: false, + }) + ); + if (this.triggerElement && !hasVirtualTrigger) { + (this.triggerElement as HTMLElement).dispatchEvent( + new OverlayStateEvent(eventName, this, { + interaction: this.type, + publish: true, + }) + ); + } + this.state = targetOpenState ? 'opened' : 'closed'; + this.returnFocus(); + // Ensure layout and paint are done and the Overlay is still closed before removing the slottable request. + await nextFrame(); + await nextFrame(); + if ( + targetOpenState === this.open && + targetOpenState === false + ) { + this.requestSlottable(); + } + }; + if (!targetOpenState && this.dialogEl.open) { + this.dialogEl.addEventListener( + 'close', + () => { + reportChange(); + }, + { once: true } + ); + this.dialogEl.close(); + } else { + reportChange(); + } + }; + this.elements.forEach((el, index) => { + guaranteedAllTransitionend( + el, + start(el, index), + finish(el, index) + ); + }); + return focusEl; + } + + protected async dialogApplyFocus( + targetOpenState: boolean, + focusEl: HTMLElement | null + ): Promise { + /** + * Focus should be handled natively in `` elements when leveraging `.showModal()`, but it's NOT. + * - webkit bug: https://bugs.webkit.org/show_bug.cgi?id=255507 + * - firefox bug: https://bugzilla.mozilla.org/show_bug.cgi?id=1828398 + **/ + this.applyFocus(targetOpenState, focusEl); + } + } + return OverlayWithDialog; +} diff --git a/packages/overlay/src/OverlayPopover.ts b/packages/overlay/src/OverlayPopover.ts index 76b381a91c8..5be607f092a 100644 --- a/packages/overlay/src/OverlayPopover.ts +++ b/packages/overlay/src/OverlayPopover.ts @@ -123,6 +123,7 @@ export function OverlayPopover>( protected override async ensureOnDOM( targetOpenState: boolean ): Promise { + await nextFrame(); if (!supportsOverlayAuto) { await this.shouldHidePopover(targetOpenState); } diff --git a/packages/overlay/src/overlay.css b/packages/overlay/src/overlay.css index 450576b1c83..eb6e49a14cc 100644 --- a/packages/overlay/src/overlay.css +++ b/packages/overlay/src/overlay.css @@ -185,7 +185,8 @@ slot[name="longpress-describedby-descriptor"] { transition-behavior: allow-discrete; } - .dialog:popover-open { + .dialog:popover-open, + .dialog:modal { display: flex; } } diff --git a/packages/picker/src/InteractionController.ts b/packages/picker/src/InteractionController.ts index f3b9a731548..2fb506fffb5 100644 --- a/packages/picker/src/InteractionController.ts +++ b/packages/picker/src/InteractionController.ts @@ -1,14 +1,14 @@ -/* -Copyright 2024 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. -*/ +/** + * Copyright 2025 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 { ReactiveController, diff --git a/packages/tabs/src/Tabs.ts b/packages/tabs/src/Tabs.ts index a48c849c2b0..3c410ee7492 100644 --- a/packages/tabs/src/Tabs.ts +++ b/packages/tabs/src/Tabs.ts @@ -161,7 +161,7 @@ export class Tabs extends SizedMixin(Focusable, { noDefaultSize: true }) { private slotEl!: HTMLSlotElement; @query('#list') - private tabList!: HTMLDivElement; + protected tabList!: HTMLDivElement; @property({ reflect: true }) selected = ''; @@ -178,7 +178,7 @@ export class Tabs extends SizedMixin(Focusable, { noDefaultSize: true }) { this.rovingTabindexController.clearElementCache(); } - private get tabs(): Tab[] { + protected get tabs(): Tab[] { return this._tabs; } @@ -300,7 +300,7 @@ export class Tabs extends SizedMixin(Focusable, { noDefaultSize: true }) { return complete; } - private getNecessaryAutoScroll(index: number): number { + protected getNecessaryAutoScroll(index: number): number { const selectedTab = this.tabs[index]; const selectionEnd = selectedTab.offsetLeft + selectedTab.offsetWidth; const viewportEnd = this.tabList.scrollLeft + this.tabList.offsetWidth; diff --git a/packages/tabs/src/TabsOverflow.ts b/packages/tabs/src/TabsOverflow.ts index bd261144998..5725b2d1a8e 100644 --- a/packages/tabs/src/TabsOverflow.ts +++ b/packages/tabs/src/TabsOverflow.ts @@ -101,7 +101,7 @@ export class TabsOverflow extends SizedMixin(SpectrumElement) { this._updateScrollState(); } - private _updateScrollState(): void { + protected _updateScrollState(): void { const { scrollContent, overflowState } = this; if (scrollContent) { diff --git a/packages/textfield/src/Textfield.ts b/packages/textfield/src/Textfield.ts index 44476c9fa3d..aa60220c790 100644 --- a/packages/textfield/src/Textfield.ts +++ b/packages/textfield/src/Textfield.ts @@ -27,6 +27,7 @@ import { query, state, } from '@spectrum-web-components/base/src/decorators.js'; +import { isWebKit } from '@spectrum-web-components/shared'; import { ManageHelpText } from '@spectrum-web-components/help-text/src/manage-help-text.js'; import { Focusable } from '@spectrum-web-components/shared/src/focusable.js'; @@ -55,6 +56,14 @@ export class TextfieldBase extends ManageHelpText( @state() protected appliedLabel?: string; + private _firstUpdateAfterConnected = true; + + private _inputRef?: HTMLInputElement | HTMLTextAreaElement; + private _formRef?: HTMLFormElement; + + @query('#form-wrapper') + protected formElement!: HTMLFormElement; + /** * A regular expression outlining the keys that will be allowed to update the value of the form control. */ @@ -67,7 +76,7 @@ export class TextfieldBase extends ManageHelpText( @property({ type: Boolean, reflect: true }) public focused = false; - @query('.input:not(#sizer)') + @query('input, input.input:not(#sizer), textarea.input:not(#sizer)') protected inputElement!: HTMLInputElement | HTMLTextAreaElement; /** @@ -203,6 +212,9 @@ export class TextfieldBase extends ManageHelpText( | HTMLTextAreaElement['autocomplete']; public override get focusElement(): HTMLInputElement | HTMLTextAreaElement { + if (isWebKit()) { + return this._inputRef ?? this.inputElement; + } return this.inputElement; } @@ -272,6 +284,104 @@ export class TextfieldBase extends ManageHelpText( protected handleInputElementPointerdown(): void {} + protected handleInputSubmit(event: Event): void { + this.dispatchEvent( + new Event('submit', { + cancelable: true, + bubbles: true, + }) + ); + event.preventDefault(); + } + + private _eventHandlers = { + input: this.handleInput.bind(this), + change: this.handleChange.bind(this), + focus: this.onFocus.bind(this), + blur: this.onBlur.bind(this), + submit: this.handleInputSubmit.bind(this), + }; + + protected firstUpdateAfterConnected(): void { + this._inputRef = this.inputElement; + if (this.formElement) { + this._formRef = this.formElement; + this.formElement.addEventListener( + 'submit', + this._eventHandlers.submit + ); + this.inputElement.addEventListener( + 'change', + this._eventHandlers['change'] + ); + this.inputElement.addEventListener( + 'input', + this._eventHandlers['input'] + ); + this.inputElement.addEventListener( + 'focus', + this._eventHandlers['focus'] + ); + this.inputElement.addEventListener( + 'blur', + this._eventHandlers['blur'] as EventListener + ); + } + } + + protected override updated(changes: PropertyValues): void { + super.updated(changes); + if (isWebKit() && this._firstUpdateAfterConnected) { + this._firstUpdateAfterConnected = false; + this.firstUpdateAfterConnected(); + } + } + + override connectedCallback(): void { + super.connectedCallback(); + if (isWebKit()) { + this._firstUpdateAfterConnected = true; + if (this._formRef) { + const formContainer = + this.shadowRoot.querySelector('#form-container'); + if (formContainer) { + formContainer.appendChild(this._formRef); + this.requestUpdate(); + } + this._formRef = undefined; + } + } + } + + override disconnectedCallback(): void { + if (isWebKit()) { + this._inputRef?.removeEventListener( + 'change', + this._eventHandlers['change'] + ); + this._inputRef?.removeEventListener( + 'input', + this._eventHandlers['input'] + ); + this._inputRef?.removeEventListener( + 'focus', + this._eventHandlers['focus'] + ); + this._inputRef?.removeEventListener( + 'blur', + this._eventHandlers['blur'] as EventListener + ); + if (this._formRef) { + this._formRef.remove(); + this._formRef.removeEventListener( + 'submit', + this._eventHandlers.submit + ); + } + } + super.disconnectedCallback(); + } + protected renderStateIcons(): TemplateResult | typeof nothing { if (this.invalid) { return html` @@ -334,6 +444,36 @@ export class TextfieldBase extends ManageHelpText( } private get renderInput(): TemplateResult { + if (isWebKit()) { + return html` + +
+
+ -1 ? this.maxlength : undefined + )} + minlength=${ifDefined( + this.minlength > -1 ? this.minlength : undefined + )} + pattern=${ifDefined(this.pattern)} + placeholder=${this.placeholder} + .value=${live(this.displayValue)} + ?disabled=${this.disabled} + ?required=${this.required} + ?readonly=${this.readonly} + autocomplete=${ifDefined(this.autocomplete)} + /> +
+
+ `; + } return html` summary:first-of-type:not([inert])', - 'details:not([inert])', - '[focusable]:not([focusable="false"])', // custom dev use-case + 'button', + '[focusable]', + '[href]', + 'input', + 'label', + 'select', + 'textarea', + '[tabindex]', ]; const userFocuable = ':not([tabindex="-1"])'; diff --git a/tools/styles/fonts.css b/tools/styles/fonts.css index 22270fbe70b..a7322d5e8ef 100755 --- a/tools/styles/fonts.css +++ b/tools/styles/fonts.css @@ -73,8 +73,6 @@ --spectrum-heading-cjk-font-family: var(--spectrum-cjk-font-family-stack); --spectrum-heading-cjk-letter-spacing: var(--spectrum-cjk-letter-spacing); --spectrum-heading-font-color: var(--spectrum-heading-color); - --spectrum-heading-margin-start: calc(var(--mod-heading-font-size, var(--spectrum-heading-font-size)) * var(--spectrum-heading-margin-top-multiplier)); - --spectrum-heading-margin-end: calc(var(--mod-heading-font-size, var(--spectrum-heading-font-size)) * var(--spectrum-heading-margin-bottom-multiplier)); font-family: var(--mod-heading-sans-serif-font-family, var(--spectrum-heading-sans-serif-font-family)); font-style: var(--mod-heading-sans-serif-font-style, var(--spectrum-heading-sans-serif-font-style)); font-weight: var(--mod-heading-sans-serif-font-weight, var(--spectrum-heading-sans-serif-font-weight)); @@ -502,8 +500,6 @@ --spectrum-detail-sans-serif-font-family: var(--spectrum-sans-font-family-stack); --spectrum-detail-serif-font-family: var(--spectrum-serif-font-family-stack); --spectrum-detail-cjk-font-family: var(--spectrum-cjk-font-family-stack); - --spectrum-detail-margin-start: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-top-multiplier)); - --spectrum-detail-margin-end: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-bottom-multiplier)); --spectrum-detail-font-color: var(--spectrum-detail-color); font-family: var(--mod-detail-sans-serif-font-family, var(--spectrum-detail-sans-serif-font-family)); font-style: var(--mod-detail-sans-serif-font-style, var(--spectrum-detail-sans-serif-font-style)); diff --git a/tools/styles/src/spectrum-detail.css b/tools/styles/src/spectrum-detail.css index 6fc55f888d3..8e1d86ef59c 100644 --- a/tools/styles/src/spectrum-detail.css +++ b/tools/styles/src/spectrum-detail.css @@ -43,8 +43,6 @@ governing permissions and limitations under the License. --spectrum-detail-sans-serif-font-family: var(--spectrum-sans-font-family-stack); --spectrum-detail-serif-font-family: var(--spectrum-serif-font-family-stack); --spectrum-detail-cjk-font-family: var(--spectrum-cjk-font-family-stack); - --spectrum-detail-margin-start: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-top-multiplier)); - --spectrum-detail-margin-end: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-bottom-multiplier)); --spectrum-detail-font-color: var(--spectrum-detail-color); font-family: var(--mod-detail-sans-serif-font-family, var(--spectrum-detail-sans-serif-font-family)); font-style: var(--mod-detail-sans-serif-font-style, var(--spectrum-detail-sans-serif-font-style)); diff --git a/tools/styles/src/spectrum-heading.css b/tools/styles/src/spectrum-heading.css index a2e68138fda..eca19b62de8 100644 --- a/tools/styles/src/spectrum-heading.css +++ b/tools/styles/src/spectrum-heading.css @@ -69,8 +69,6 @@ governing permissions and limitations under the License. --spectrum-heading-cjk-font-family: var(--spectrum-cjk-font-family-stack); --spectrum-heading-cjk-letter-spacing: var(--spectrum-cjk-letter-spacing); --spectrum-heading-font-color: var(--spectrum-heading-color); - --spectrum-heading-margin-start: calc(var(--mod-heading-font-size, var(--spectrum-heading-font-size)) * var(--spectrum-heading-margin-top-multiplier)); - --spectrum-heading-margin-end: calc(var(--mod-heading-font-size, var(--spectrum-heading-font-size)) * var(--spectrum-heading-margin-bottom-multiplier)); font-family: var(--mod-heading-sans-serif-font-family, var(--spectrum-heading-sans-serif-font-family)); font-style: var(--mod-heading-sans-serif-font-style, var(--spectrum-heading-sans-serif-font-style)); font-weight: var(--mod-heading-sans-serif-font-weight, var(--spectrum-heading-sans-serif-font-weight)); diff --git a/tools/styles/typography.css b/tools/styles/typography.css index 22270fbe70b..6d88c9803fd 100644 --- a/tools/styles/typography.css +++ b/tools/styles/typography.css @@ -502,8 +502,6 @@ --spectrum-detail-sans-serif-font-family: var(--spectrum-sans-font-family-stack); --spectrum-detail-serif-font-family: var(--spectrum-serif-font-family-stack); --spectrum-detail-cjk-font-family: var(--spectrum-cjk-font-family-stack); - --spectrum-detail-margin-start: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-top-multiplier)); - --spectrum-detail-margin-end: calc(var(--mod-detail-font-size, var(--spectrum-detail-font-size)) * var(--spectrum-detail-margin-bottom-multiplier)); --spectrum-detail-font-color: var(--spectrum-detail-color); font-family: var(--mod-detail-sans-serif-font-family, var(--spectrum-detail-sans-serif-font-family)); font-style: var(--mod-detail-sans-serif-font-style, var(--spectrum-detail-sans-serif-font-style)); diff --git a/yarn.lock b/yarn.lock index 8200cdf86aa..02612bb5a5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6960,7 +6960,6 @@ __metadata: "@spectrum-web-components/reactive-controllers": "npm:1.7.0" "@spectrum-web-components/shared": "npm:1.7.0" "@spectrum-web-components/theme": "npm:1.7.0" - focus-trap: "npm:^7.6.4" languageName: unknown linkType: soft @@ -17939,15 +17938,6 @@ __metadata: languageName: node linkType: hard -"focus-trap@npm:^7.6.4": - version: 7.6.4 - resolution: "focus-trap@npm:7.6.4" - dependencies: - tabbable: "npm:^6.2.0" - checksum: 10c0/ed810d47fd904a5e0269e822d98e634c6cbdd7222046c712ef299bdd26a422db754e3cec04e6517065b12be4b47f65c21f6244e0c07a308b1060985463d518cb - languageName: node - linkType: hard - "focus-visible@npm:^5.1.0": version: 5.2.0 resolution: "focus-visible@npm:5.2.0" @@ -31569,13 +31559,6 @@ __metadata: languageName: node linkType: hard -"tabbable@npm:^6.2.0": - version: 6.2.0 - resolution: "tabbable@npm:6.2.0" - checksum: 10c0/ced8b38f05f2de62cd46836d77c2646c42b8c9713f5bd265daf0e78ff5ac73d3ba48a7ca45f348bafeef29b23da7187c72250742d37627883ef89cbd7fa76898 - languageName: node - linkType: hard - "table-layout@npm:^1.0.1": version: 1.0.2 resolution: "table-layout@npm:1.0.2"