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
12 changes: 12 additions & 0 deletions .changeset/combobox-breadcrumbs-lang-of-parts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@spectrum-web-components/combobox': patch
'@spectrum-web-components/breadcrumbs': patch
'@spectrum-web-components/base': patch
'@spectrum-web-components/reactive-controllers': patch
---
Comment thread
Copilot marked this conversation as resolved.

**Fixed**: Propagate `lang`/`dir` for a single item's language without breaking layout:

- Combobox: forwards `lang`/`dir` from slotted `<sp-menu-item>` (or `.options` data) onto the rendered popover `<sp-menu-item>`, and syncs the input's own `lang` to the committed option's language for correct pronunciation
- Breadcrumbs: forwards the same `lang`/`dir` propagation to the "More items" overflow menu
- `BreadcrumbItem` now forwards `lang`/`dir` to `#item-link` only, so a single item's language does not flip its own layout or mirror its separator's chevron; the separator tracks the ambient direction (nearest ancestor `dir`, or the document default) instead of the host's own `dir` attribute, including live updates when an ancestor's `dir` changes after mount
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ parameters:
# 3. Commit this change to the PR branch where the changes exist.
current_golden_images_hash:
type: string
default: 75b8c3cdc71a773889e3bdd1032f89ff73c12475
default: 269f7b7535187364b8879232b07e2d7bfba7e02f

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.

Can you please remove this hash. Once you get two approvals we generally encourage authors to update this hash


