Skip to content

Commit

Permalink
feat: add useHideOnScrollDown and truncateToNearestWord
Browse files Browse the repository at this point in the history
  • Loading branch information
ZL-Asica committed Feb 8, 2025
1 parent 07a9d08 commit 44d2961
Show file tree
Hide file tree
Showing 9 changed files with 297 additions and 2 deletions.
1 change: 1 addition & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pnpm changeset version
echo "Remember to run 'pnpm changeset' to create a changeset"
node sync-version.mjs
git add CHANGELOG.md .changeset/ package.json pnpm-lock.yaml jsr.json
lint-staged
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# @zl-asica/react

## 0.3.11

### Patch Changes

- Add useHideOnScrollDown and truncateToNearestWord

## 0.3.10

### Patch Changes
Expand Down
2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zl-asica/react",
"version": "0.3.10",
"version": "0.3.11",
"license": "MIT",
"exports": "./src/index.ts",
"importMap": "./import_map.json",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@zl-asica/react",
"type": "module",
"version": "0.3.10",
"version": "0.3.11",
"packageManager": "pnpm@9.15.4",
"description": "A library of reusable React hooks, components, and utilities built by ZL Asica.",
"author": {
Expand Down
131 changes: 131 additions & 0 deletions src/__tests__/hooks/dom/useHideOnScrollDown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { useHideOnScrollDown } from '@/hooks/dom'
import { act, renderHook } from '@testing-library/react'

describe('useHideOnScrollDown', () => {
beforeEach(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 0, writable: true })
})

it('should initialize as visible', () => {
const { result } = renderHook(() => useHideOnScrollDown())
expect(result.current).toBe(true)
})

it('should stay visible when scrolling up', () => {
const { result } = renderHook(() => useHideOnScrollDown())

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 10, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
Object.defineProperty(globalThis, 'scrollY', { value: 5, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(true)
})

it('should hide when scrolling down past threshold', () => {
const { result } = renderHook(() => useHideOnScrollDown(undefined, 30))

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 40, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(false)
})

it('should show again when scrolling up after being hidden', () => {
const { result } = renderHook(() => useHideOnScrollDown(undefined, 30))

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 40, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})
expect(result.current).toBe(false)

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 20, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})
expect(result.current).toBe(true)
})

it('should use targetRef height if provided', () => {
const container = document.createElement('div')
Object.defineProperty(container, 'offsetHeight', { value: 60, writable: true })
const targetRef = { current: container } as React.RefObject<HTMLElement>

const { result } = renderHook(() => useHideOnScrollDown(targetRef))

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 70, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(false)
})

it('should handle null targetRef gracefully', () => {
const targetRef = { current: null } as React.RefObject<HTMLElement>
const { result } = renderHook(() => useHideOnScrollDown(targetRef, 50))

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 60, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(false)
})

it('should update visibility state when ref changes', () => {
const container1 = document.createElement('div')
Object.defineProperty(container1, 'offsetHeight', { value: 40, writable: true })

const container2 = document.createElement('div')
Object.defineProperty(container2, 'offsetHeight', { value: 80, writable: true })

const targetRef = { current: container1 } as React.RefObject<HTMLElement>

const { rerender, result } = renderHook(
({ ref }) => useHideOnScrollDown(ref),
{ initialProps: { ref: targetRef } },
)

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 50, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})
expect(result.current).toBe(false)

act(() => {
// @ts-expect-error - testing ref change
targetRef.current = container2
rerender({ ref: targetRef })
})

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 70, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})
expect(result.current).toBe(false)
})

it('should support default window target', () => {
const { result } = renderHook(() => useHideOnScrollDown())

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 100, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(false)

act(() => {
Object.defineProperty(globalThis, 'scrollY', { value: 50, writable: true })
globalThis.dispatchEvent(new Event('scroll'))
})

expect(result.current).toBe(true)
})
})
33 changes: 33 additions & 0 deletions src/__tests__/utils/stringUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
reverseString,
toSnakeCase,
truncate,
truncateToNearestWord,
} from '@/utils/stringUtils'

describe('string Utils', () => {
Expand Down Expand Up @@ -141,3 +142,35 @@ describe('generateUniqueId', () => {
expect(id1).not.toEqual(id2) // Different biases should produce different IDs
})
})

