I am trying to change the format of an input with a bind:value and a function, but when I change the format and in this case the length of the input changes compared to the original input the caret is placed between the characters and not at the end.
Also the input is a reusable component so I would prefer the fix to be done in the parent of this component, so as not to integrate additional logic into it.
If someone knows a solution I would appreciate it.
example: svelte playground
parent component:
<script>
import InputComponent from "./InputComponent.svelte"
let id = $state('')
function formatId(value) {
value = value.replace(/\D/g, '');
if (value.length < 2) return value;
const idWithoutDash = value.slice(0, -1);
const verifier = value.slice(-1);
const formatted = idWithoutDash.replace(/\B(?=(\d{2})+(?!\d))/g, '.');
return `${formatted}-${verifier}`;
}
</script>
<InputComponent bind:value={() => id, (value) => id = formatId(value)}></InputComponent>
<p>
{id}
</p>
child component:
<script>
let { value = $bindable() } = $props();
</script>
<input bind:value={value} />
I was trying to format the user input as I was typing using bind:value and running a function, I expected the input to be smooth with the carot always at the end but the result is that the carot gets placed between the characters in the input if the input changes length.
I am trying to change the format of an input with a bind:value and a function, but when I change the format and in this case the length of the input changes compared to the original input the caret is placed between the characters and not at the end.
Also the input is a reusable component so I would prefer the fix to be done in the parent of this component, so as not to integrate additional logic into it.
If someone knows a solution I would appreciate it.
example: svelte playground
parent component:
<script>
import InputComponent from "./InputComponent.svelte"
let id = $state('')
function formatId(value) {
value = value.replace(/\D/g, '');
if (value.length < 2) return value;
const idWithoutDash = value.slice(0, -1);
const verifier = value.slice(-1);
const formatted = idWithoutDash.replace(/\B(?=(\d{2})+(?!\d))/g, '.');
return `${formatted}-${verifier}`;
}
</script>
<InputComponent bind:value={() => id, (value) => id = formatId(value)}></InputComponent>
<p>
{id}
</p>
child component:
<script>
let { value = $bindable() } = $props();
</script>
<input bind:value={value} />
I was trying to format the user input as I was typing using bind:value and running a function, I expected the input to be smooth with the carot always at the end but the result is that the carot gets placed between the characters in the input if the input changes length.
Share Improve this question edited Jan 20 at 6:51 Uwe Keim 40.7k61 gold badges187 silver badges302 bronze badges asked Jan 20 at 6:44 mferick02mferick02 32 bronze badges1 Answer
Reset to default 0The short answer to this: It is currently impossible to achieve this without modifying the input component. This is because a way to both get and set the current caret position is absolutely needed. It is only possible if the parent component can get a reference to the input element in order to read or set the selectionStart
property of the input textbox.
Longer Answers
Before I start, I'll answer your question, which is: How can one always maintain the caret at the end of the string?
R/
- Make the input provide a way to set the caret position (an exported function or a prop).
- Using an
$effect.pre
, set the input element'sselectionStart
to be the input box's value's length.
Why don't I show code for this? Well, what if the user types "123456", just to realize it was meant to be "132456"? What if the user repositions the caret instead of backspacing all the way down, say with the mouse or the arrow keys? After repositioning, the user deletes the wrong digit, which makes the caret reposition to the end of the string. Now the user has to reposition the caret once more to type the good character.
So no, a solution that strictly answers your question is a poor solution.
Still, if you definitely want to pursue the poor solution, read the good solutions for ways of communicating with the input element of the child component from the parent component.
Better Solutions
Any acceptable solution, in my opinion, needs to preserve the caret exactly where it was relative to the digits immediately after the value changes.
This requires the ability to obtain the caret's position right after the value edit.
To gain access to the caret position, we can:
- Export 2 functions from the input component:
getCaretPos
andsetCaretPos
. With these, the parent component can read and write the position of the caret. - Expose a
caretPos
bindable property that gets updated using anoninput
handler. Then the parent component would simply bind to this property. - If/When PR #15.000 sees daylight, you could get a hold of the input element using an attachment (the replacement of actions). The input component would have to collaborate by spreading
restProps
on the input element.
The first option is the least appealing because it is the most modifications to the input component.
Solving with Bindable caretPos
<script>
let { value = $bindable(), caretPos = $bindable() } = $props();
let box = $state();
$effect.pre(() => {
if (!box || caretPos === undefined) {
return;
}
box.selectionStart = box.selectionEnd = caretPos;
});
</script>
<input bind:value bind:this={box} oninput={() => caretPos = box.selectionStart} />
This in itself presents an obstacle: It doesn't work. The value binding (and execution of the formatting function in the parent) will execute before the oninput
event handler seen here, so the bound value in the parent for caretPos
will always be outdated (will be the previous edit's value).
"If you cannot beat them, join them."
As the saying goes, since we cannot beat the value binding, we modify the binding to use functions:
<script>
let { value = $bindable(), caretPos = $bindable() } = $props();
let box = $state();
$effect.pre(() => {
if (!box || caretPos === undefined) {
return;
}
box.selectionStart = box.selectionEnd = caretPos;
});
</script>
<input bind:value={() => value, (v) => { caretPos = box.selectionStart; value = v; }} bind:this={box} />
This one will work.
The $effect.pre
is used to transmit new caret positions to the input element; the setter binding function updates the caretPos
and value
bindable properties. Net effect: We have a functional 2-way communication of the caret's position.
Now to the parent component. This is its script tag
<script>
import InputComponent from "./InputComponent.svelte"
import { tick } from "svelte";
let id = $state('')
let caretPos = $state(0);
function formatId(value) {
let finalCaretPos = caretPos;
let referencePos = caretPos;
value = value.replace(/\D/g, (_, offset) => {
if (offset < referencePos) {
--finalCaretPos;
}
return '';
});
if (value.length < 2) return value;
const idWithoutDash = value.slice(0, -1);
const verifier = value.slice(-1);
referencePos = finalCaretPos;
const formatted = idWithoutDash.replace(/\B(?=(\d{2})+(?!\d))/g, (_, _g1, offset) => {
if (offset < referencePos) {
++finalCaretPos;
}
return '.';
});
if (finalCaretPos >= formatted.length) {
++finalCaretPos;
}
tick().then(() => caretPos = finalCaretPos);
return `${formatted}-${verifier}`;
}
</script>
The caretPos
state piece is used to bind to the component (not shown, but yes, another bind:
in your InputComponent
).
Then it comes the calculations. We start with the original caret position. Then we copy that value to be used in the first part: The stripping of the non-digits. Every time a non-digit is removed from the value before (or to the left of) the caret, we decrease finalCaretPos
. How? By taking advantage of the fact that String.prototype.replace()
can take a replacer function. We use this function for this purpose.
Then comes the verifier digit logic. All good, no changes.
Then comes the adding format, which follows the same logic as as removals. We first update the referencePos
variable because it has to be in sync with the current version of the value, which is a value with no non-digits. Then the logic goes as it went before, but adding every time a period is added, so long the position is before (or to the left of) the caret.
To complete the trick, we transmit the calculated final caret position by setting the bound caretPos
property using tick().then()
. The tick()
function's return value is a promise that resolves after all pending UI changes have occurred. I added this because I used $effect.pre
in the input component. Now that I am explaining my doing, maybe tick()
was not needed had I used $effect
. Try it out and see if it works.
Live demo in REPL
Solving with Attachments
The first part of the solution is to modify the input component by adding restProps
and spreading them on the input element:
<script>
let { value = $bindable(), ...restProps } = $props();
</script>
<input bind:value {...restProps} />
The second part is the parent component. This time I show the entire file so the attachment part is seen in the input component:
<script>
import InputComponent from "./InputComponent.svelte"
import { tick } from "svelte";
let id = $state('')
let inputEl = $state();
function getInputEl(node) {
inputEl = node;
return () => inputEl = undefined;
}
function formatId(value) {
let finalCaretPos = inputEl?.selectionStart ?? 0;
let referencePos = finalCaretPos;
value = value.replace(/\D/g, (_, offset) => {
if (offset < referencePos) {
--finalCaretPos;
}
return '';
});
if (value.length < 2) return value;
const idWithoutDash = value.slice(0, -1);
const verifier = value.slice(-1);
referencePos = finalCaretPos;
const formatted = idWithoutDash.replace(/\B(?=(\d{2})+(?!\d))/g, (_, _g1, offset) => {
if (offset < referencePos) {
++finalCaretPos;
}
return '.';
});
if (finalCaretPos >= formatted.length) {
++finalCaretPos;
}
tick().then(() => {
if (inputEl) {
inputEl.selectionStart = inputEl.selectionEnd = finalCaretPos;
}
});
return `${formatted}-${verifier}`;
}
</script>
<InputComponent bind:value={() => id, (value) => id = formatId(value)} {@attach getInputEl}></InputComponent>
<p>
{id}
</p>
The {@attach getInputEl}
part is the attachment, a new Svelte v5 feature that is about to come out, hopefully, in the next few days or weeks.
getInputEl
is the attachment function, which can be found in the script tag.
There are no logical changes in the algorithm. The only thing that changes is how the caret position is read: In this version we use inputEl?.selectionStart
. There's technically a change that inputEl
be undefined
, so this version takes a couple previsions for this at the start of formatInput()
and in the delegate in tick().then()
.
Live demo in REPL