Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

feat(useInputSelection,useInputCaretPosition): add useInputSelection and useInputCaretPosition #4496

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
Loading
from
Open
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
2 changes: 2 additions & 0 deletions 2 packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export * from './useGeolocation'
export * from './useIdle'
export * from './useImage'
export * from './useInfiniteScroll'
export * from './useInputCaretPosition'
export * from './useInputSelection'
export * from './useIntersectionObserver'
export * from './useKeyModifier'
export * from './useLocalStorage'
Expand Down
1 change: 1 addition & 0 deletions 1 packages/core/useEventListener/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ interface InferEventTarget<Events> {
}

export type WindowEventName = keyof WindowEventMap
export type HTMLElementEventName = keyof HTMLElementEventMap
export type DocumentEventName = keyof DocumentEventMap

export interface GeneralEventListener<E = Event> {
Expand Down
17 changes: 17 additions & 0 deletions 17 packages/core/useInputCaretPosition/demo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
import { ref, useTemplateRef } from 'vue'
import { useInputCaretPosition } from '..'

const input = useTemplateRef<HTMLInputElement>('input')

const { position } = useInputCaretPosition(input)

const inputValue = ref('')

Check failure on line 9 in packages/core/useInputCaretPosition/demo.vue

View workflow job for this annotation

GitHub Actions / lint

Usage of ref() is restricted. Use shallowRef() or deepRef() instead

Check failure on line 9 in packages/core/useInputCaretPosition/demo.vue

View workflow job for this annotation

GitHub Actions / autofix

Usage of ref() is restricted. Use shallowRef() or deepRef() instead
</script>

<template>
<div>
<input ref="input" v-model="inputValue" type="text" placeholder="Type here">
caret position: {{ position }}
</div>
</template>
54 changes: 54 additions & 0 deletions 54 packages/core/useInputCaretPosition/index.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { page, userEvent } from '@vitest/browser/context'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { useInputCaretPosition } from '.'

const simpleInput = defineComponent({
template: `<input data-testId="input" value="vueuse is cool!">`,
})

describe('useInputCaretPosition', () => {
it('should be defined', () => {
expect(useInputCaretPosition).toBeDefined()
})

it('should return the initial position', async () => {
const wrapper = mount(simpleInput)
const { position } = useInputCaretPosition(wrapper.element)
expect(position.value).toMatchInlineSnapshot(`15`)
})

it('should update the position selection change', async ({ onTestFailed, onTestFinished }) => {
const screen = page.render(simpleInput)
onTestFailed(() => screen.unmount())
onTestFinished(() => screen.unmount())
Comment on lines +24 to +25
Copy link
Collaborator Author

@OrbisK OrbisK Jan 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tests are sometimes flaky if i dont do this. not sure why.

I think if a test fails the page is not unmounted, leaving the next tests with some unexpected initial state.

const input = screen.getByTestId('input')
const element = input.element() as HTMLInputElement
const { position } = useInputCaretPosition(element)

await userEvent.click(input)
expect(position.value).toMatchInlineSnapshot(`15`)
await userEvent.keyboard('[ArrowLeft][ArrowLeft]')
expect(position.value).toMatchInlineSnapshot(`13`)
})

it('should update the element when updating the refs', async ({ onTestFailed, onTestFinished }) => {
const screen = page.render(simpleInput)
onTestFailed(() => screen.unmount())
onTestFinished(() => screen.unmount())
const input = screen.getByTestId('input')
const element = input.element() as HTMLInputElement

const { position } = useInputCaretPosition(element)

expect(element.selectionStart).toMatchInlineSnapshot(`15`)
expect(element.selectionEnd).toMatchInlineSnapshot(`15`)

position.value = 2

await nextTick()
expect(element.selectionStart).toMatchInlineSnapshot(`2`)
expect(element.selectionEnd).toMatchInlineSnapshot(`2`)
})
})
33 changes: 33 additions & 0 deletions 33 packages/core/useInputCaretPosition/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
category: Unknown
---

# useCaretPosition

Reactive caret position of an input element.

::: warning
[`onselectionchange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/selectionchange_event) for
inputs is still an experimental browser feature. It may have different behaviors across different
browsers. (e.g. Chrome will fire `onselectionchange` event when the input is
mounted ([Chromium Issue](https://issues.chromium.org/issues/389368412)), leading to unexpected initial values)
:::

## Usage

```vue
<script setup lang="ts">
import { useInputCaretPosition } from '@vueuse/core'
import { useTemplateRef } from 'vue'

const input = useTemplateRef<HTMLInputElement>('input')

const { position } = useInputCaretPosition(input)
</script>

<template>
<div>
<input ref="input" type="text" placeholder="Type here">
</div>
</template>
```
31 changes: 31 additions & 0 deletions 31 packages/core/useInputCaretPosition/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { MaybeElementRef } from '../unrefElement'
import type { UseInputSelectionOptions } from '../useInputSelection'
import { computed } from 'vue'
import { useInputSelection } from '../useInputSelection'

export interface UseInputCaretPostionOptions extends UseInputSelectionOptions {

}

export function useInputCaretPosition<T extends (HTMLInputElement | HTMLTextAreaElement)>(target: MaybeElementRef<T | null | undefined>, options: UseInputCaretPostionOptions = {}) {
const { start, end, direction } = useInputSelection<T>(target, options)

const position = computed<T['selectionStart'] | T['selectionEnd']>({
set(value) {
start.value = value
end.value = value
},
get() {
if (direction.value === 'backward') {
return start.value
}
return end.value
},
})

return {
position,
}
}

export type UseInputCaretPositionReturn = ReturnType<typeof useInputCaretPosition>
34 changes: 34 additions & 0 deletions 34 packages/core/useInputSelection/demo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script setup lang="ts">
import { stringify } from '@vueuse/docs-utils'

Check failure on line 2 in packages/core/useInputSelection/demo.vue

View workflow job for this annotation

GitHub Actions / test (20.x)

Cannot find module '@vueuse/docs-utils' or its corresponding type declarations.

Check failure on line 2 in packages/core/useInputSelection/demo.vue

View workflow job for this annotation

GitHub Actions / test (22.x)

Cannot find module '@vueuse/docs-utils' or its corresponding type declarations.
import { reactive, ref, useTemplateRef } from 'vue'
import { useInputSelection } from '.'

const input = useTemplateRef('input')
const textarea = useTemplateRef('textarea')

const { start, end, direction } = useInputSelection(input)
const selectionTextarea = reactive(useInputSelection(textarea))

const value = ref('VueUse is cool')

Check failure on line 12 in packages/core/useInputSelection/demo.vue

View workflow job for this annotation

GitHub Actions / lint

Usage of ref() is restricted. Use shallowRef() or deepRef() instead

Check failure on line 12 in packages/core/useInputSelection/demo.vue

View workflow job for this annotation

GitHub Actions / autofix

Usage of ref() is restricted. Use shallowRef() or deepRef() instead

const text = stringify(reactive({ start, end, direction }))
const textTextarea = stringify(selectionTextarea)

function selectFirst() {
input.value?.focus()
start.value = 0
end.value = 5
}
</script>

<template>
<div>
<input ref="input" v-model="value" type="text" placeholder="Type here">
<button @click.prevent="() => selectFirst()">
select first 5
</button>
<pre lang="yaml">{{ text }}</pre>
<textarea ref="textarea" v-model="value" type="text" placeholder="Type here" />
<pre lang="yaml">{{ textTextarea }}</pre>
</div>
</template>
58 changes: 58 additions & 0 deletions 58 packages/core/useInputSelection/index.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { page, userEvent } from '@vitest/browser/context'
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { defineComponent, nextTick } from 'vue'
import { useInputSelection } from '.'

const simpleInput = defineComponent({
template: `<input data-testId="input" value="vueuse is cool!">`,
})

// TODO: this tests current chrome behavior, initial values are not consistent across browsers
describe('useInputSelection', () => {
it('should be defined', () => {
expect(useInputSelection).toBeDefined()
})

it('should return the initial position', async () => {
const wrapper = mount(simpleInput)
const { start, end, direction } = useInputSelection(wrapper.element)
expect(start.value).toMatchInlineSnapshot(`15`)
expect(end.value).toMatchInlineSnapshot(`15`)
expect(direction.value).toMatchInlineSnapshot(`"forward"`)
})

it('should update the position selection change', async ({ onTestFailed, onTestFinished }) => {
const screen = page.render(simpleInput)
onTestFailed(() => screen.unmount())
onTestFinished(() => screen.unmount())
const input = screen.getByTestId('input')
const element = input.element() as HTMLInputElement
const { start, end, direction } = useInputSelection(element)

// simulating select all
await userEvent.tripleClick(input)
expect(element.selectionStart).toMatchInlineSnapshot(`0`)
expect(element.selectionEnd).toMatchInlineSnapshot(`15`)
expect(start.value).toMatchInlineSnapshot(`0`)
expect(end.value).toMatchInlineSnapshot(`15`)
expect(direction.value).toMatchInlineSnapshot(`"forward"`)
})

it('should update the element when updating the refs', async ({ onTestFailed, onTestFinished }) => {
const screen = page.render(simpleInput)
onTestFailed(() => screen.unmount())
onTestFinished(() => screen.unmount())
const input = screen.getByTestId('input')
const element = input.element() as HTMLInputElement
const { start, end, direction } = useInputSelection(element)

start.value = 1
end.value = 2
direction.value = 'backward'
await nextTick()
expect(element.selectionStart).toMatchInlineSnapshot(`1`)
expect(element.selectionEnd).toMatchInlineSnapshot(`2`)
expect(element.selectionDirection).toMatchInlineSnapshot(`"backward"`)
})
})
31 changes: 31 additions & 0 deletions 31 packages/core/useInputSelection/index.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
category: Unknown
---

# useInputSelection

Reactive selection state of an input element.

::: warning
[`onselectionchange`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/selectionchange_event) for
inputs is still an experimental browser feature. It may have different behaviors across different
browsers. (e.g. Chrome will fire `onselectionchange` event when the input is
mounted ([Chromium Issue](https://issues.chromium.org/issues/389368412)), leading to unexpected initial values)
:::

## Usage

```vue
<script setup lang="ts">
import { useInputSelection } from '@vueuse/core'
import { useTemplateRef } from 'vue'

const input = useTemplateRef('input')

const { start, end, direction } = useInputSelection(input)
</script>

<template>
<input ref="input" type="text" placeholder="Type here">
</template>
```
65 changes: 65 additions & 0 deletions 65 packages/core/useInputSelection/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ShallowRef } from 'vue'
import type { MaybeElementRef } from '../unrefElement'
import type { HTMLElementEventName } from '../useEventListener'
import { computed, shallowRef, watch } from 'vue'
import { unrefElement } from '../unrefElement'
import { useEventListener } from '../useEventListener'

// todo: maybe remove input and foucsin events if feature is stable
export const inputSelectionDefaultEvents: HTMLElementEventName[] = ['selectionchange', 'input', 'focusin']

export interface UseInputSelectionOptions {
/**
* Events to trigger the selection tracking.
*
* @default ['selectionchange', 'input', 'focusin']
*/
events?: HTMLElementEventName[]
}

export interface UseInputSelectionReturn<T extends (HTMLInputElement | HTMLTextAreaElement)> {
start: ShallowRef<T['selectionStart']>
end: ShallowRef<T['selectionEnd']>
direction: ShallowRef<T['selectionDirection']>
}

/**
* Track or set the selection state of a DOM input or textarea element.
*
* @param target
* @param options
*/
export function useInputSelection<T extends (HTMLInputElement | HTMLTextAreaElement)>(target: MaybeElementRef<T | null | undefined>, options: UseInputSelectionOptions = {}): UseInputSelectionReturn<T> {
const {
events = inputSelectionDefaultEvents,
} = options

const el = computed(() => unrefElement(target))

const start = shallowRef<T['selectionStart'] | null>(el.value?.selectionStart ?? null)
const end = shallowRef<T['selectionEnd'] | null>(el.value?.selectionEnd ?? null)
const direction = shallowRef<T['selectionDirection'] | null>(el.value?.selectionDirection ?? null)

watch([() => start.value, () => end.value, () => direction.value], ([s, e, d]) => {
const el = unrefElement(target)
if (!el)
return
el.setSelectionRange(s, e, d)
}, { flush: 'post' })

useEventListener(target, events, (e) => {
const el = e.target as T
if (start.value !== el.selectionStart)
start.value = el.selectionStart
if (end.value !== el.selectionEnd)
end.value = el.selectionEnd
if (direction.value !== el.selectionDirection)
direction.value = el.selectionDirection
}, { passive: true })

return {
start,
end,
direction,
}
}
Loading
Morty Proxy This is a proxified and sanitized view of the page, visit original site.