sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.18k stars 4.09k forks source link

[Site]: Explain How to Format Inputs in the Tutorials #6997

Open ITenthusiasm opened 2 years ago

ITenthusiasm commented 2 years ago

Describe the problem

It's not readily apparent from the docs how to format inputs (at least not for beginners). This can be a significant source of frustration/confusion. I think this is especially true for devs coming from other frameworks, where people are used to easily forcing inputs to adhere to state variables.

I've spent a good few hours between days trying to figure out how to make a money-formatted input element in Svelte. I almost opened a new issue in ignorance asking for one-way data-binding from state to element (like in React for inputs) until I saw #6197, and then saw Rich mention masking in #2446.

Describe the proposed solution

I believe the Tutorials would benefit greatly from a brief explanation on this. Intuitively, when I was trying to figure out how to format my inputs, I searched the part of the tutorials that talked about inputs. Perhaps a new section could be added called Formatted Inputs or Masked Inputs. I know this is under the bindings section, but since it relates to binding and to the order of directives, I think this is a proper location.

Whatever comments are made, the markup would probably include something like

<input type="text" on:input={handleInput} bind:value={value} />

with the script being something like

let value;

function handleInput(event) {
  if (someConditionIsMet) {
    value = event.target.value; // or event.currentTarget.value
    // Maybe also apply formatting
    return;
  }

  // Do not change the input's value if condition is not met
  event.target.value = value;
}

Alternatives considered

Alternatively, something like actions could be used in the tutorial... which might influence the location of this section. The important thing to me would be whatever makes things clearer/easier. I would think that the previous approach would require less of an established foundation for beginners.

This information could also be placed somewhere outside of the docs. But it would be great to have some kind of clear base example easily accessible for all people.

Importance

would make my life easier

dummdidumm commented 2 years ago

If I understand you correctly, your confusion came from thinking that you only have the possibility to do bind:value for inputs, and that you didn't know that you could use regular input+events combinations? If so, what about the wording in this tutorial step was unclear to you (or in a way so that you didn't think about it later)?

ITenthusiasm commented 2 years ago

Not quite. I knew about events. However, my confusion was centered on how to format inputs specifically.

So for instance, using a credit card format. (That example uses an action, though. Alternatives involving state + event handler also exist.) Or perhaps requiring a money format... Basically, something that requires a certain format for the input, that might automatically format the input, and that prevents users from inputting invalid values.

In other frameworks like React or Vue, no explanation for this use case is necessary. Their implementation of one-way data binding (specifically, one-way binding from state to element) leaves the state entirely in control and prevents DOM events from updating elements or state on their own. So for formatting inputs, a user would just listen for the DOM event and update the state variable accordingly in a function they made -- as normal.

