Skip to content
Open
Show file tree
Hide file tree
Changes from 13 commits
Commits
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
2 changes: 1 addition & 1 deletion 2nd-gen/packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
core/
├── element/ # SpectrumElement base class, defineElement, version tracking
├── mixins/ # SizedMixin, ObserveSlotPresence, ObserveSlotText
├── controllers/ # LanguageResolutionController
├── controllers/ # LanguageResolutionController, PendingController, …
├── utils/ # capitalize, getLabelFromSlot
└── components/ # One folder per component
└── badge/
Expand Down
107 changes: 11 additions & 96 deletions 2nd-gen/packages/core/components/button/Button.base.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would like to advocate that button base be the shared button base. the pending mixin should be applied at the swc button level. if theres disagreement then i will request that shared button base (i would want to discuss the naming of this as well) should be treated like every other base class and have its own directory for discoverability. or we need to discuss architecture of these kinds of components and how we organize them, i can see this coming up with cards too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the concepts of base classes is that they store core functionality. i would almost argue that we dont need a core base class per component. i would use one button base for all buttons and then in SWC add the differing functionality that makes them unique components. i thought that was the pattern we were aiming for.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per our discussion yesterday I have removed the mixin, created a controller and removed the sharedButtonbase.
So buttonbase is the base that other buttons can use.
One thing though right now that is still there is a closebuttonbase which extends the button base and overrides close button specific size and color properties. Let me know if this is okay or do we want to not hav ea close button base at all.

@Rajdeepc Rajdeepc Jun 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@caseyisonit @TarunAdobe I like this pattern except for the fact that it has scalability issue. In future if 3+ more components uses PendingController you will have 3-4 hosts each copy pasting the same property block. In that case you need to revisit this and extract a PendingButtonBase class or a helper. For now this is fine.

Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import { PropertyValues } from 'lit';
import { property, state } from 'lit/decorators.js';
import { property } from 'lit/decorators.js';

