Skip to content

Commit 3072ee4

Browse files
committed
chore(icons): centralize rtl handling for icons in swc-ui-icon
1 parent 7ecaa90 commit 3072ee4

4 files changed

Lines changed: 178 additions & 14 deletions

File tree

2nd-gen/packages/swc/components/accordion/accordion-item.css

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,25 +111,22 @@
111111
/* ── Disclosure indicator (chevron) ─────────────────────────────────── */
112112

113113
/* swc-ui-icon maps the item size to the correct optical step and sizes its own box
114-
* from the ui-icon-* token scale, so the accordion only passes size. Rotation and RTL
115-
* mirroring remain here as accordion-specific behavior. */
114+
* from the ui-icon-* token scale, so the accordion only passes size. swc-ui-icon also
115+
* owns RTL mirroring for the chevron itself; rotation on open remains here as
116+
* accordion-specific behavior. */
116117
.swc-AccordionItem-indicator {
117118
flex-shrink: 0;
118119
rotate: 0deg;
119120
transition: rotate token("animation-duration-100") token("animation-ease-in-out");
120121
}
121122

122-
/* Mirror for RTL so the chevron points left. The UI icon element does not yet mirror
123-
* directional icons itself, so the accordion handles RTL mirroring for now. */
124-
:dir(rtl) .swc-AccordionItem-indicator {
125-
scale: -1 1;
126-
}
127-
128123
:host([open]) .swc-AccordionItem-indicator {
129124
rotate: 90deg;
130125
}
131126

132-
/* RTL + open: rotate the opposite direction */
127+
/* RTL + open: swc-ui-icon has already mirrored the chevron horizontally, so rotating
128+
* the same direction as LTR would point the wrong way; rotate the opposite direction
129+
* to still land on "pointing down" once open. */
133130
:host([open]):dir(rtl) .swc-AccordionItem-indicator {
134131
rotate: -90deg;
135132
}

2nd-gen/packages/swc/components/ui-icons/UiIcon.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,17 @@ import { UI_ICONS, UiIconName } from './icon-set/index.js';
1818
import { resolveUiIconArt } from './ui-icons.types.js';
1919

2020
import iconBaseStyles from '../../stylesheets/_lit-styles/icon-base.css';
21+
import uiIconDirectionStyles from './ui-icon-direction.css';
2122
import uiIconSizeStyles from './ui-icon-sizes.css';
2223

2324
/**
2425
* An internal icon renderer for Spectrum UI icons (chevrons, checkmarks, arrows, and
2526
* other control internals). The `icon` attribute selects the icon-set, and the
2627
* element renders the optically-tuned step that matches its `size`. Extends
27-
* `IconBase` for size and host-owned accessibility. Not published for consumers;
28-
* used by other swc components.
28+
* `IconBase` for size and host-owned accessibility. A curated set of directional
29+
* icons (chevrons, arrows) mirror automatically in RTL; consuming components do not
30+
* need their own mirror rule for these icons. Not published for consumers; used by
31+
* other swc components.
2932
*
3033
* @element swc-ui-icon
3134
* @status internal
@@ -41,13 +44,15 @@ import uiIconSizeStyles from './ui-icon-sizes.css';
4144
export class UiIcon extends IconBase {
4245
/**
4346
* The logical UI icon to render, matching a key in the icon-set registry (for
44-
* example `chevron` or `corner-triangle`).
47+
* example `chevron` or `corner-triangle`). Reflected so RTL mirroring can key off
48+
* the host attribute regardless of whether a consumer sets `icon` via markup or a
49+
* property binding.
4550
*/
46-
@property({ type: String })
51+
@property({ type: String, reflect: true })
4752
public icon!: UiIconName;
4853

4954
public static override get styles(): CSSResultArray {
50-
return [iconBaseStyles, uiIconSizeStyles];
55+
return [iconBaseStyles, uiIconSizeStyles, uiIconDirectionStyles];
5156
}
5257

5358
protected override willUpdate(changed: PropertyValues<this>): void {

2nd-gen/packages/swc/components/ui-icons/test/ui-icons.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,137 @@ export const LabelTogglingTest: Story = {
185185
},
186186
};
187187

