Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/all-schools-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": minor
---

feat(dom): Accept both CSS selector strings and HTMLElements as input (all utilities)
5 changes: 5 additions & 0 deletions .changeset/cold-regions-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": minor
---

feat(dom): Add `onScrollComplete` callback and error handling in `scrollToElementAfterRender`
5 changes: 5 additions & 0 deletions .changeset/mean-states-sneeze.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": patch
---

fix(dom): Safely handle null parent nodes in `toggleInertAround` to prevent errors
5 changes: 5 additions & 0 deletions .changeset/moody-peaches-judge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": minor
---

feat(dom): Add error handling and return error in `focusElement` if `focus()` throws
5 changes: 5 additions & 0 deletions .changeset/pretty-moons-cough.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": minor
---

feat(dom): Return `null` or empty array when elements are not found (getElementDimensions`, `getFocusableElements`, `isElementInViewport`)
5 changes: 5 additions & 0 deletions .changeset/quick-ads-attack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bnidev/js-utils": minor
---

feat(dom): Return an object in `focusElement` to provide useful feedback and enable follow-up handling
91 changes: 88 additions & 3 deletions src/dom/__tests__/focusElement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,23 @@ describe('focusElement', () => {

afterEach(() => {
element.remove()
vi.restoreAllMocks()
})

it('focuses the element and removes tabindex by default', () => {
const focusSpy = vi.spyOn(element, 'focus')
focusElement('test-element')
const {
element: el,
attempted,
focused,
error
} = focusElement('#test-element')

expect(el).toBe(element)
expect(attempted).toBe(true)
expect(focused).toBe(true)
expect(error).toBeUndefined()

expect(document.activeElement).toBe(element)
expect(element.tabIndex).toBe(-1)
expect(focusSpy).toHaveBeenCalled()
Expand All @@ -25,14 +37,87 @@ describe('focusElement', () => {

it('focuses the element and keeps tabindex if removeTabIndex is false', () => {
const focusSpy = vi.spyOn(element, 'focus')
focusElement('test-element', false)
const {
element: el,
attempted,
focused,
error
} = focusElement('#test-element', false)

expect(el).toBe(element)
expect(attempted).toBe(true)
expect(focused).toBe(true)
expect(error).toBeUndefined()

expect(document.activeElement).toBe(element)
expect(element.tabIndex).toBe(-1)
expect(focusSpy).toHaveBeenCalled()
expect(element.getAttribute('tabindex')).toBe('-1')
})

it('does nothing if the element does not exist', () => {
expect(() => focusElement('non-existent')).not.toThrow()
const {
element: el,
attempted,
focused,
error
} = focusElement('#non-existent')

expect(el).toBeNull()
expect(attempted).toBe(false)
expect(focused).toBe(false)
expect(error).toBeUndefined()
})

it('returns error if focus throws', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) // silence warning

vi.spyOn(element, 'focus').mockImplementation(() => {
throw new Error('Focus failed')
})

const { error } = focusElement('#test-element')

expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toBe('Focus failed')

warnSpy.mockRestore() // restore console.warn after test
})

it('removes tabindex attribute by default', () => {
// Make sure element has tabindex set initially so removeAttribute matters
element.setAttribute('tabindex', '0')

const { element: el } = focusElement('#test-element')

expect(el).toBe(element)
expect(el?.hasAttribute('tabindex')).toBe(false)
})

it('removes tabindex attribute if removeTabIndex is true explicitly', () => {
element.setAttribute('tabindex', '3')
const { element: el } = focusElement('#test-element', true)

expect(el).toBe(element)
expect(el?.hasAttribute('tabindex')).toBe(false)
})

it('does not remove tabindex attribute if removeTabIndex is false', () => {
element.setAttribute('tabindex', '-1')
const { element: el } = focusElement('#test-element', false)
expect(el?.getAttribute('tabindex')).toBe('-1')
})

it('focuses the element when passed as an HTMLElement', () => {
const focusSpy = vi.spyOn(element, 'focus')
const { element: el, attempted, focused, error } = focusElement(element)

expect(el).toBe(element)
expect(attempted).toBe(true)
expect(focused).toBe(true)
expect(error).toBeUndefined()

expect(document.activeElement).toBe(element)
expect(focusSpy).toHaveBeenCalled()
})
})
58 changes: 52 additions & 6 deletions src/dom/__tests__/getElementDimensions.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getElementDimensions } from '../getElementDimensions'

describe('getElementDimensions', () => {
it('returns correct dimensions and position from getBoundingClientRect', () => {
const mockElement = document.createElement('div')
let element: HTMLElement

vi.spyOn(mockElement, 'getBoundingClientRect').mockReturnValue({
beforeEach(() => {
element = document.createElement('div')
element.id = 'test-element'
document.body.appendChild(element)
})

afterEach(() => {
element.remove()
vi.restoreAllMocks()
})

it('returns correct dimensions from a DOM element', () => {
const mockRect = {
width: 100,
height: 50,
top: 10,
Expand All @@ -15,9 +26,12 @@ describe('getElementDimensions', () => {
x: 20,
y: 10,
toJSON: () => {}
})
}

vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(mockRect)

const result = getElementDimensions(element)

const result = getElementDimensions(mockElement)
expect(result).toEqual({
width: 100,
height: 50,
Expand All @@ -27,4 +41,36 @@ describe('getElementDimensions', () => {
bottom: 60
})
})

it('returns correct dimensions from a selector', () => {
const mockRect = {
width: 200,
height: 150,
top: 30,
left: 40,
right: 240,
bottom: 180,
x: 40,
y: 30,
toJSON: () => {}
}

vi.spyOn(element, 'getBoundingClientRect').mockReturnValue(mockRect)

const result = getElementDimensions('#test-element')

expect(result).toEqual({
width: 200,
height: 150,
top: 30,
left: 40,
right: 240,
bottom: 180
})
})

it('returns null if selector does not match any element', () => {
const result = getElementDimensions('#non-existent')
expect(result).toBeNull()
})
})
52 changes: 38 additions & 14 deletions src/dom/__tests__/getFocusableElements.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getFocusableElements } from '../getFocusableElements'

describe('getFocusableElements', () => {
it('returns only focusable elements', () => {
// Create a mock container with various elements
const container = document.createElement('div')
let container: HTMLElement

beforeEach(() => {
container = document.createElement('div')
container.id = 'test-container'
container.innerHTML = `
<a href="#">Link</a>
<button>Button</button>
Expand All @@ -19,12 +21,16 @@ describe('getFocusableElements', () => {
<div style="display: none" tabindex="0">Hidden via display</div>
<div style="visibility: hidden" tabindex="0">Hidden via visibility</div>
`

document.body.appendChild(container)
})

afterEach(() => {
container.remove()
})

it('returns only visible, enabled focusable elements from an HTMLElement', () => {
const result = getFocusableElements(container)

// Should return the valid, visible focusable elements
expect(result).toHaveLength(6)
expect(result.map((el) => el.tagName.toLowerCase())).toEqual([
'a',
Expand All @@ -34,18 +40,36 @@ describe('getFocusableElements', () => {
'textarea',
'div'
])
})

document.body.removeChild(container)
it('returns only visible, enabled focusable elements from a selector', () => {
const result = getFocusableElements('#test-container')

expect(result).toHaveLength(6)
expect(result.map((el) => el.tagName.toLowerCase())).toEqual([
'a',
'button',
'input',
'select',
'textarea',
'div'
])
})

it('returns an empty array if no focusable elements exist', () => {
const container = document.createElement('div')
container.innerHTML = `
<div>Plain text</div>
<span>Span</span>
`
it('returns an empty array when container selector does not match', () => {
const result = getFocusableElements('#non-existent')
expect(result).toEqual([])
})

const result = getFocusableElements(container)
it('returns an empty array if container has no focusable elements', () => {
const emptyContainer = document.createElement('div')
emptyContainer.id = 'empty'
emptyContainer.innerHTML = `<p>Text only</p><span>No tabindex</span>`
document.body.appendChild(emptyContainer)

const result = getFocusableElements('#empty')
expect(result).toEqual([])

emptyContainer.remove()
})
})
26 changes: 26 additions & 0 deletions src/dom/__tests__/isElementInViewport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,30 @@ describe('isElementInViewport', () => {
it('returns false if the element is null', () => {
expect(isElementInViewport(null as unknown as HTMLElement)).toBe(false)
})

it('returns true when given a selector of a visible element', () => {
const el = document.createElement('div')
el.id = 'visible-element'
document.body.appendChild(el)

vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
top: 50,
left: 50,
bottom: 150,
right: 150,
width: 100,
height: 100,
x: 50,
y: 50,
toJSON: () => {}
})

expect(isElementInViewport('#visible-element')).toBe(true)

document.body.removeChild(el)
})

it('returns false when given a selector that matches no element', () => {
expect(isElementInViewport('#non-existent')).toBe(false)
})
})
Loading