In Svelte, DOM events are not prevented from updating the DOM or state variable. Because of this, the order of on:input and bind:value matter significantly when it comes to formatting an input (as pointed out in #2446). But this nuance isn't readily apparent anywhere, making formatting a bit difficult for newcomers -- especially from other frameworks.

Does that answer your question?

dummdidumm commented 2 years ago

Maybe we are talking past each other, but what you describe is possible with Svelte, too:

<script>
    let value = 'world';
    function handle(evt) {
        if (value.length > 10) {
            value = 'nope';
        } else {
            value = evt.target.value + 1;
        }
    }
</script>

<input type="text" {value} on:input={handle} />

You don't need to use bind:value to make an input accept a value. bind:value makes this a two way binding. What you want is to split this up into one-way + event. The above code does this and I'm able to adjust the value the way I want, without having to resort to evt.target.value = ... That's why I asked if that tutorial chapter wasn't clear about this, but I guess it wasn't. Maybe that tutorial would benefit from having the initial code contain the code to synchronize the input with the variable using on:input.

ITenthusiasm commented 2 years ago

Yeah some additional expounding like what you mentioned could be useful.

Regarding your example, my only concern is situations where a person wants to make sure that the value doesn't change. That is, the input is "blocked".

In React/Vue, maybe you'd keep the first if block (or a few). But for the case of the value not changing, you would just do nothing.

If I understand correctly (maybe I don't), if the developer does nothing in Svelte, then the value is automatically set to event.target.value, right? So doing nothing is not an option. To prevent this default behavior, the developer would basically need a way to assign the value to itself, but that doesn't do anything because the value doesn't change.

I've seen someone do value = String(value), but it's very odd to do this, and it wastes space (though not much). And the reasoning will not be readily apparent to a typical developer. (Force-updates are rarely ever apparent in code, as far as I've seen.) Mutating event.target.value back to value avoids creating new values unnecessarily. But I suppose someone else could also consider it odd.

ITenthusiasm commented 2 years ago

@dummdidumm Honestly, the discussion on #6998 and the related Twitter thread make me a little more nervous about this kinda stuff... especially since this topic is even tricky for well-known Svelte supporters. (When it's that tricky, it's really clear that documentation is important.)

To be fair, the inconsistency that occurs when placing bind:value beforehand across browsers (in #6998) is worth addressing in its own right as a bug. But that aside, this is all a sign that people need to know the significance of placing a bind:value after an event handler. And if I understand correctly, the need to mutate event.target.value back to value when the desire is to keep the input "frozen"/unchanged for invalid inputs still remains.

The tutorials would greatly benefit from this, since the significance of #2446 is not immediately apparent, nor is it easily searchable in Google -- meaning people will start thinking there are bugs that don't really exist. Input masking/formatting is a perfect candidate to explain this odd scenario, as it's a common use case that already seems to be tripping people up. And it could easily go in one of the sections I previously mentioned. Thoughts?

7nik commented 2 years ago

What's wrong with using the reactive block to correct the value? https://svelte.dev/repl/282c70030a864507852a2769639d4043?version=3.44.2

If you don't want to overwrite the user's value, you can define two variables, e.g., storedValue and displayedValue, and use the reactive block to update storedValue with either valid value of displayedValue or null.

ITenthusiasm commented 2 years ago

@7nik Two Things

1) The main problem is again intuition. Anyone coming from any frontend framework -- even perhaps plain HTML and JS -- would expect all the value-related changes to happen within the event handler. Reactive blocks are not intuitive at all in this regard. And they split up the sections that a developer has to look across to understand what the code is doing.

2) By the time the reactive block is reached, it may be impossible to attain the original value in order to prevent a change to an input's value. In other words, the reactive block won't always work. As for managing 2 variables, I tried that once and that isn't very intuitive either.

What confuses and surprises me so much is that in regular HTML + JS, the user would have to mutate event.target.value. And a similar approach to this is necessary as well if actions are used. Svelte, from what I can tell, tries to avoid diverging too much from plain HTML + JS. In that case, it makes the most sense to either use actions or take the approach I recommended: adjusting event.target.value.

I may also have to clarify the problem statement. I tried to keep it shorter in order to reduce the amount of reading people have to do. But historically on the Discord, I've had to explain more specific details as the conversation has gone on. But no one has provided a greater solution for this use case than mutating event.target.value from what I've seen so far. Kev suggested actions, which was really good. But as far as clear docs/tutorials are concerned, I think people will need something lower level.

7nik commented 2 years ago

The binding is just a way to synchronize a variable and an element's property. It isn't designed for wedging in a logic "to format the input or something". Assigning to element.value doesn't produce any events, so no one will know that you've changed it until you dispatch an event. Thus you get weird and buggy behavior.

If you need more than just synchronize a variable and a property - don't use binding, do it yourself with whatever additional logic you need. And then wrap all the logic into a separate component so you'll have something like <CardNumber bind:value /> where value will contain either a valid card number or nothing.

Another way I see is using action to validate and synchronize the field, but with storage passed as a parameter to the action.

However, it'd be nice if bind: could accept additional parameters, e.g., data validation and conversion functions. Because recently, I have to deal with the datatime field and it returns a string while I want to work with a Date object. So I had to have two variables: one for string value and another for date object and use reactive blocks to synchronize them.

ITenthusiasm commented 2 years ago

The binding is just a way to synchronize a variable and an element's property. It isn't designed for wedging in a logic "to format the input or something".

I'm not sure this is 100% true. But it's fair to say that people shouldn't need to create state variables [that they aren't using elsewhere/otherwise] every time they want to format an input [in the context of a general page or section of a page].

Even so, the issue isn't just trying to approach formatting strictly by using state variables. The issue is when an input needs to be formatted and the value needs to be synchronized and the input needs to remain "unchanged" when something invalid was entered (whether "unchanged" means literally unchanged, as above, or it means the value was coerced via regex, etc. to a new value that we're hoping the regex, etc. properly determined to be the previous state of the input).