188+
// ──────────────────────────────────────────────────────────────
189+
// TEST: RTL mirroring
190+
// ──────────────────────────────────────────────────────────────
191+
192+
export const DirectionalIconMirrorsInRtlTest: Story = {
193+
render: () => html`
194+
<swc-ui-icon
195+
icon="chevron"
196+
dir="rtl"
197+
accessible-label="Expand"
198+
></swc-ui-icon>
199+
`,
200+
play: async ({ canvasElement, step }) => {
201+
const icon = await getComponent<UiIcon>(canvasElement, 'swc-ui-icon');
202+
203+
await step('mirrors a curated directional icon under RTL', async () => {
204+
expect(
205+
getComputedStyle(icon).scale,
206+
'chevron is horizontally mirrored in RTL'
207+
).toBe('-1 1');
208+
});
209+
},
210+
};
211+
212+
// chevron and arrow are two independent :host() selector clauses in
213+
// ui-icon-direction.css, not one dynamically-generated rule, so each is
214+
// tested explicitly; a typo in one clause wouldn't be caught by testing
215+
// only the other.
216+
export const SecondDirectionalIconMirrorsInRtlTest: Story = {
217+
render: () => html`
218+
<swc-ui-icon icon="arrow" dir="rtl" accessible-label="Go"></swc-ui-icon>
219+
`,
220+
play: async ({ canvasElement, step }) => {
221+
const icon = await getComponent<UiIcon>(canvasElement, 'swc-ui-icon');
222+
223+
await step(
224+
'mirrors the other curated directional icon under RTL',
225+
async () => {
226+
expect(
227+
getComputedStyle(icon).scale,
228+
'arrow is horizontally mirrored in RTL'
229+
).toBe('-1 1');
230+
}
231+
);
232+
},
233+
};
234+
235+
// Mirrors tabs.test.ts's SelectionIndicatorDirectionChangeTest pattern: real
236+
// consumers (AccordionItem, MessageSources, ResponseStatus) never set `dir`
237+
// directly on their <swc-ui-icon>; they rely on it inheriting from an
238+
// ancestor. Verified separately from the direct-attribute tests above since
239+
// :host(:dir(rtl)[icon="..."])'s selector-matching quirk (see
240+
// ui-icon-direction.css) makes it worth confirming inherited directionality
241+
// resolves the same way, not assuming it from spec compliance alone.
242+
export const DirectionalIconMirrorsWithInheritedRtlTest: Story = {
243+
render: () => html`
244+
<div id="direction-wrapper" dir="ltr">
245+
<swc-ui-icon icon="chevron" accessible-label="Expand"></swc-ui-icon>
246+
</div>
247+
`,
248+
play: async ({ canvasElement, step }) => {
249+
const icon = await getComponent<UiIcon>(canvasElement, 'swc-ui-icon');
250+
const wrapper = canvasElement.querySelector(
251+
'#direction-wrapper'
252+
) as HTMLElement;
253+
254+
await step('does not mirror while the ancestor is LTR', async () => {
255+
expect(
256+
getComputedStyle(icon).scale,
257+
'chevron is unmirrored while the ancestor is LTR'
258+
).toBe('none');
259+
});
260+
261+
await step(
262+
'mirrors once an ancestor dir attribute flips to RTL at runtime',
263+
async () => {
264+
wrapper.setAttribute('dir', 'rtl');
265+
await icon.updateComplete;
266+
expect(
267+
getComputedStyle(icon).scale,
268+
'chevron mirrors once the ancestor is RTL, with no dir attribute of its own'
269+
).toBe('-1 1');
270+
}
271+
);
272+
273+
wrapper.setAttribute('dir', 'ltr');
274+
},
275+
};
276+
277+
export const DirectionalIconUnmirroredInLtrTest: Story = {
278+
render: () => html`
279+
<swc-ui-icon
280+
icon="chevron"
281+
dir="ltr"
282+
accessible-label="Expand"
283+
></swc-ui-icon>
284+
`,
285+
play: async ({ canvasElement, step }) => {
286+
const icon = await getComponent<UiIcon>(canvasElement, 'swc-ui-icon');
287+
288+
await step('does not mirror under LTR', async () => {
289+
expect(getComputedStyle(icon).scale, 'chevron is unmirrored in LTR').toBe(
290+
'none'
291+
);
292+
});
293+
},
294+
};
295+
296+
export const NonDirectionalIconUnaffectedInRtlTest: Story = {
297+
render: () => html`
298+
<swc-ui-icon
299+
icon="checkmark"
300+
dir="rtl"
301+
accessible-label="Done"
302+
></swc-ui-icon>
303+
`,
304+
play: async ({ canvasElement, step }) => {
305+
const icon = await getComponent<UiIcon>(canvasElement, 'swc-ui-icon');
306+
307+
await step(
308+
'leaves a non-directional icon unmirrored under RTL',
309+
async () => {
310+
expect(
311+
getComputedStyle(icon).scale,
312+
'checkmark is not in the curated directional list, so it never flips'
313+
).toBe('none');
314+
}
315+
);
316+
},
317+
};
318+
188319
// ──────────────────────────────────────────────────────────────
189320
// TEST: Edge cases
190321
// ──────────────────────────────────────────────────────────────
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/*
14+
* A4U exposes no per-icon mirror flag, so directional icons are mirrored in RTL via
15+
* this curated, hand-maintained list rather than per-icon metadata (icon-rfc.md,
16+
* section 11). First pass: chevrons and arrows. Icons not listed here (checkmarks,
17+
* crosses, and similar non-directional art) never flip. Consuming components no
18+
* longer need their own RTL mirror rule for these icons; this is the single place
19+
* that owns the decision.
20+
*
21+
* Both conditions must live inside :host()'s own argument as one compound selector
22+
* (:host(:dir(rtl)[icon="..."])). Chaining an attribute selector after a closing
23+
* :host(:dir(...)) instead (:host(:dir(rtl))[icon="..."]) parses without error but
24+
* never matches, verified against Chromium 138.0.7204.23 (this repo's pinned
25+
* Playwright 1.53.1); keep new entries in this same form rather than "simplifying"
26+
* back to the chained one.
27+
*/
28+
:host(:dir(rtl)[icon="chevron"]),
29+
:host(:dir(rtl)[icon="arrow"]) {
30+
scale: -1 1;
31+
}

0 commit comments

Comments
 (0)