From 2bd4fad3e76db9fcbbaa1e08470969e1908d8ef6 Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 9 Oct 2026 22:46:27 +0200 Subject: [PATCH 1/3] fix(RtlService): read computed direction first, so an LTR island on an RTL page is LTR Any dir="rtl" ancestor won over the element's own direction. Computed direction decides now; nearest [dir] only without one (detached element; SSR, Domino returns inline style only). Callers: tabs-list, chip-set; pro adds rating, OTP, stepper, range-slider, autocomplete. --- .../lib/chip-set/chip-set.component.spec.ts | 14 +++ .../src/lib/services/rtl.service.spec.ts | 96 +++++++++++++++++++ .../src/lib/services/rtl.service.ts | 12 ++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts b/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts index 006e8198..0051eef1 100644 --- a/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts +++ b/projects/coreui-angular/src/lib/chip-set/chip-set.component.spec.ts @@ -101,6 +101,7 @@ describe('ChipSetComponent', () => { }); afterEach(() => { + document.documentElement.removeAttribute('dir'); vi.restoreAllMocks(); }); @@ -198,6 +199,19 @@ describe('ChipSetComponent', () => { expect(document.activeElement).toBe(chip('a')); }); + it('ChipSet keeps arrow keys in an LTR island on an RTL page', async () => { + document.documentElement.dir = 'rtl'; + host.selectable.set(true); + host.dir.set('ltr'); + host.declare('a', 'b'); + await fixture.whenStable(); + chip('a').focus(); + press(chip('a'), 'ArrowRight'); + expect(document.activeElement).toBe(chip('b')); + press(chip('b'), 'ArrowLeft'); + expect(document.activeElement).toBe(chip('a')); + }); + it('ChipSet fires remove so the parent can drop the chip', async () => { host.removable.set(true); host.chips.set(['a', 'b']); diff --git a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts index 1274c3ea..2fdb773a 100644 --- a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts +++ b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts @@ -4,13 +4,109 @@ import { RtlService } from './rtl.service'; describe('RtlService', () => { let service: RtlService; + let fixtureEl: HTMLElement; beforeEach(() => { TestBed.configureTestingModule({}); service = TestBed.inject(RtlService); + fixtureEl = document.createElement('div'); + document.body.append(fixtureEl); + }); + + afterEach(() => { + fixtureEl.remove(); + document.documentElement.removeAttribute('dir'); + document.body.removeAttribute('dir'); + vi.restoreAllMocks(); }); it('should be created', () => { expect(service).toBeTruthy(); }); + + it('should read the direction of the element, not of the document', () => { + fixtureEl.innerHTML = '
'; + + expect(service.isRTL(fixtureEl.querySelector('#inside'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#outside'))).toBe(false); + }); + + it('should leave an element that sets its own direction out of an RTL subtree', () => { + document.documentElement.dir = 'rtl'; + fixtureEl.innerHTML = '
'; + + expect(service.isRTL(fixtureEl.querySelector('#island'))).toBe(false); + }); + + it('should fall back to the document when given nothing', () => { + document.documentElement.dir = 'rtl'; + expect(service.isRTL()).toBe(true); + + document.documentElement.dir = ''; + expect(service.isRTL()).toBe(false); + }); + + it('should answer the same for a detached element as for a connected one', () => { + const cases: [string, boolean][] = [ + ['
مرحبا
', true], + ['
hello
', false] + ]; + + for (const [markup, expected] of cases) { + const detached = document.createElement('div'); + detached.innerHTML = markup; + + expect(service.isRTL(detached.querySelector('[data-probe]'))).toBe(expected); + + fixtureEl.append(detached); + + expect(service.isRTL(detached.querySelector('[data-probe]'))).toBe(expected); + } + }); + + it('should answer for the document when a detached element declares nothing', () => { + document.documentElement.dir = 'rtl'; + + expect(service.isRTL(document.createElement('div'))).toBe(true); + }); + + it('should answer for an element that is not in the document yet', () => { + const detached = document.createElement('div'); + detached.dir = 'rtl'; + detached.innerHTML = ''; + + expect(service.isRTL(detached.firstElementChild as HTMLElement)).toBe(true); + }); + + it('should resolve dir=auto from the content it has at the time', () => { + fixtureEl.innerHTML = + '
مرحبا
hello
'; + + expect(service.isRTL(fixtureEl.querySelector('#arabic'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#latin'))).toBe(false); + }); + + it('should follow a direction set from CSS, which is what the layout follows', () => { + fixtureEl.innerHTML = + '
' + + '
'; + + expect(service.isRTL(fixtureEl.querySelector('#styled'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#unstyled'))).toBe(false); + + const detached = document.createElement('div'); + detached.style.direction = 'rtl'; + detached.innerHTML = ''; + + expect(service.isRTL(detached.firstElementChild as HTMLElement)).toBe(false); + }); + + it('should read the nearest dir when there is no computed direction', () => { + vi.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: '' } as CSSStyleDeclaration); + document.documentElement.dir = 'rtl'; + fixtureEl.innerHTML = '
'; + + expect(service.isRTL(fixtureEl.querySelector('#page'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#island'))).toBe(false); + }); }); diff --git a/projects/coreui-angular/src/lib/services/rtl.service.ts b/projects/coreui-angular/src/lib/services/rtl.service.ts index e9380b1e..f2e706bb 100644 --- a/projects/coreui-angular/src/lib/services/rtl.service.ts +++ b/projects/coreui-angular/src/lib/services/rtl.service.ts @@ -8,10 +8,14 @@ export class RtlService { isRTL(element?: HTMLElement | null): boolean { if (element) { - return ( - element.closest('[dir="rtl"]') !== null || - this.#document.defaultView?.getComputedStyle(element).direction === 'rtl' - ); + const direction = element.isConnected ? this.#document.defaultView?.getComputedStyle(element).direction : ''; + if (direction) { + return direction === 'rtl'; + } + const declared = element.closest('[dir]'); + if (declared) { + return declared.matches(':dir(rtl)'); + } } return [this.#document?.documentElement?.dir, this.#document?.body?.dir].includes('rtl'); From d9f58c1debd98c23ab941a299516f6c12573d74a Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 9 Oct 2026 23:34:55 +0200 Subject: [PATCH 2/3] fix(RtlService): fall back to nearest dir="ltr"/"rtl" without :dir() :dir() missing in Chrome/Edge 119, inside Angular 22 browser floor. Without computed direction (detached, SSR) dir="auto" cannot be resolved, so it is skipped for the next explicit dir. --- .../src/lib/services/rtl.service.spec.ts | 60 ++++++++++++++----- .../src/lib/services/rtl.service.ts | 4 +- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts index 2fdb773a..e443fda3 100644 --- a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts +++ b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts @@ -48,8 +48,8 @@ describe('RtlService', () => { it('should answer the same for a detached element as for a connected one', () => { const cases: [string, boolean][] = [ - ['
مرحبا
', true], - ['
hello
', false] + ['
', true], + ['
', false] ]; for (const [markup, expected] of cases) { @@ -65,6 +65,12 @@ describe('RtlService', () => { }); it('should answer for the document when a detached element declares nothing', () => { + const detached = document.createElement('div'); + detached.style.direction = 'rtl'; + detached.innerHTML = ''; + + expect(service.isRTL(detached.firstElementChild as HTMLElement)).toBe(false); + document.documentElement.dir = 'rtl'; expect(service.isRTL(document.createElement('div'))).toBe(true); @@ -78,35 +84,59 @@ describe('RtlService', () => { expect(service.isRTL(detached.firstElementChild as HTMLElement)).toBe(true); }); + it('should answer a detached element in a browser without :dir()', () => { + const matches = Element.prototype.matches; + vi.spyOn(Element.prototype, 'matches').mockImplementation(function (this: Element, selectors: string) { + if (selectors.includes(':dir(')) { + throw new SyntaxError(`'${selectors}' is not a valid selector`); + } + return matches.call(this, selectors); + }); + const detached = document.createElement('div'); + detached.innerHTML = '
hello
'; + + expect(service.isRTL(detached.querySelector('#probe'))).toBe(true); + }); + it('should resolve dir=auto from the content it has at the time', () => { - fixtureEl.innerHTML = - '
مرحبا
hello
'; + fixtureEl.innerHTML = '
مرحبا
'; + const probe = fixtureEl.querySelector('#probe'); - expect(service.isRTL(fixtureEl.querySelector('#arabic'))).toBe(true); - expect(service.isRTL(fixtureEl.querySelector('#latin'))).toBe(false); + expect(service.isRTL(probe)).toBe(true); + + probe?.parentElement?.firstChild?.replaceWith('hello'); + + expect(service.isRTL(probe)).toBe(false); }); it('should follow a direction set from CSS, which is what the layout follows', () => { fixtureEl.innerHTML = - '
' + - '
'; + '
' + + '
'; - expect(service.isRTL(fixtureEl.querySelector('#styled'))).toBe(true); - expect(service.isRTL(fixtureEl.querySelector('#unstyled'))).toBe(false); + expect(service.isRTL(fixtureEl.querySelector('#rtl-by-css'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#ltr-by-css'))).toBe(false); + }); - const detached = document.createElement('div'); - detached.style.direction = 'rtl'; - detached.innerHTML = ''; + it('should answer for a field the stylesheet keeps left-to-right, not for its container', () => { + document.documentElement.dir = 'rtl'; + fixtureEl.innerHTML = + '
'; + const field = fixtureEl.querySelector('#field'); - expect(service.isRTL(detached.firstElementChild as HTMLElement)).toBe(false); + expect(service.isRTL(field)).toBe(false); + expect(service.isRTL(field?.parentElement)).toBe(true); }); it('should read the nearest dir when there is no computed direction', () => { vi.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: '' } as CSSStyleDeclaration); document.documentElement.dir = 'rtl'; - fixtureEl.innerHTML = '
'; + fixtureEl.innerHTML = + '
' + + '
hello
'; expect(service.isRTL(fixtureEl.querySelector('#page'))).toBe(true); expect(service.isRTL(fixtureEl.querySelector('#island'))).toBe(false); + expect(service.isRTL(fixtureEl.querySelector('#auto'))).toBe(true); }); }); diff --git a/projects/coreui-angular/src/lib/services/rtl.service.ts b/projects/coreui-angular/src/lib/services/rtl.service.ts index f2e706bb..15f3ecde 100644 --- a/projects/coreui-angular/src/lib/services/rtl.service.ts +++ b/projects/coreui-angular/src/lib/services/rtl.service.ts @@ -12,9 +12,9 @@ export class RtlService { if (direction) { return direction === 'rtl'; } - const declared = element.closest('[dir]'); + const declared = element.closest('[dir="ltr"], [dir="rtl"]'); if (declared) { - return declared.matches(':dir(rtl)'); + return declared.matches('[dir="rtl"]'); } } From 2126f7d680815c6e3f90866c846581edf37b5e0c Mon Sep 17 00:00:00 2001 From: xidedix Date: Fri, 9 Oct 2026 23:58:56 +0200 Subject: [PATCH 3/3] test(RtlService): guard dir=auto under an RTL ancestor, name what each spec checks --- .../coreui-angular/src/lib/services/rtl.service.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts index e443fda3..a2c93d1a 100644 --- a/projects/coreui-angular/src/lib/services/rtl.service.spec.ts +++ b/projects/coreui-angular/src/lib/services/rtl.service.spec.ts @@ -46,7 +46,7 @@ describe('RtlService', () => { expect(service.isRTL()).toBe(false); }); - it('should answer the same for a detached element as for a connected one', () => { + it('should answer the same for a detached element as for a connected one under an explicit dir', () => { const cases: [string, boolean][] = [ ['
', true], ['
', false] @@ -99,7 +99,7 @@ describe('RtlService', () => { }); it('should resolve dir=auto from the content it has at the time', () => { - fixtureEl.innerHTML = '
مرحبا
'; + fixtureEl.innerHTML = '
مرحبا
'; const probe = fixtureEl.querySelector('#probe'); expect(service.isRTL(probe)).toBe(true); @@ -128,7 +128,7 @@ describe('RtlService', () => { expect(service.isRTL(field?.parentElement)).toBe(true); }); - it('should read the nearest dir when there is no computed direction', () => { + it('should read the nearest explicit dir when there is no computed direction', () => { vi.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: '' } as CSSStyleDeclaration); document.documentElement.dir = 'rtl'; fixtureEl.innerHTML =