describe('truncateToNearestWord', () => {
it('should return the original string if within the limit', () => {
expect(truncateToNearestWord('Short text', 50)).toBe('Short text')
})

it('should truncate at the nearest whole word', () => {
expect(truncateToNearestWord('This is a long example sentence.', 10)).toBe('This is a...')
expect(truncateToNearestWord('Hello world, how are you?', 15)).toBe('Hello world,...')
})

it('should not add "..." if the string is exactly maxLength', () => {
expect(truncateToNearestWord('Perfect fit!', 13)).toBe('Perfect fit!')
})

it('should handle strings with no spaces correctly', () => {
expect(truncateToNearestWord('Supercalifragilisticexpialidocious', 10)).toBe('Supercalif...')
})

it('should handle edge cases gracefully', () => {
expect(truncateToNearestWord('', 10)).toBe('') // Empty string
expect(truncateToNearestWord(' ', 10)).toBe(' ') // String of spaces
expect(truncateToNearestWord('Word', 2)).toBe('Wo...') // Too short to keep anything
})

it('should throw an error for invalid inputs', () => {
// @ts-expect-error: Testing invalid inputs
expect(() => truncateToNearestWord(123, 10)).toThrow()
expect(() => truncateToNearestWord('Valid', -5)).toThrow()
expect(() => truncateToNearestWord('Valid', 0)).toThrow()
})
})
1 change: 1 addition & 0 deletions src/hooks/dom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
export { useAdaptiveEffect } from './useAdaptiveEffect'
export { useClickOutside } from './useClickOutside'
export { useEventListener } from './useEventListener'
export { useHideOnScrollDown } from './useHideOnScrollDown'
export { useHover } from './useHover'
export { useIntersectionObserver } from './useIntersectionObserver'
export { useInViewport } from './useInViewport'
Expand Down
72 changes: 72 additions & 0 deletions src/hooks/dom/useHideOnScrollDown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
'use client'

import type { RefObject } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useEventListener } from './useEventListener'

/**
* useHideOnScrollDown
*
* Custom hook to toggle visibility on scroll.
* It hides the element when scrolling down and shows it when scrolling up.
*
* @param {React.RefObject<HTMLElement>} [targetRef] - The reference to the target element (e.g., header). If not provided, defaults to `null` (uses `threshold`).
* @param {number} [threshold] - The scroll position threshold before hiding (used if `targetRef` is not provided).
* @returns {boolean} - Whether the element should be visible.
*
* @example
* ```tsx
* const headerRef = useRef<HTMLElement>(null)
* const isVisible = useHideOnScrollDown(headerRef)
*
* return (
* <header ref={headerRef} style={{ opacity: isVisible ? 1 : 0 }}>
* Header content
* </header>
* )
* ```
*
* @example
* ```tsx
* const isVisible = useHideOnScrollDown(null, 100)
*
* return (
* <header style={{ opacity: isVisible ? 1 : 0 }}>
* Header content
* </header>
* )
*/
export const useHideOnScrollDown = (
targetRef?: RefObject<HTMLElement>,
threshold: number = 50,
): boolean => {
const [isVisible, setIsVisible] = useState(true)
const lastScrollY = useRef(0)
const [hideThreshold, setHideThreshold] = useState(
targetRef?.current?.offsetHeight ?? threshold,
)

// When targetRef changes, update the hideThreshold
useEffect(() => {
setHideThreshold(targetRef?.current?.offsetHeight ?? threshold)
globalThis.dispatchEvent(new Event('scroll')) // Force update on scroll
}, [targetRef, threshold])

useEventListener('scroll', () => {
const currentScrollY = globalThis.scrollY

if (currentScrollY < hideThreshold) {
setIsVisible(true)
}
else if (currentScrollY > lastScrollY.current) {
setIsVisible(false)
}
else {
setIsVisible(true)
}

lastScrollY.current = currentScrollY
})

return isVisible
}
51 changes: 51 additions & 0 deletions src/utils/stringUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,54 @@ export const generateUniqueId = async (
const fallbackSimple = Math.random().toString(36) + Date.now().toString(36)
return fallbackSimple.slice(0, length)
}

/**
* truncateToNearestWord
*
* Truncate a string to the nearest whole word within a given length limit.
* If the string exceeds the max length, it appends '...' at the end.
* If no space is found within the truncated string, and the string is longer than the max length,
* it will truncate the string at the max length and append '...'.
*
* @param {string} input - The raw input string to be truncated.
* @param {number} maxLength - The maximum length of the truncated string (including ellipsis if applied).
* @returns {string} - The truncated string that does not break words, with an optional ellipsis.
*
* @example
* ```ts
* truncateToNearestWord("This is a very long sentence that needs truncation.", 20);
* // Returns: "This is a very..."
* ```
*
* @example
* ```ts
* truncateToNearestWord("Short sentence.", 50);
* // Returns: "Short sentence." (no truncation applied)
* ```
*
* @example
* ```ts
* truncateToNearestWord("Exact length match!", 20);
* // Returns: "Exact length match!"
* ```
*/
export const truncateToNearestWord = (input: string, maxLength: number): string => {
if (typeof input !== 'string' || typeof maxLength !== 'number' || maxLength <= 0) {
throw new TypeError('Invalid input: input must be a string and maxLength must be a positive number.')
}

// No truncation needed
if (input.length <= maxLength) {
return input
}

let truncated = input.substring(0, maxLength)

// Find the last space within the truncated string to avoid cutting words
const lastSpaceIndex = truncated.lastIndexOf(' ')
if (lastSpaceIndex > 0) {
truncated = truncated.substring(0, lastSpaceIndex)
}

return `${truncated}...`
}

0 comments on commit 44d2961

Please sign in to comment.