import { SpectrumElement } from '@spectrum-web-components/core/element/index.js';
import {
Expand All @@ -23,11 +23,10 @@ import {
import { BUTTON_VALID_SIZES } from './Button.types.js';

/**
* Abstract base class for all button-like components. Owns shared semantic
* concerns: interaction state, sizing, slot-derived icon/label state,
* accessible-name resolution, and host-to-internal-button attribute forwarding.
* Abstract base for button-like components that share sizing, slots, disabled
* state, and accessible-name resolution.
*
* Visual API specific to `sp-button` (`variant`, `fill-style`, `static-color`)
* Visual API specific to `swc-button` (`variant`, `fill-style`, `static-color`)
* is intentionally absent so that ActionButton, ClearButton, CloseButton,
* PickerButton, and InfieldButton can extend this base without inheriting
* the `swc-button` visual surface.
Expand All @@ -36,9 +35,6 @@ import { BUTTON_VALID_SIZES } from './Button.types.js';
* @slot icon - Optional leading icon.
*
* @attribute {ElementSize} size - The size of the button.
*
* @todo We currently have 3 levels of mixins on this class, but the mixin
* composition guide recommends a maximum of 2. Explore reducing after milestone 2.
*/
export abstract class ButtonBase extends SizedMixin(
ObserveSlotText(ObserveSlotPresence(SpectrumElement, '[slot="icon"]'), ''),
Expand All @@ -60,43 +56,17 @@ export abstract class ButtonBase extends SizedMixin(
@property({ type: Boolean, reflect: true })
public disabled: boolean = false;

/**
* Whether the button is in a pending (busy) state. The button remains
* focusable but activation is suppressed.
*/
@property({ type: Boolean, reflect: true })
public pending: boolean = false;

/**
* Accessible label forwarded to the internal `<button>` element as
* `aria-label`. Required for icon-only buttons, which have no visible text.
*/
@property({ type: String, attribute: 'accessible-label' })
public accessibleLabel?: string;

/**
* Custom accessible label used during the pending state. When omitted,
* the pending label is derived from the resolved non-busy accessible name
* plus a busy suffix (e.g. "Save, busy").
*/
@property({ type: String, attribute: 'pending-label' })
public pendingLabel?: string;

/**
* Tracks whether the pending visual (disabled colors + spinner) is currently
* active. Set to `true` after a 1-second delay once `pending` becomes true,
* so the button does not immediately flash to its unavailable appearance.
* Protected so subclasses can reference it in their `classMap` binding.
*/
@state()
protected pendingActive: boolean = false;

// ──────────────────────
// IMPLEMENTATION
// ──────────────────────

private _pendingTimer: ReturnType<typeof setTimeout> | null = null;

protected get hasIcon(): boolean {
return this.slotContentIsPresent;
}
Expand All @@ -112,25 +82,10 @@ export abstract class ButtonBase extends SizedMixin(
*
* @internal
*/
protected getResolvedAccessibleName(): string | null {
public getResolvedAccessibleName(): string | null {
return this.accessibleLabel ?? (this.textContent?.trim() || null);
}

/**
* Derives the pending-state accessible label. Prefers an explicit
* `pendingLabel`, then falls back to the resolved non-busy accessible
* name plus a ", busy" suffix, then a fixed "Busy" fallback.
*
* @internal
*/
protected getPendingAccessibleName(): string {
if (this.pendingLabel) {
return this.pendingLabel;
}
const resolvedName = this.getResolvedAccessibleName();
return resolvedName ? `${resolvedName}, busy` : 'Busy';
}

/**
* Returns the set of attributes that should be forwarded to the internal
* semantic `<button>` element, if not otherwise directly managed.
Expand All @@ -143,78 +98,38 @@ export abstract class ButtonBase extends SizedMixin(
> {
return {
disabled: this.disabled,
'aria-disabled': this.pending && !this.disabled ? 'true' : undefined,
};
}

public override connectedCallback(): void {
super.connectedCallback();
// Capture phase so slotted light-DOM clicks are suppressed before host
// listeners (e.g. Storybook actions) run.
this.addEventListener('click', this.handleClick, true);
this.addEventListener('click', this.handleActivationClick, true);
}

public override disconnectedCallback(): void {
this.removeEventListener('click', this.handleClick, true);
if (this._pendingTimer !== null) {
clearTimeout(this._pendingTimer);
this._pendingTimer = null;
}
this.pendingActive = false;
this.removeEventListener('click', this.handleActivationClick, true);
super.disconnectedCallback();
}

/**
* Suppresses click activation while the button is `disabled` or `pending`.
* Suppresses click activation while the button is disabled.
*
* Slotted icon content lives in the light DOM, so pointer clicks on icons
* bypass the disabled inner `<button>` and bubble on the host. The host
* listener (capture) and inner `@click` binding both call this handler.
*/
protected readonly handleClick = (event: Event): void => {
if (this.disabled || this.pending) {
protected handleActivationClick(event: Event): void {
if (this.disabled) {
event.preventDefault();
event.stopImmediatePropagation();
}
};
}

protected override update(changedProperties: PropertyValues): void {
if (changedProperties.has('pending')) {
if (this.pending) {
this._pendingTimer = setTimeout(() => {
if (this.pending) {
const internalButton = this.renderRoot.querySelector('button');
if (internalButton) {
internalButton.style.setProperty(
'--_swc-button-pending-inline-size',
`${internalButton.offsetWidth}px`
);
}
this.pendingActive = true;
}
this._pendingTimer = null;
}, 1000);
} else {
if (this._pendingTimer !== null) {
clearTimeout(this._pendingTimer);
this._pendingTimer = null;
}
this.renderRoot
.querySelector('button')
?.style.removeProperty('--_swc-button-pending-inline-size');
this.pendingActive = false;
}
}
super.update(changedProperties);
if (window.__swc?.DEBUG) {
if (this.pending && this.disabled) {
window.__swc.warn(
this,
`<${this.localName}> should not set both "pending" and "disabled" simultaneously. Use "pending" to keep the button focusable while unavailable, or "disabled" to fully remove it from the tab order.`,
'https://opensource.adobe.com/spectrum-web-components/components/button/#pending',
{ issues: ['pending + disabled'] }
);
}
if (this.hasIcon && !this.hasLabel && !this.accessibleLabel) {
window.__swc.warn(
this,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Copyright 2026 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { property } from 'lit/decorators.js';

import { ButtonBase } from '@spectrum-web-components/core/components/button';

import {
CLOSE_BUTTON_VALID_SIZES,
type CloseButtonSize,
type CloseButtonStaticColor,
} from './CloseButton.types.js';

/**
* Abstract base for dismiss close-button semantics shared by rendering layers.
* Extends {@link ButtonBase} with close-button sizing and static-color API.
*
* @slot - Accessible text label rendered visually hidden next to the cross icon.
*
* @attribute {CloseButtonSize} size - Visual size of the close button.
* @attribute {'white' | 'black'} static-color - Static color treatment for display over colored or image backgrounds.
* @attribute {boolean} disabled - Whether the button is disabled.
*/
export abstract class CloseButtonBase extends ButtonBase {
/** @internal */
static override readonly VALID_SIZES: readonly CloseButtonSize[] =
CLOSE_BUTTON_VALID_SIZES;

/**
* Static color treatment for display over colored or image backgrounds.
*/
@property({ type: String, reflect: true, attribute: 'static-color' })
public staticColor?: CloseButtonStaticColor;

/**
* Close buttons always render a cross icon; treat as icon-present for
* shared {@link ButtonBase} accessibility checks.
*
* @internal
*/
protected override get hasIcon(): boolean {
return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* 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 type { ElementSize } from '@spectrum-web-components/core/mixins/index.js';

// ──────────────────
// SHARED
// ──────────────────

export const CLOSE_BUTTON_VALID_SIZES = [
's',
'm',
'l',
'xl',
] as const satisfies readonly ElementSize[];

export const CLOSE_BUTTON_STATIC_COLORS = ['white', 'black'] as const;

// ──────────────────
// TYPES
// ──────────────────

export type CloseButtonSize = (typeof CLOSE_BUTTON_VALID_SIZES)[number];
export type CloseButtonStaticColor =
(typeof CLOSE_BUTTON_STATIC_COLORS)[number];
13 changes: 13 additions & 0 deletions 2nd-gen/packages/core/components/close-button/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 './CloseButton.base.js';
export * from './CloseButton.types.js';
4 changes: 4 additions & 0 deletions 2nd-gen/packages/core/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,7 @@ export {
type PlacementOptions,
type VirtualTrigger,
} from './placement-controller/index.js';
export {
PendingController,
type PendingHost,
} from './pending-controller/index.js';
16 changes: 16 additions & 0 deletions 2nd-gen/packages/core/controllers/pending-controller/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* 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 {
PendingController,
type PendingHost,
} from './src/pending-controller.js';
Loading
Loading