To be fair, even outside that use case, it's impossible to [easily] prevent an input from changing if the correct format is not adhered to because an event handler has no knowledge of what the previous state of the input was by itself. Things like regex are an option, but you have to be incredibly clever and specific with how you employ it as the use case gets more complex (like with money, which seems harder than card numbers). And even then, it's possible to run into bugs. With state variables, you're guaranteed that the input's value did not change at all, and with minimal effort.

And then wrap all the logic into a separate component

I'd rather avoid using components because it complicates/restricts styling due to scoping. An action (as was mentioned) would play more nicely I think.

Assigning to element.value doesn't produce any events, so no one will know that you've changed it until you dispatch an event. Thus you get weird and buggy behavior.

From what I understand, the behavior I suggested isn't buggy at all. An event has already been produced in the scenario I gave, and that event is exactly what will cause the element to update. When bind is used, whatever comes out of event.target.value (from the event handler) will always update the state variable if the state variable itself wasn't already updated.

Another way I see is using action to validate and synchronize the field, but with storage passed as a parameter to the action.

Yes. Dig actions. I'm using them in my app. And from what I can tell, you don't need to pass in the second parameter for storage if you order directives properly (#2446). They're great for re-use, and they don't create the component issue I mentioned earlier. The problem again comes back to intuition, and how easily a user would find the help in a tutorial. I spent a really long time trying to figure out how to format money inputs (inputs in generally, really). I did see a public component. But personally it seems too convoluted. The main point of this issue is that people need a clear way to do validation + formatting.

Though, if actions seem like a better place to put the approach, that's also fine.

ITenthusiasm commented 2 years ago

Mkay. Circling back around to this after exploring a few options in my own projects. I think this is still an option for sure... But I probably disagree with it now. There is a different "purist" option that can be used instead... though the easiest way to make it re-usable is yet again through actions. (However, someone could just as will put this in the <script> tag without the wrapping action.)

// formatAction.ts

function formatAction(input: HTMLInputElement) {
  let lastValidValue: string;

  function handleBeforeInput(event: InputEvent & { target: HTMLInputElement }) {
    lastValidValue = event.target.value;
  }

  function handleInput(event: InputEvent & { target: HTMLInputElement }) {
    const { value, selectionStart } = event.target;

    if (/* Value does not match required formatting */) {
      event.target.value = lastValidValue;
      const cursorPlace = selectionStart - (value.length - event.target.value.length);
      requestAnimationFrame(() => event.target.setSelectionRange(cursorPlace, cursorPlace));
      return;
    }

    lastValidValue = value;
  }

  input.addEventListener("beforeinput", handleBeforeInput as EventListener);
  input.addEventListener("input", handleInput as EventListener);

  return {
    destroy() {
      input.removeEventListener("beforeinput", handleBeforeInput as EventListener);
      input.removeEventListener("input", handleInput as EventListener);
    }
  };
}
<!-- Somewhere in a Svelte file -->
<input type="text" use:formatAction />

Surely there had to have been a way to format (and restrict) inputs before frontend frameworks started popping off. And I imagine they looked something like this.

This approach seems a bit more verbose? But I'm assuming (?) that since it doesn't involve state, this won't cause any unnecessary re-renders. Additionally, as @7nik was hinting at, it's more re-usable because it doesn't depend on state. This can be slapped directly into any input while allowing the consumer to customize the HTML element. Or both of the handlers can be placed on a single input element while the lastValidValue is managed internally in a <script> tag without ever being exposed to the template.


Maybe some explanation on formatting would be better handled in the actions section after all? But I'm also wondering if it's just a matter of knowing pure HTML and JS better at this point, since it could be done without state. (Which would hopefully improve performance, re-usability, and readability?)

ITenthusiasm commented 2 years ago

I think it easily gets missed, so I just want to add the reminder that the key struggle here is how to prevent invalid inputs from the user. Altering inputs is very easy. Prevention (which has valid uses cases across several websites) requires more effort and can't be accomplished through this method.

Some mutation of event.target.value will inevitably be necessary for this use case. But it's very straightforward and isn't really a problem.

7nik commented 2 years ago

Using the beforeinput event can be an excellent way to validate and format inputs. The only headache is to get the next value. Here is what I got: https://svelte.dev/repl/f45d38ec017749ddbcb86310d1102f82?version=3.46.4