Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
4 changes: 2 additions & 2 deletions 2nd-gen/packages/core/components/asset/Asset.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ export abstract class AssetBase extends SpectrumElement {
// IMPLEMENTATION
// ──────────────────────

protected override updated(changes: PropertyValues): void {
super.updated(changes);
protected override update(changes: PropertyValues): void {
if (typeof this.variant !== 'undefined') {
const constructor = this.constructor as typeof AssetBase;
validateEnum(this, {
Expand All @@ -63,5 +62,6 @@ export abstract class AssetBase extends SpectrumElement {
url: 'https://spectrum-web-components.adobe.com/?path=/docs/components-asset--docs',
});
}
super.update(changes);
}
}
30 changes: 17 additions & 13 deletions 2nd-gen/packages/core/components/card/Card.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,19 +181,7 @@ export abstract class CardBase extends SizedMixin(SpectrumElement, {
?.addEventListener('slotchange', this.handleActionsSlotChange);
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);

if (changedProperties.has('selectable')) {
if (this.selectable) {
this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.handleSelectableKeydown);
} else {
this.removeAttribute('tabindex');
this.removeEventListener('keydown', this.handleSelectableKeydown);
}
}

protected override update(changedProperties: PropertyValues): void {
const { VARIANTS, DENSITIES } = this.constructor as typeof CardBase;

if (changedProperties.has('variant')) {
Expand All @@ -215,6 +203,22 @@ export abstract class CardBase extends SizedMixin(SpectrumElement, {
});
}

super.update(changedProperties);
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);

if (changedProperties.has('selectable')) {
if (this.selectable) {
this.setAttribute('tabindex', '0');
this.addEventListener('keydown', this.handleSelectableKeydown);
} else {
this.removeAttribute('tabindex');
this.removeEventListener('keydown', this.handleSelectableKeydown);
}
}

warnIf(
this,
changedProperties.has('titleAsLink') &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ export abstract class IllustratedMessageBase extends SpectrumElement {
return this.slotText.hasContent;
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);

protected override update(changedProperties: PropertyValues): void {
if (changedProperties.has('size')) {
validateEnum(this, {
prop: 'size',
Expand All @@ -128,6 +126,8 @@ export abstract class IllustratedMessageBase extends SpectrumElement {
url: 'https://spectrum-web-components.adobe.com/?path=/docs/components-illustrated-message--docs',
});
}

super.update(changedProperties);
}

protected handleActionsSlotChange(): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,17 @@ export abstract class ProgressCircleBase extends SizedMixin(SpectrumElement, {
this.progress = clamped;
}
}

if (changes.has('staticColor') && this.staticColor !== undefined) {

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.

const constructor = this.constructor as typeof ProgressCircleBase;
validateEnum(this, {
prop: 'static-color',
value: this.staticColor,
valid: constructor.STATIC_COLORS,
url: 'https://spectrum-web-components.adobe.com/?path=/docs/components-progress-circle--docs',
});
}

super.willUpdate(changes);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,7 @@ export abstract class StatusLightBase extends SizedMixin(SpectrumElement, {
// IMPLEMENTATION
// ──────────────────────

protected override updated(changes: PropertyValues): void {
super.updated(changes);
protected override update(changes: PropertyValues): void {
const constructor = this.constructor as typeof StatusLightBase;
// @ts-expect-error -- intentional runtime guard: 1st-gen consumers may pass 'accent'
if (this.variant === 'accent') {
Expand All @@ -125,5 +124,7 @@ export abstract class StatusLightBase extends SizedMixin(SpectrumElement, {
'https://spectrum-web-components.adobe.com/?path=/docs/status-light-migration-guide--docs',
{ level: 'deprecation' }
);

super.update(changes);
}
}
7 changes: 4 additions & 3 deletions 2nd-gen/packages/core/components/tooltip/Tooltip.base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,10 +471,7 @@ export abstract class TooltipBase
if (this.disabled && this.open) {
this.open = false;
}
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('variant')) {
const constructor = this.constructor as typeof TooltipBase;
validateEnum(this, {

@Rajdeepc Rajdeepc Aug 6, 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.

Not a bug: I see Tooltip and ProgressCircle fold the validateEnum check in willUpdate while others do in update. Again, functionally both is running pre-render but its an inconsistency where we are trying to create a pattern.

Expand All @@ -493,6 +490,10 @@ export abstract class TooltipBase
url: 'https://spectrum-web-components.adobe.com/?path=/docs/components-tooltip--docs',
});
}
}

protected override updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (changedProperties.has('offset')) {
this.style.setProperty(
'--_swc-tooltip-animation-distance',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import { DocsFooter, DocsHeader } from '../../../swc/.storybook/blocks';

import * as Stories from './stories/attribute-observer-controller.stories';

<Meta of={Stories} />

<DocsHeader />

## Usage

`AttributeObserverController` watches a set of host attributes and requests a host re-render whenever one of them changes. It exists for state that lives in plain HTML attributes rather than reactive properties. `aria-label` and `aria-labelledby` are the motivating case: they are not declared as Lit `@property`s, so mutating them after connect does not trigger Lit's update cycle, and any `updated()` logic that reads them would otherwise run only once and then go stale.

Like [Slot presence controller](../?path=/docs/core-controllers-slot-presence-controller--docs), it uses a `MutationObserver` and calls `host.requestUpdate()` on change; the host's own `updated()` then re-runs and re-reads the attributes. The controller holds no state and makes no decisions about the attributes it watches.

```typescript
import { LitElement, html, type PropertyValues } from 'lit';
import { AttributeObserverController } from '@adobe/spectrum-wc-core/controllers';
import { isDebug } from '@adobe/spectrum-wc-core/utils';

class SwcThing extends LitElement {
constructor() {
super();
new AttributeObserverController(this, ['aria-label', 'aria-labelledby'], {
debugOnly: true,
});
}

protected override updated(changes: PropertyValues): void {
super.updated(changes);
if (isDebug()) {
this.warnIfMissingAccessibleName();
}
}
}
```

### Use `debugOnly` when the only consumer is a dev warning

Pass `{ debugOnly: true }` when the sole reason to watch the attributes is to re-run a development-mode check. The observer is then attached only while `isDebug()` is `true`, so it costs nothing in production (and nothing during SSR). When the attribute changes drive real runtime behavior, leave `debugOnly` unset so the observer always runs.

## Behaviors

### Revalidate on attribute change

Both boxes below read their own `aria-label` and render it. Only the left one runs the controller. The button changes `aria-label` on both from the outside. The box with the controller notices the change, re-renders, updates its value, and shows a ✓ confirmation that its follow-up ran. The box without it stays frozen on the old value and never confirms, because nothing tells Lit the attribute changed. That difference is the controller's entire job: turn an out-of-band attribute change into a normal re-render so `updated()` can react.

<Canvas of={Stories.RevalidateOnChange} />

## Accessibility

The controller has no direct accessibility surface. Its common use is to keep an accessible-name check current when the name is supplied via `aria-label` / `aria-labelledby`, but the controller only triggers re-evaluation; the host owns the actual check and message.

## API

### Constructor

`new AttributeObserverController(host, attributes, options?)` registers the controller on the host.

### Parameters

| Parameter | Type | Description |
| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------------------- |
| `host` | `ReactiveElement` | The element that owns the controller. |
| `attributes` | `string \| string[]` | The attribute name(s) to observe (the observer's `attributeFilter`). |
| `options` | `AttributeObserverControllerOptions` | Optional. `debugOnly` (default `false`): only attach the observer while `isDebug()` is `true`. |

<DocsFooter />
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 {
AttributeObserverController,
type AttributeObserverControllerOptions,
} from './src/attribute-observer-controller.js';

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.

Do we need this controller? It feels like a big overhead for what could (maybe) be simpler... e.g., the platform already gives us attributeChangedCallback/observedAttributes, which is what Lit uses internally to sync attributes to properties, and would get us attribute-change detection without a MutationObserver instance per host (even though I only see it being used on Dropzone for now? - which may also hint at some premature optimization).

But even setting that aside (and following up our Slack thread), we already have a first-party pattern for this, using accessible-label / accessibleLabel. If we lean on that convention instead of watching aria-label directly, we also won't need the controller for this case.

Aaaand as a bit of a tangent, but what I think we haven't decided / established a pattern is for labelledby. We've been treating aria-labelledby as the attribute... Should we extend the accessible-label pattern with an accessible-labelledby counterpart too?

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* 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 { ReactiveController, ReactiveElement } from 'lit';

import { isDebug } from '@adobe/spectrum-wc-core/utils/index.js';

/**
* Options for {@link AttributeObserverController}.
*/
export interface AttributeObserverControllerOptions {
/**
* When `true`, the observer is only attached while dev-mode validation is
* active (`isDebug()`), so it costs nothing in production. Use this when the
* only reason to watch the attributes is to re-run a dev warning. Default:
* `false` (always observe).
*/
debugOnly?: boolean;
}

/**
* A reactive controller that watches a set of host attributes and requests a
* host re-render whenever one of them changes. It exists for state that lives
* in plain HTML attributes rather than reactive properties, for example
* `aria-label` / `aria-labelledby`: because those are not declared as Lit
* `@property`s, mutating them does not trigger Lit's update cycle, so any
* `updated()` logic that reads them would otherwise go stale.
*
* Like {@link SlotPresenceController}, it observes with a `MutationObserver` and
* calls `host.requestUpdate()` on change; the host's own `updated()` then
* re-runs and re-reads the attributes. The controller intentionally holds no
* state and makes no decisions about the attributes it watches.
*
* @example
* ```typescript
* // Re-run a dev-only accessible-name warning when the aria attributes change.
* class MyComponent extends SpectrumElement {
* private nameObserver = new AttributeObserverController(
* this,
* ['aria-label', 'aria-labelledby'],

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.

Because of cross root ARIA issues, hosts shouldn't be using the aria-label and aria-labelledby attributes directly. Instead we typically have a label attribute that sets this. I'm wondering if, rather than creating a controller here, we align the API to have label and labelledby properties that set aria-label and aria-labelledby, even in components where the host has the role.

* { debugOnly: true }
* );
*
* protected override updated(changes: PropertyValues): void {
* super.updated(changes);
* if (isDebug()) {
* this.warnIfMissingAccessibleName();
* }
* }
* }
* ```
*/
export class AttributeObserverController implements ReactiveController {
private host: ReactiveElement;
private attributeFilter: string[];
private debugOnly: boolean;
private observer: MutationObserver;

constructor(
host: ReactiveElement,
attributes: string | string[],
options: AttributeObserverControllerOptions = {}
) {
this.host = host;
this.attributeFilter = Array.isArray(attributes)
? attributes
: [attributes];
this.debugOnly = options.debugOnly ?? false;
this.host.addController(this);

this.observer = new MutationObserver(() => {
// Lit batches `requestUpdate()` into the next microtask, so several
// attribute changes in one tick still coalesce into a single re-render.
this.host.requestUpdate();
});
}

hostConnected(): void {
// When only used to drive a dev warning, skip observing entirely in
// production (and SSR) so there is zero runtime cost there.
if (this.debugOnly && !isDebug()) {
return;
}
this.observer.observe(this.host, {
attributes: true,
attributeFilter: this.attributeFilter,
});
}

hostDisconnected(): void {
this.observer.disconnect();
}
}
Loading
Loading