commands:
halt-if-not-affected:
Expand Down
1 change: 1 addition & 0 deletions 1st-gen/packages/breadcrumbs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"@spectrum-web-components/icons-workflow": "1.12.2",
"@spectrum-web-components/link": "1.12.2",
"@spectrum-web-components/menu": "1.12.2",
"@spectrum-web-components/reactive-controllers": "1.12.2",
"@spectrum-web-components/shared": "1.12.2"
},
"keywords": [
Expand Down
81 changes: 81 additions & 0 deletions 1st-gen/packages/breadcrumbs/src/BreadcrumbItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ import {
} from '@spectrum-web-components/base';
import { property } from '@spectrum-web-components/base/src/decorators.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';
import { normalizeDir } from '@spectrum-web-components/base/src/normalize-dir.js';
import chevronStyles from '@spectrum-web-components/icon/src/spectrum-icon-chevron.css.js';
import { observeAttribute } from '@spectrum-web-components/reactive-controllers/src/AttributeObserver.js';
import { Focusable } from '@spectrum-web-components/shared/src/focusable.js';
import { LikeAnchor } from '@spectrum-web-components/shared/src/like-anchor.js';

Expand All @@ -30,6 +32,30 @@ export interface BreadcrumbSelectDetail {
value: string;
}

// Walks up through `parentElement`, crossing shadow-root boundaries via
// `getRootNode().host` — needed because `Breadcrumbs.renderMenu()` places
// an "is-menu" `BreadcrumbItem` inside its own shadow root rather than as
// a light-DOM child, so a plain `parentElement` walk would stop there
// without ever reaching `<sp-breadcrumbs>` itself.
function* ancestorElements(start: Element): Generator<Element> {
Comment thread
majornista marked this conversation as resolved.
let node: Node = start;
for (;;) {
const parent = (node as Element).parentElement;
if (parent) {
yield parent;
node = parent;
continue;
}
const root = node.getRootNode();
if (root instanceof ShadowRoot) {
yield root.host;
node = root.host;
continue;
}
break;
}
}

export class BreadcrumbItem extends LikeAnchor(Focusable) {
public static override get styles(): CSSResultArray {
return [styles, chevronStyles];
Expand All @@ -49,12 +75,49 @@ export class BreadcrumbItem extends LikeAnchor(Focusable) {
return this.shadowRoot.querySelector('#item-link') as HTMLElement;
}

// `renderLink()` and `renderSeparator()` bake this host's own `lang`/`dir`
// and the ambient `direction` into explicit attributes at render time.
// Neither `lang` nor `dir` is a reactive Lit property here, so — unlike
// plain CSS inheritance — none of that updates on its own if this host's
// own `lang`/`dir` change, or an ancestor's `dir` changes, after mount.
public static override get observedAttributes(): string[] {
return [...super.observedAttributes, 'dir', 'lang'];
}

public override attributeChangedCallback(
name: string,
old: string | null,
value: string | null
): void {
super.attributeChangedCallback(name, old, value);
if (name === 'dir' || name === 'lang') {
this.requestUpdate();
}
}

// `attributeChangedCallback` only reaches this host's own attributes, not
// an ancestor's `dir` — react to those via the shared `AttributeObserver`
// singleton instead of a `MutationObserver` per item.
private ancestorDirUnsubscribes: (() => void)[] = [];

override connectedCallback(): void {
super.connectedCallback();

if (!this.hasAttribute('role')) {
this.setAttribute('role', 'listitem');
}

for (const ancestor of ancestorElements(this)) {
this.ancestorDirUnsubscribes.push(
observeAttribute(ancestor, 'dir', () => this.requestUpdate())
);
}
Comment thread
majornista marked this conversation as resolved.
}

override disconnectedCallback(): void {
this.ancestorDirUnsubscribes.forEach((unsubscribe) => unsubscribe());
this.ancestorDirUnsubscribes = [];
super.disconnectedCallback();
}

private announceSelected(value: string): void {
Expand Down Expand Up @@ -90,10 +153,19 @@ export class BreadcrumbItem extends LikeAnchor(Focusable) {
}

protected renderLink(): TemplateResult {
// Forward `lang`/`dir` from the host onto the link so a single item's
// language only affects its own text rendering, not the host's own
// `direction` (which the separator mirrors via `:dir(rtl)`); see
// `:host([dir]) { direction: inherit; }` in breadcrumb-item.css. Read the raw
// `dir` attribute rather than `this.dir` — `SpectrumElement` overrides
// the native `dir` getter to return the *computed* CSS direction, which
// is exactly what `direction: inherit` decouples from the attribute.
return html`
<a
id="item-link"
href=${ifDefined(!this.isLastOfType ? this.href : undefined)}
lang=${ifDefined(this.lang || undefined)}
dir=${ifDefined(normalizeDir(this.getAttribute('dir')))}
tabindex="0"
aria-current=${ifDefined(this.isLastOfType ? 'page' : undefined)}
@keydown=${this.handleKeyDown}
Expand All @@ -105,11 +177,20 @@ export class BreadcrumbItem extends LikeAnchor(Focusable) {
}

private renderSeparator(): TemplateResult {
// `:dir()` resolves directionality by walking up the `dir` *attribute*
// chain, independent of the CSS `direction` property — so it still
// picks up this host's own `dir` (set for `#item-link`'s content, per
// `renderLink()`) even though `:host([dir]) { direction: inherit; }` keeps the
// host's own *computed* direction tied to the ambient context. Read that
// computed value and set it explicitly here so `:dir()` on `#separator`
// resolves from its own accurate attribute instead of the host's.
const ambientDir = getComputedStyle(this).direction as 'ltr' | 'rtl';

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.

Blocking:
This will run unconditionally on every render including calls triggered by ResizeObserver. You will layout thrashing for breadcrumb trails. Please cache or memoize this only recompute in updated() when dir changed on this host or a watched ancestor.

return html`
<sp-icon-chevron100
id="separator"
size="xs"
class="spectrum-UIIcon-ChevronRight100"
dir=${ambientDir}
></sp-icon-chevron100>
`;
}
Expand Down
54 changes: 53 additions & 1 deletion 1st-gen/packages/breadcrumbs/src/Breadcrumbs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
ref,
} from '@spectrum-web-components/base/src/directives.js';
import { ifDefined } from '@spectrum-web-components/base/src/directives.js';
import type { Directionality } from '@spectrum-web-components/base/src/normalize-dir.js';
import { normalizeDir } from '@spectrum-web-components/base/src/normalize-dir.js';
import { observeAttribute } from '@spectrum-web-components/reactive-controllers/src/AttributeObserver.js';

/* eslint-disable import/no-extraneous-dependencies */
import '@spectrum-web-components/breadcrumbs/sp-breadcrumb-item.js';
Expand All @@ -48,6 +51,8 @@ type BreadcrumbItem = {
value: string;
offsetWidth: number;
isVisible: boolean; // false if displayed in menu overlay
lang?: string;
dir?: Directionality;
};
Comment thread
majornista marked this conversation as resolved.

/**
Expand Down Expand Up @@ -105,6 +110,13 @@ export class Breadcrumbs extends SpectrumElement {
private resizeObserver: ResizeObserver | undefined;
private firstRender = true;

// `calculateBreadcrumbItemsWidth()` only re-snapshots `lang`/`dir` into
// `items` when items are added/removed or the layout recalculates
// (`maxVisibleItems`/`compact` change); watch each item's own `lang`/`dir`
// directly so the cached `<sp-menu-item>` rendered by `renderMenu()`
// doesn't go stale relative to the live, now-reactive breadcrumb item.
private itemAttributeUnsubscribes: (() => void)[] = [];

private menuRef: Ref<ActionMenu> = createRef();

private get hasMenu(): boolean {
Expand Down Expand Up @@ -132,6 +144,8 @@ export class Breadcrumbs extends SpectrumElement {

public override disconnectedCallback(): void {
this.resizeObserver?.unobserve(this);
this.itemAttributeUnsubscribes.forEach((unsubscribe) => unsubscribe());
this.itemAttributeUnsubscribes = [];
super.disconnectedCallback();
}

Expand Down Expand Up @@ -190,10 +204,40 @@ export class Breadcrumbs extends SpectrumElement {
value: el.value || index.toString(),
offsetWidth: width,
isVisible: true,
lang: el.lang || undefined,
// `SpectrumElement` overrides `dir` to return the *computed* CSS
// direction rather than the attribute, so read the attribute
// directly to capture the item's own authored direction override.
dir: normalizeDir(el.getAttribute('dir')),
};
});
}

/**
* Re-syncs a single cached item's `lang`/`dir` whenever the corresponding
* live breadcrumb item's own `lang`/`dir` changes, without touching its
* cached `offsetWidth`/`isVisible`.
*/
private watchItemAttributes(): void {
this.itemAttributeUnsubscribes.forEach((unsubscribe) => unsubscribe());
this.itemAttributeUnsubscribes = this.breadcrumbsElements.flatMap(
(el, index) =>
(['lang', 'dir'] as const).map((attribute) =>
observeAttribute(el, attribute, () => {
this.items = this.items.map((item, i) =>
i === index
? {
...item,
lang: el.lang || undefined,
dir: normalizeDir(el.getAttribute('dir')),
}
: item
);
})
)
);
}

/**
* Calculate which breadcrumbs fit in the viewport, and which should be hidden.
*/
Expand Down Expand Up @@ -293,7 +337,12 @@ export class Breadcrumbs extends SpectrumElement {

${this.items.map(
(item) => html`
<sp-menu-item href=${ifDefined(item.href)} value=${item.value}>
<sp-menu-item
href=${ifDefined(item.href)}
value=${item.value}
lang=${ifDefined(item.lang)}
dir=${ifDefined(item.dir)}
>
${item.label}
</sp-menu-item>
`
Expand All @@ -311,6 +360,8 @@ export class Breadcrumbs extends SpectrumElement {
if (this.breadcrumbsElements.length === 0) {
this.items = [];
this.visibleItems = 0;
this.itemAttributeUnsubscribes.forEach((unsubscribe) => unsubscribe());
this.itemAttributeUnsubscribes = [];
return;
}

Expand All @@ -319,6 +370,7 @@ export class Breadcrumbs extends SpectrumElement {

// Force a recalculation of widths and overflow
this.calculateBreadcrumbItemsWidth();
this.watchItemAttributes();

// Reset visibleItems to 0 to force a full recalculation
this.visibleItems = 0;
Expand Down
7 changes: 7 additions & 0 deletions 1st-gen/packages/breadcrumbs/src/breadcrumb-item.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,10 @@
:host([disabled]) {
pointer-events: none;
}

/* A `dir` set on this host (e.g. to localize one item's text) should not
flip this item's own layout or its separator's mirrored chevron; only
`#item-link` (see BreadcrumbItem.ts) picks up that direction. */
:host([dir]) {
direction: inherit;
}
37 changes: 37 additions & 0 deletions 1st-gen/packages/breadcrumbs/stories/breadcrumbs.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,43 @@ AddItemsDynamic.swc_vrt = {
skip: true,
};

// Autonyms (each language's name rendered in that language), alphabetized
// by the rendered text so both LTR and RTL scripts are interleaved.
const languages = [
{ value: 'de', label: 'Deutsch', lang: 'de', dir: 'ltr' },
{ value: 'en', label: 'English', lang: 'en', dir: 'ltr' },
{ value: 'es', label: 'Español', lang: 'es', dir: 'ltr' },
{ value: 'fr', label: 'Français', lang: 'fr', dir: 'ltr' },
{ value: 'ru', label: 'Русский', lang: 'ru', dir: 'ltr' },
{ value: 'he', label: 'עברית', lang: 'he', dir: 'rtl' },
{ value: 'ar', label: 'العربية', lang: 'ar', dir: 'rtl' },
] as const;

export const LanguageOfParts = (args: StoryArgs): TemplateResult => {
return html`
<sp-breadcrumbs
${spreadProps(args)}
max-visible-items=${ifDefined(args['max-visible-items'])}
@change=${args.onChange}
>
${languages.map(
(language) => html`
<sp-breadcrumb-item
value=${language.value}
lang=${language.lang}
dir=${language.dir}
>
${language.label}
</sp-breadcrumb-item>
`
)}
</sp-breadcrumbs>
`;
};
LanguageOfParts.args = {
'max-visible-items': 3,
};

export const ShowRoot = (args: StoryArgs): TemplateResult => {
return html`
<sp-breadcrumbs
Expand Down
Loading
Loading