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..a2c93d1a 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,139 @@ 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 under an explicit dir', () => { + const cases: [string, boolean][] = [ + ['
', true], + ['
', 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', () => { + 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); + }); + + 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 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 = '
مرحبا
'; + const probe = fixtureEl.querySelector('#probe'); + + 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('#rtl-by-css'))).toBe(true); + expect(service.isRTL(fixtureEl.querySelector('#ltr-by-css'))).toBe(false); + }); + + 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(field)).toBe(false); + expect(service.isRTL(field?.parentElement)).toBe(true); + }); + + 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 = + '
' + + '
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 e9380b1e..15f3ecde 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="ltr"], [dir="rtl"]'); + if (declared) { + return declared.matches('[dir="rtl"]'); + } } return [this.#document?.documentElement?.dir, this.#document?.body?.dir].includes('rtl');