Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
84f723a
chore(storybook): add nav entries for migration analysis docs
rubencarvalho May 22, 2026
859656a
feat(placement-controller): add PlacementController for 2nd-gen
rubencarvalho May 22, 2026
0d36de6
fix(placement-controller): apply review findings
rubencarvalho May 22, 2026
2b0b694
refactor(placement-controller): trim public surface and doc noise
rubencarvalho May 26, 2026
2aa2f98
refactor(placement-controller): use inline JSDoc API table
rubencarvalho May 26, 2026
731110e
chore: bump floatingui version
rubencarvalho May 26, 2026
fe69752
refactor(placement-controller): unify ShouldFlip demo with toggle
rubencarvalho May 26, 2026
154239a
refactor(placement-controller): polish demos and drop prescriptive copy
rubencarvalho May 26, 2026
809895b
refactor(placement-controller): fix virtual-trigger surface at 320x320
rubencarvalho May 26, 2026
17874d8
refactor(placement-controller): make ShouldFlip demo resizable
rubencarvalho May 26, 2026
d7e66d6
refactor(placement-controller): drop ShouldFlip demo
rubencarvalho May 26, 2026
134d2c0
docs(placement-controller): clarify that shift is always installed
rubencarvalho May 26, 2026
bdbb6b5
docs(placement-controller): stop name-dropping shift everywhere
rubencarvalho May 26, 2026
b4fc052
test(placement-controller): cover the remaining option contracts
rubencarvalho May 26, 2026
c3d22f4
chore: simplify api
rubencarvalho May 28, 2026
1308596
chore: update tests
rubencarvalho May 28, 2026
f7d8261
chore: simplify some tests
rubencarvalho May 28, 2026
0737d45
refactor(placement-controller): match 1st-gen middleware order and al…
rubencarvalho May 28, 2026
8759cbf
feat(placement-controller): wire up arrow middleware (parity with 1st…
rubencarvalho May 28, 2026
ae6d862
refactor(placement-controller): arrow demo uses bottom-end + text-siz…
rubencarvalho May 29, 2026
ae11079
docs(placement-controller): rename 1st-gen/2nd-gen to gen1/gen2
rubencarvalho May 29, 2026
4ab272b
Merge branch 'main' into ruben/feat-placement-controller-swc-1996
rubencarvalho May 29, 2026
fc04c9d
test(placement-controller): isolate tests from playground demo and ti…
rubencarvalho May 29, 2026
6fa1159
test(placement-controller): assert arrow tip points at trigger center
rubencarvalho May 29, 2026
ed15086
Merge branch 'main' into ruben/feat-placement-controller-swc-1996
rubencarvalho May 30, 2026
9a3cca8
fix(placement-controller): ignore superseded compute in size apply
rubencarvalho May 30, 2026
aa164da
fix(placement-controller): expose available space as custom properties
rubencarvalho Jun 2, 2026
5c76a44
fix(placement-controller): resolve logical start/end sides for RTL
rubencarvalho Jun 2, 2026
950f961
Merge branch 'main' into ruben/popover-migration
rubencarvalho Jun 15, 2026
bcf04d8
feat(popover): scaffold + API contract (phases 2+3) (#6354)
rubencarvalho Jun 17, 2026
13779ea
Merge remote-tracking branch 'origin/main' into ruben/popover-migration
rubencarvalho Jun 17, 2026
3399a7c
Merge remote-tracking branch 'origin/main' into ruben/popover-migration
rubencarvalho Jun 17, 2026
310e945
Merge remote-tracking branch 'origin/main' into ruben/popover-migration
rubencarvalho Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions 2nd-gen/packages/core/components/popover/Popover.base.ts
Original file line number Diff line number Diff line change
@@ -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 (`<dialog>.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--<placement>` 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);
}
}
70 changes: 70 additions & 0 deletions 2nd-gen/packages/core/components/popover/Popover.types.ts
Original file line number Diff line number Diff line change
@@ -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 `<swc-popover>`. 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;
}
13 changes: 13 additions & 0 deletions 2nd-gen/packages/core/components/popover/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
export * from './Popover.base.js';
export * from './Popover.types.js';
16 changes: 16 additions & 0 deletions 2nd-gen/packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -171,13 +179,21 @@
"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"
},
"./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",
Expand Down
58 changes: 58 additions & 0 deletions 2nd-gen/packages/core/utils/dismissible-stack.ts
Original file line number Diff line number Diff line change
@@ -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 `<dialog>` 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;
}
10 changes: 10 additions & 0 deletions 2nd-gen/packages/core/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading