diff --git a/2nd-gen/packages/core/components/popover/Popover.base.ts b/2nd-gen/packages/core/components/popover/Popover.base.ts new file mode 100644 index 00000000000..3497f7b181e --- /dev/null +++ b/2nd-gen/packages/core/components/popover/Popover.base.ts @@ -0,0 +1,194 @@ +/** + * 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 { PropertyValues } from 'lit'; +import { property } from 'lit/decorators.js'; + +import type { VirtualTrigger } from '@spectrum-web-components/core/controllers/index.js'; +import { SpectrumElement } from '@spectrum-web-components/core/element/index.js'; + +import { + type Placement, + POPOVER_VALID_PLACEMENTS, + POPOVER_VALID_SIZES, + type PopoverSize, +} from './Popover.types.js'; + +/** + * Abstract base for the popover component. Declares the public property surface + * shared by all popover implementations; the concrete SWC subclass owns + * rendering, the dialog lifecycle, event dispatch, and ARIA wiring. + * + * @todo Phase 4/5: dialog lifecycle (`showPopover()` / `showModal()`), event + * dispatch, trigger and ARIA wiring, and `PlacementController` integration. + * + * @slot - Popover content. Free-form; consumers slot whatever pattern they build. + */ +export abstract class PopoverBase extends SpectrumElement { + /** + * @internal + * + * The valid placement values for the popover. Narrowed in downstream + * first-party subclasses per the proxy pattern. + */ + static readonly VALID_PLACEMENTS: readonly Placement[] = + POPOVER_VALID_PLACEMENTS; + + /** + * @internal + * + * The valid fixed sizes for the popover. + */ + static readonly VALID_SIZES: readonly PopoverSize[] = POPOVER_VALID_SIZES; + + /** + * Whether the popover is open. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public open = false; + + /** + * Opt in to blocking modal behavior (`.showModal()`): focus trap, + * background inert, native `role="dialog"`. When unset, the popover uses + * `popover="auto"` light-dismiss behavior. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public modal = false; + + /** + * The placement of the popover relative to its trigger. + * + * @default 'bottom' + */ + @property({ type: String, reflect: true }) + public placement: Placement = 'bottom'; + + /** + * Optional fixed size. When set, the popover uses a fixed inline size + * (`s` → 336px, `m` → 416px, `l` → 576px); when unset, it fits its contents. + */ + @property({ type: String, reflect: true }) + public size?: PopoverSize; + + // The computed placement after the `flip` middleware reorients the popover is + // intentionally kept off the public property surface: a readonly property would + // still be writable at runtime and could desync the component from the + // controller. The concrete component applies the computed placement only for + // styling, via the `.swc-Popover--` modifier classes on the internal + // surface element. (Tooltip reaches the same "no writable property" result a + // different way, by reflecting an `actual-placement` host attribute.) Consumers + // read the requested side via `placement`. + + /** + * Hide the popover's arrow (tip). The arrow is shown by default. + * + * @default false + */ + @property({ type: Boolean, reflect: true, attribute: 'hide-arrow' }) + public hideArrow = false; + + /** + * Main-axis offset in pixels from the trigger. + * + * @default 8 + */ + @property({ type: Number }) + public offset = 8; + + /** + * Cross-axis offset in pixels from the trigger. + * + * @default 0 + */ + @property({ type: Number, attribute: 'cross-offset' }) + public crossOffset = 0; + + /** + * Distance from the viewport edge for the `flip` and `shift` middleware. + * + * Positioning implementation detail. Set by first-party components; excluded + * from the public API. Users are not expected to set it. + * + * @internal + * @default 8 + */ + @property({ type: Number, attribute: 'container-padding' }) + public containerPadding = 8; + + /** + * Allow the popover to flip to the opposite side when constrained. When + * `false`, the popover stays in the requested placement. + * + * @default true + */ + @property({ type: Boolean, reflect: true, attribute: 'should-flip' }) + public shouldFlip = true; + + /** + * Minimum inset of the tip from the popover's corners, passed to the + * `PlacementController`'s `arrow` middleware as its `padding`. + * + * Positioning implementation detail. Set by first-party components; excluded + * from the public API. Users are not expected to set it. + * + * @internal + * @default 8 + */ + @property({ type: Number, attribute: 'tip-padding' }) + public tipPadding = 8; + + /** + * ID of the trigger element in the same document tree root. + */ + @property({ type: String }) + public for?: string; + + /** + * Direct trigger reference. Overrides `for` when both are set. Use for + * cross-shadow-root triggers or programmatic wiring. + */ + @property({ attribute: false }) + public triggerElement: HTMLElement | VirtualTrigger | null = null; + + /** + * Suppress the automatic click-to-toggle wiring on the resolved trigger. When + * set, control visibility through the `open` property instead. ARIA + * relationship wiring still applies. + * + * @default false + */ + @property({ type: Boolean, reflect: true }) + public manual = false; + + protected override update(changedProperties: PropertyValues): void { + if (window.__swc?.DEBUG) { + // Validate against the static so subclasses that narrow the placement set + // (the proxy pattern) get their own valid values checked at runtime. + const constructor = this.constructor as typeof PopoverBase; + if (!constructor.VALID_PLACEMENTS.includes(this.placement)) { + window.__swc.warn( + this, + `<${this.localName}> element expects the "placement" attribute to be one of the following:`, + 'https://spectrum-web-components.adobe.com/?path=/docs/components-popover--docs', + { + issues: [...constructor.VALID_PLACEMENTS], + } + ); + } + } + super.update(changedProperties); + } +} diff --git a/2nd-gen/packages/core/components/popover/Popover.types.ts b/2nd-gen/packages/core/components/popover/Popover.types.ts new file mode 100644 index 00000000000..b5177f98764 --- /dev/null +++ b/2nd-gen/packages/core/components/popover/Popover.types.ts @@ -0,0 +1,70 @@ +/** + * 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 { + ALL_PLACEMENTS, + type Placement, +} from '@spectrum-web-components/core/controllers/index.js'; + +// ───────────────────────── +// PLACEMENT +// ───────────────────────── + +/** + * The placement of the popover relative to its trigger, re-exported from the + * `PlacementController` so the popover and the controller share a single source + * of truth for the 22 supported values. + */ +export type { Placement }; +export { ALL_PLACEMENTS }; + +/** + * The full set of placement values accepted by ``. Downstream + * first-party components narrow this set per the proxy pattern. + */ +export const POPOVER_VALID_PLACEMENTS = + ALL_PLACEMENTS satisfies readonly Placement[]; + +// ───────────────────────── +// SIZE +// ───────────────────────── + +/** + * Optional fixed sizes for the popover. When unset, the popover fits its + * contents. Each size pins a fixed inline size (`s` → 336px, `m` → 416px, + * `l` → 576px). + */ +export const POPOVER_VALID_SIZES = ['s', 'm', 'l'] as const; + +export type PopoverSize = (typeof POPOVER_VALID_SIZES)[number]; + +// ───────────────────────── +// EVENTS +// ───────────────────────── + +/** + * The cause of a popover close, carried on `swc-close.detail.source`. + * + * - `'escape'`: the Escape key dismissed the popover. + * - `'outside'`: a click outside the popover (light-dismiss in default mode, + * wired backdrop-click in modal mode) dismissed it. + * - `'programmatic'`: `open` was set to `false` in code. + */ +export type PopoverCloseSource = 'escape' | 'outside' | 'programmatic'; + +/** + * Detail payload carried on the `swc-close` event. + */ +export interface PopoverCloseEventDetail { + /** What triggered the close. */ + source: PopoverCloseSource; +} diff --git a/2nd-gen/packages/core/components/popover/index.ts b/2nd-gen/packages/core/components/popover/index.ts new file mode 100644 index 00000000000..5c48da3deaf --- /dev/null +++ b/2nd-gen/packages/core/components/popover/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 './Popover.base.js'; +export * from './Popover.types.js'; diff --git a/2nd-gen/packages/core/package.json b/2nd-gen/packages/core/package.json index 9ed05b3ccce..9c059dce611 100644 --- a/2nd-gen/packages/core/package.json +++ b/2nd-gen/packages/core/package.json @@ -71,6 +71,14 @@ "types": "./dist/components/meter/index.d.ts", "import": "./dist/components/meter/index.js" }, + "./components/popover": { + "types": "./dist/components/popover/index.d.ts", + "import": "./dist/components/popover/index.js" + }, + "./components/popover/index.js": { + "types": "./dist/components/popover/index.d.ts", + "import": "./dist/components/popover/index.js" + }, "./components/progress-circle": { "types": "./dist/components/progress-circle/index.d.ts", "import": "./dist/components/progress-circle/index.js" @@ -171,6 +179,10 @@ "types": "./dist/utils/capitalize.d.ts", "import": "./dist/utils/capitalize.js" }, + "./utils/dismissible-stack.js": { + "types": "./dist/utils/dismissible-stack.d.ts", + "import": "./dist/utils/dismissible-stack.js" + }, "./utils/get-label-from-slot.js": { "types": "./dist/utils/get-label-from-slot.d.ts", "import": "./dist/utils/get-label-from-slot.js" @@ -178,6 +190,10 @@ "./utils/index.js": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js" + }, + "./utils/resolve-trigger.js": { + "types": "./dist/utils/resolve-trigger.d.ts", + "import": "./dist/utils/resolve-trigger.js" } }, "main": "./dist/index.js", diff --git a/2nd-gen/packages/core/utils/dismissible-stack.ts b/2nd-gen/packages/core/utils/dismissible-stack.ts new file mode 100644 index 00000000000..539e6d4f999 --- /dev/null +++ b/2nd-gen/packages/core/utils/dismissible-stack.ts @@ -0,0 +1,58 @@ +/** + * 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. + */ + +/** + * A LIFO stack of currently-open dismissible top-layer components, used to + * coordinate Escape handling across mechanisms that don't natively order with + * each other (e.g. an `popover="auto"` element open while a `` is also + * open). + * + * Usage convention for consumers: + * + * - `registerDismissible(this)` on open. + * - `unregisterDismissible(this)` on close and in `disconnectedCallback`. + * - Check `isTopDismissible(this)` before processing any custom Escape handling. + * + * State is module-level and in-memory only; it resets on page reload, which is + * correct for this coordination use case. + * + */ +const dismissibleStack: object[] = []; + +/** + * Push a dismissible onto the stack when it opens. Duplicate registration + * (same key already on the stack) is a no-op to prevent double-push from + * re-entrant open paths. + */ +export function registerDismissible(key: object): void { + if (!dismissibleStack.includes(key)) { + dismissibleStack.push(key); + } +} + +/** + * Remove the most-recent entry matching the key when it closes. Idempotent: + * calling with a key that is not on the stack is a safe no-op. + */ +export function unregisterDismissible(key: object): void { + const index = dismissibleStack.lastIndexOf(key); + if (index !== -1) { + dismissibleStack.splice(index, 1); + } +} + +/** + * Whether the key is the topmost (most-recently-registered) dismissible. + */ +export function isTopDismissible(key: object): boolean { + return dismissibleStack.at(-1) === key; +} diff --git a/2nd-gen/packages/core/utils/index.ts b/2nd-gen/packages/core/utils/index.ts index 26867bea526..7260f3e1c02 100644 --- a/2nd-gen/packages/core/utils/index.ts +++ b/2nd-gen/packages/core/utils/index.ts @@ -11,6 +11,16 @@ */ export { capitalize } from './capitalize.js'; +export { + isTopDismissible, + registerDismissible, + unregisterDismissible, +} from './dismissible-stack.js'; export { getActiveElement } from './get-active-element.js'; export { focusableSelector, tabbableSelector } from './focusable-selectors.js'; export { getLabelFromSlot } from './get-label-from-slot.js'; +export { + resolveTrigger, + type ResolvedTrigger, + type ResolveTriggerOptions, +} from './resolve-trigger.js'; diff --git a/2nd-gen/packages/core/utils/resolve-trigger.ts b/2nd-gen/packages/core/utils/resolve-trigger.ts new file mode 100644 index 00000000000..b104ee1d21d --- /dev/null +++ b/2nd-gen/packages/core/utils/resolve-trigger.ts @@ -0,0 +1,48 @@ +/** + * 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. + */ + +/** + * Options for {@link resolveTrigger}. + */ +export interface ResolveTriggerOptions { + /** ID of the trigger element in the host's tree root. */ + for?: string; + /** Direct trigger reference; overrides `for` when set. */ + triggerElement?: HTMLElement | null; +} + +/** + * The resolved trigger and the element that should receive ARIA wiring. + * + * `trigger` is the element used for positioning. `interactiveElement` is the + * AT-facing focusable element — the inner `