Skip to content

Commit cb0a8ab

Browse files
feat: add DOM utilities
1 parent 005edb8 commit cb0a8ab

File tree

7 files changed

+189
-9
lines changed

7 files changed

+189
-9
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# @curiousleaf/utils
1+
# @primoui/utils
22

33
To install dependencies:
44

bun.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22
"lockfileVersion": 1,
33
"workspaces": {
44
"": {
5-
"name": "@curiousleaf/utils",
5+
"name": "@primoui/utils",
66
"dependencies": {
77
"@sindresorhus/slugify": "^2.2.1",
88
},
99
"devDependencies": {
1010
"@biomejs/biome": "^1.9.4",
11-
"@types/bun": "^1.2.14",
11+
"@types/bun": "^1.2.15",
1212
"rimraf": "^6.0.1",
1313
"typescript": "^5.8.3",
1414
"vite": "^6.3.5",
@@ -133,7 +133,7 @@
133133

134134
"@sindresorhus/transliterate": ["@sindresorhus/[email protected]", "", { "dependencies": { "escape-string-regexp": "^5.0.0" } }, "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ=="],
135135

136-
"@types/bun": ["@types/[email protected].14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
136+
"@types/bun": ["@types/[email protected].15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
137137

138138
"@types/estree": ["@types/[email protected]", "", {}, "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ=="],
139139

@@ -147,7 +147,7 @@
147147

148148
"brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
149149

150-
"bun-types": ["[email protected].14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
150+
"bun-types": ["[email protected].15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
151151

152152
"color-convert": ["[email protected]", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
153153

package.json

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
{
22
"name": "@primoui/utils",
33
"description": "A lightweight set of utilities",
4-
"version": "1.1.7",
4+
"version": "1.1.8",
55
"license": "MIT",
66
"type": "module",
77
"author": {
88
"name": "Piotr Kulpinski",
99
"email": "[email protected]",
1010
"url": "https://kulpinski.pl"
1111
},
12-
"repository": "primoui/utils",
12+
"repository": {
13+
"type": "git",
14+
"url": "https://github.com/primoui/utils.git"
15+
},
1316
"files": ["dist"],
1417
"main": "./dist/index.umd.cjs",
1518
"module": "./dist/index.js",
@@ -32,7 +35,7 @@
3235
},
3336
"devDependencies": {
3437
"@biomejs/biome": "^1.9.4",
35-
"@types/bun": "^1.2.14",
38+
"@types/bun": "^1.2.15",
3639
"rimraf": "^6.0.1",
3740
"typescript": "^5.8.3",
3841
"vite": "^6.3.5"

src/dom/dom.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { beforeEach, describe, expect, it, jest } from "bun:test"
2+
import { getElementPosition } from "./dom"
3+
4+
// Mock DOM methods
5+
const mockGetElementById = jest.fn()
6+
const mockGetComputedStyle = jest.fn()
7+
const mockGetBoundingClientRect = jest.fn()
8+
9+
// Setup global mocks
10+
Object.defineProperty(global, "document", {
11+
value: {
12+
getElementById: mockGetElementById,
13+
},
14+
writable: true,
15+
})
16+
17+
Object.defineProperty(global, "window", {
18+
value: {
19+
getComputedStyle: mockGetComputedStyle,
20+
scrollY: 0,
21+
},
22+
writable: true,
23+
})
24+
25+
describe("getElementPosition", () => {
26+
beforeEach(() => {
27+
jest.clearAllMocks()
28+
// Reset window.scrollY
29+
Object.defineProperty(window, "scrollY", {
30+
value: 0,
31+
writable: true,
32+
})
33+
})
34+
35+
it("should return undefined when element is not found", () => {
36+
mockGetElementById.mockReturnValue(null)
37+
38+
const result = getElementPosition("non-existent-id")
39+
40+
expect(result).toBeUndefined()
41+
expect(mockGetElementById).toHaveBeenCalledWith("non-existent-id")
42+
})
43+
44+
it("should return undefined when id is empty string", () => {
45+
mockGetElementById.mockReturnValue(null)
46+
47+
const result = getElementPosition("")
48+
49+
expect(result).toBeUndefined()
50+
expect(mockGetElementById).toHaveBeenCalledWith("")
51+
})
52+
53+
it("should return undefined when id is undefined", () => {
54+
mockGetElementById.mockReturnValue(null)
55+
56+
const result = getElementPosition(undefined)
57+
58+
expect(result).toBeUndefined()
59+
expect(mockGetElementById).toHaveBeenCalledWith("")
60+
})
61+
62+
it("should calculate position correctly with no scroll margin", () => {
63+
const mockElement = {
64+
getBoundingClientRect: mockGetBoundingClientRect,
65+
}
66+
67+
mockGetElementById.mockReturnValue(mockElement)
68+
mockGetBoundingClientRect.mockReturnValue({ top: 100 })
69+
mockGetComputedStyle.mockReturnValue({ scrollMarginTop: "0px" })
70+
71+
// Set scroll position
72+
Object.defineProperty(window, "scrollY", { value: 50, writable: true })
73+
74+
const result = getElementPosition("test-id")
75+
76+
expect(result).toEqual({
77+
id: "test-id",
78+
top: 150, // 50 (scrollY) + 100 (getBoundingClientRect.top) - 0 (scrollMarginTop)
79+
})
80+
expect(mockGetElementById).toHaveBeenCalledWith("test-id")
81+
expect(mockGetComputedStyle).toHaveBeenCalledWith(mockElement)
82+
})
83+
84+
it("should calculate position correctly with scroll margin", () => {
85+
const mockElement = {
86+
getBoundingClientRect: mockGetBoundingClientRect,
87+
}
88+
89+
mockGetElementById.mockReturnValue(mockElement)
90+
mockGetBoundingClientRect.mockReturnValue({ top: 200 })
91+
mockGetComputedStyle.mockReturnValue({ scrollMarginTop: "20px" })
92+
93+
// Set scroll position
94+
Object.defineProperty(window, "scrollY", { value: 100, writable: true })
95+
96+
const result = getElementPosition("test-id")
97+
98+
expect(result).toEqual({
99+
id: "test-id",
100+
top: 280, // 100 (scrollY) + 200 (getBoundingClientRect.top) - 20 (scrollMarginTop)
101+
})
102+
})
103+
104+
it("should handle fractional scroll margin values", () => {
105+
const mockElement = {
106+
getBoundingClientRect: mockGetBoundingClientRect,
107+
}
108+
109+
mockGetElementById.mockReturnValue(mockElement)
110+
mockGetBoundingClientRect.mockReturnValue({ top: 150.5 })
111+
mockGetComputedStyle.mockReturnValue({ scrollMarginTop: "10.7px" })
112+
113+
// Set scroll position
114+
Object.defineProperty(window, "scrollY", { value: 75.3, writable: true })
115+
116+
const result = getElementPosition("test-id")
117+
118+
expect(result).toEqual({
119+
id: "test-id",
120+
top: 215, // Math.floor(75.3 + 150.5 - 10.7) = Math.floor(215.1) = 215
121+
})
122+
})
123+
124+
it("should handle zero scroll position", () => {
125+
const mockElement = {
126+
getBoundingClientRect: mockGetBoundingClientRect,
127+
}
128+
129+
mockGetElementById.mockReturnValue(mockElement)
130+
mockGetBoundingClientRect.mockReturnValue({ top: 300 })
131+
mockGetComputedStyle.mockReturnValue({ scrollMarginTop: "10px" })
132+
133+
// window.scrollY is already 0 from beforeEach
134+
135+
const result = getElementPosition("test-id")
136+
137+
expect(result).toEqual({
138+
id: "test-id",
139+
top: 290, // 0 + 300 - 10
140+
})
141+
})
142+
143+
it("should handle negative getBoundingClientRect top values", () => {
144+
const mockElement = {
145+
getBoundingClientRect: mockGetBoundingClientRect,
146+
}
147+
148+
mockGetElementById.mockReturnValue(mockElement)
149+
mockGetBoundingClientRect.mockReturnValue({ top: -50 })
150+
mockGetComputedStyle.mockReturnValue({ scrollMarginTop: "0px" })
151+
152+
Object.defineProperty(window, "scrollY", { value: 200, writable: true })
153+
154+
const result = getElementPosition("test-id")
155+
156+
expect(result).toEqual({
157+
id: "test-id",
158+
top: 150, // 200 + (-50) - 0
159+
})
160+
})
161+
})

src/dom/dom.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Returns the position of an element with the given ID relative to the top of the viewport.
3+
* @param id - The ID of the element to get the position of.
4+
* @returns An object with the ID and top position of the element, or undefined if the element is not found.
5+
*/
6+
export const getElementPosition = (id?: string) => {
7+
const el = document.getElementById(id || "")
8+
if (!el) return
9+
10+
const style = window.getComputedStyle(el)
11+
const scrollMt = Number.parseFloat(style.scrollMarginTop)
12+
const top = Math.floor(window.scrollY + el.getBoundingClientRect().top - scrollMt)
13+
14+
return { id, top }
15+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./colors/colors"
2+
export * from "./dom/dom"
23
export * from "./errors/errors"
34
export * from "./events/events"
45
export * from "./format/format"

vite.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export default defineConfig({
66
build: {
77
lib: {
88
entry: resolve(__dirname, "./src/index.ts"),
9-
name: "@curiousleaf/utils",
9+
name: "@primoui/utils",
1010
fileName: "index",
1111
},
1212
rollupOptions: {

0 commit comments

Comments
 (0)