├── .gitignore ├── src ├── index.js ├── tests │ ├── utils │ │ ├── testAction.js │ │ └── actionWithUpdate.js │ ├── TestWrapper.svelte │ └── Form.spec.js ├── selectTextOnFocus.js ├── Form.svelte ├── actions │ ├── actions.js │ └── form │ │ └── getValues.js └── utils │ └── serialize.js ├── .npmignore ├── .travis.yml ├── .babelrc ├── jest.config.js ├── CHANGELOG.md ├── rollup.config.js ├── package.json ├── README.md └── logo.svg /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist/ 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as default } from './Form.svelte'; 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .babelrc 2 | jest.config.js 3 | **__tests__** 4 | .travis.yml -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: "10" 3 | install: npm install 4 | script: npm run test -------------------------------------------------------------------------------- /src/tests/utils/testAction.js: -------------------------------------------------------------------------------- 1 | export function testAction(node, options = "blue") { 2 | node.parentNode.style.setProperty('background', options) 3 | } -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /src/tests/utils/actionWithUpdate.js: -------------------------------------------------------------------------------- 1 | export function actionWithUpdate(node, options = "blue") { 2 | node.parentNode.style.setProperty('background', options) 3 | 4 | return { 5 | update(options) { 6 | node.parentNode.style.setProperty('background', options) 7 | } 8 | } 9 | } -------------------------------------------------------------------------------- /src/selectTextOnFocus.js: -------------------------------------------------------------------------------- 1 | export function selectTextOnFocus(node) { 2 | 3 | const handleFocus = event => { 4 | node && typeof node.select === 'function' && node.select() 5 | } 6 | 7 | node.addEventListener('focus', handleFocus) 8 | 9 | return { 10 | destroy() { 11 | node.removeEventListener('focus', handleFocus) 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/Form.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 |
16 | -------------------------------------------------------------------------------- /src/actions/actions.js: -------------------------------------------------------------------------------- 1 | export function useActions(node, actions = []) { 2 | let cleanUpFunctions = [] 3 | 4 | // Apply each action 5 | actions.forEach(([action, options]) => { 6 | 7 | const { destroy } = action(node, options) || {}; 8 | destroy && cleanupFunction.push(destroy); 9 | }) 10 | 11 | return { 12 | destroy() { 13 | cleanUpFunctions.forEach(destroy => destroy()) 14 | } 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | // For a detailed explanation regarding each configuration property, visit: 2 | // https://jestjs.io/docs/en/configuration.html 3 | 4 | module.exports = { 5 | transform: { 6 | "^.+\\.js$": "babel-jest", 7 | "^.+\\.svelte$": "svelte-jester" 8 | }, 9 | moduleFileExtensions: [ 10 | 'js', 11 | 'svelte' 12 | ], 13 | setupFilesAfterEnv: [ 14 | '@testing-library/jest-dom/extend-expect' 15 | ] 16 | }; -------------------------------------------------------------------------------- /src/tests/TestWrapper.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 18 |{JSON.stringify(values)}
19 |
20 |
21 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [Unreleased]
2 |
3 | ## [0.7.0] - 2020-05-20
4 |
5 | ### New Features
6 |
7 | - onSubmit event is now forwarded to the parent.
8 | - Switched to another mode of getting the input nodes. 13% speed increase.
9 |
10 | ## [0.6.0] - 2020-05-06
11 |
12 | ### New Features
13 |
14 | - Now possible to supply an array of actions to the `Form` component.
15 |
16 | ## [0.5.1] - 2020-05-06
17 |
18 | ### Fixed
19 |
20 | - Fixes a multiple select bug that would deselect things after trying to select more than 2 items.
--------------------------------------------------------------------------------
/rollup.config.js:
--------------------------------------------------------------------------------
1 | import svelte from 'rollup-plugin-svelte';
2 | import resolve from '@rollup/plugin-node-resolve';
3 | import pkg from './package.json';
4 |
5 | const name = pkg.name
6 | .replace(/^(@\S+\/)?(svelte-)?(\S+)/, '$3')
7 | .replace(/^\w/, m => m.toUpperCase())
8 | .replace(/-\w/g, m => m[1].toUpperCase());
9 |
10 | export default {
11 | input: 'src/index.js',
12 | output: [
13 | { file: pkg.module, 'format': 'es' },
14 | { file: pkg.main, 'format': 'umd', name }
15 | ],
16 | plugins: [
17 | svelte(),
18 | resolve()
19 | ]
20 | };
21 |
--------------------------------------------------------------------------------
/src/actions/form/getValues.js:
--------------------------------------------------------------------------------
1 | import { serialize, deserialize } from '../../utils/serialize'
2 |
3 | export function getValues(node) {
4 | let initialUpdateDone = 0
5 |
6 | const inputs = [...node.getElementsByTagName('input')]
7 |
8 | inputs.forEach(el => {
9 | el.oninput = node.onchange
10 | })
11 |
12 | node.addEventListener('input', handleUpdate)
13 |
14 | function handleUpdate() {
15 | node.dispatchEvent(new CustomEvent('update', {
16 | detail: { ...serialize(node) }
17 | }));
18 | }
19 |
20 | handleUpdate()
21 |
22 | return {
23 | update(values) {
24 | if (initialUpdateDone === 2) {
25 | deserialize(node, values)
26 | }
27 | else {
28 | initialUpdateDone += 1;
29 | }
30 | },
31 | destroy() {
32 | node.removeEventListener('input', handleUpdate)
33 | }
34 | };
35 | }
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@svelteschool/svelte-forms",
3 | "description": "A simple form component that doesn't care about what you put in it. It just works.",
4 | "version": "0.7.0",
5 | "author": {
6 | "name": "Svelte School",
7 | "email": "svelteschool@kevmodro.me"
8 | },
9 | "svelte": "src/index.js",
10 | "module": "dist/index.mjs",
11 | "main": "dist/index.js",
12 | "scripts": {
13 | "build": "rollup -c",
14 | "test": "jest src",
15 | "release": "np --no-yarn"
16 | },
17 | "devDependencies": {
18 | "@babel/core": "^7.9.0",
19 | "@babel/preset-env": "^7.9.0",
20 | "@rollup/plugin-node-resolve": "^6.0.0",
21 | "@testing-library/jest-dom": "^5.3.0",
22 | "@testing-library/svelte": "^3.0.0",
23 | "@testing-library/user-event": "^10.0.1",
24 | "babel-jest": "^25.2.6",
25 | "husky": "^4.2.3",
26 | "jest": "^25.2.6",
27 | "np": "^6.2.0",
28 | "rollup": "^1.20.0",
29 | "rollup-plugin-svelte": "^5.0.0",
30 | "svelte": "^3.0.0",
31 | "svelte-jester": "^1.0.5"
32 | },
33 | "husky": {
34 | "hooks": {
35 | "pre-commit": "npm test"
36 | }
37 | },
38 | "keywords": [
39 | "svelte",
40 | "forms",
41 | "svelte-forms",
42 | "svelte-component"
43 | ],
44 | "files": [
45 | "src",
46 | "dist"
47 | ],
48 | "repository": {
49 | "type": "git",
50 | "url": "git+https://github.com/svelteschool/svelte-forms.git"
51 | },
52 | "homepage": "https://github.com/svelteschool/svelte-forms/"
53 | }
--------------------------------------------------------------------------------
/src/utils/serialize.js:
--------------------------------------------------------------------------------
1 | export function serialize(form) {
2 | const response = {};
3 |
4 | [...form.elements].forEach(function elements(input, _index) {
5 | // I know this "switch (true)" isn't beautiful, but it works!!!
6 | switch (true) {
7 | case !input.name:
8 | case input.disabled:
9 | case /(file|reset|submit|button)/i.test(input.type):
10 | break;
11 | case /(select-multiple)/i.test(input.type):
12 | response[input.name] = [];
13 | [...input.options].forEach(function options(option, _selectIndex) {
14 | if (option.selected) {
15 | response[input.name].push(option.value);
16 | }
17 | });
18 | break;
19 | case /(radio)/i.test(input.type):
20 | if (input.checked) {
21 | response[input.name] = input.value;
22 | }
23 | break;
24 | case /(checkbox)/i.test(input.type):
25 | if (input.checked) {
26 | response[input.name] = [...(response[input.name] || []), input.value];
27 | }
28 | break;
29 | default:
30 | if (input.value) {
31 | response[input.name] = input.value;
32 | }
33 | break;
34 | }
35 | });
36 | return response;
37 | }
38 |
39 | export function deserialize(form, values) {
40 | [...form.elements].forEach(function elements(input, _index) {
41 | // I know this "switch (true)" isn't beautiful, but it works!!!
42 | switch (true) {
43 | case !input.name:
44 | case input.disabled:
45 | case /(file|reset|submit|button)/i.test(input.type):
46 | break;
47 | case /(select-multiple)/i.test(input.type):
48 | [...input.options].forEach(function options(option, _selectIndex) {
49 | option.selected =
50 | values[input.name] && values[input.name].includes(option.value);
51 | });
52 | break;
53 | case /(radio)/i.test(input.type):
54 | input.checked =
55 | values[input.name] && values[input.name] === input.value;
56 | break;
57 | case /(checkbox)/i.test(input.type):
58 | input.checked =
59 | values[input.name] && values[input.name].includes(input.value);
60 | break;
61 | default:
62 | input.value = values[input.name] || "";
63 | break;
64 | }
65 | });
66 | }
67 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
12 |
13 | A no-fuss [Svelte](https://svelte.dev/) form component that just works.
14 |
15 | - **Plug'n'Play**. Input elements in, values out.
16 | - Works just like a normal form. Except it does all the tedious work for you.
17 | - **Extendable**. Work with most inputs and custom input components out of the box.
18 | - **Two-Way Binding**. Svelte-forms is two-way bound by default. Change a value in your object, and it changes in your inputs.
19 | - ~~**A toolbox of actions** to apply to your elements: **Validate**, **FocusOnSelect**, **Numbers**, **TextareaAutoRezie**, and many more.~~ (Soon!)
20 |
21 | [**Try it out on the Svelte REPL!**](https://svelte.dev/repl/ddc56a9e9f9c4289bbe714c6dd48989d?version=3.20.1)
22 |
23 | ## Usage
24 |
25 | Simply bind to the components values property:
26 |
27 | Using built-in HTML input elements:
28 | ```html
29 |
33 |
34 |
44 | ```
45 |
46 | Here's how the values object would be structured in the above case:
47 |
48 | ```js
49 | {
50 | firstName: 'Svelte',
51 | lastName: 'School'
52 | }
53 | ```
54 |
55 | Inputs that do not have a `name` property or are `disabled` will not show up in the object.
56 |
57 | __File inputs are not supported.__
58 |
59 | ## Props
60 |
61 | prop name | type | default
62 | ---------------------|---------------------------|-------------------------
63 | `actions` | `[[action, actionProp]]` | `[]`
64 |
65 | ### `actions`
66 | The actions prop takes an array of [action, options]. The `action` is applied to the form element using the `options` just like it would be if you manually applied it to an element: `use:action={options}`.
67 |
68 | ## Validation
69 |
70 | Handling form validation is pretty straight forward in Svelte using this library, you'd pick your preferred validation library (Yup for example) and just do a reactive statement like so: $: validity = validateForm(values) where validateForm is a function that does just that.
71 |
72 | ## Installing
73 |
74 | Simple. Install it using `yarn` or `npm`.
75 | ```
76 | yarn add @svelteschool/svelte-forms
77 |
78 | npm install @svelteschool/svelte-forms
79 | ```
80 |
81 | If you're using Sapper, make sure to install it as a dev dependency:
82 | ```
83 | yarn add -D @svelteschool/svelte-forms
84 | ```
85 |
86 | ## Running the tests
87 |
88 | Run tests by running the test script:
89 | ```
90 | yarn test
91 | ```
92 |
93 | ## Contribute
94 |
95 | If you are interested in contributing you are welcome to open PRs. Please make sure all tests pass and if you add functionality, add your own tests.
96 |
97 |
98 | ## Authors
99 |
100 | * **Svelte School** - [Svelte School](https://github.com/svelteschool)
101 | * **Kevin Åberg Kultalahti** - [kevmodrome](https://github.com/kevmodrome)
102 |
103 | ## License
104 |
105 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details
106 |
107 | ## Acknowledgments
108 |
109 | * Inspired by [lukeed](https://github.com/lukeed) and his [formee](https://github.com/lukeed/formee) library.
110 |
--------------------------------------------------------------------------------
/logo.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/src/tests/Form.spec.js:
--------------------------------------------------------------------------------
1 | import "@testing-library/jest-dom/extend-expect";
2 | import {
3 | getByPlaceholderText,
4 | getByTestId,
5 | waitFor,
6 | } from "@testing-library/dom";
7 | import userEvent from "@testing-library/user-event";
8 | import { render } from "@testing-library/svelte";
9 | import TestWrapper from "./TestWrapper.svelte";
10 |
11 | // Things needed in the tests
12 | import { testAction } from "./utils/testAction.js";
13 |
14 | function renderForm(props) {
15 | const container = render(TestWrapper, props);
16 | return container.container.firstChild;
17 | }
18 |
19 | describe("has correct values", () => {
20 | test("default values", async () => {
21 | const container = renderForm({
22 | html: ``,
23 | });
24 | const pre = getByTestId(container, "values");
25 | expect(pre).toHaveTextContent("From Test");
26 | });
27 | test("one input", async () => {
28 | const container = renderForm({
29 | html: ``,
30 | });
31 | const input = getByPlaceholderText(container, "Enter name");
32 | userEvent.type(input, "Kevin");
33 | const pre = getByTestId(container, "values");
34 | await waitFor(() => {
35 | expect(pre).toHaveTextContent("Kevin");
36 | });
37 | });
38 | test("checkbox inputs", async () => {
39 | const container = renderForm({
40 | html: `
41 |
42 |
43 | `,
44 | });
45 | const input = getByTestId(container, "m2");
46 | userEvent.click(input);
47 | const pre = getByTestId(container, "values");
48 | await waitFor(() => {
49 | expect(pre).toHaveTextContent('{"movies":["Home Alone"]}');
50 | });
51 | });
52 | test("radio button inputs", async () => {
53 | const container = renderForm({
54 | html: `
55 |
56 |
57 | `,
58 | });
59 | const input = getByTestId(container, "g2");
60 | userEvent.click(input);
61 | const pre = getByTestId(container, "values");
62 | await waitFor(() => {
63 | expect(pre).toHaveTextContent('{"gender":"F"}');
64 | });
65 | });
66 | test("multi-select input", async () => {
67 | const container = renderForm({
68 | html: `
69 |
70 |
78 | `,
79 | });
80 | const select = getByTestId(container, "p1");
81 | userEvent.selectOptions(select, ["dog", "hamster"]);
82 | // TODO: Fix this
83 | // userEvent.selectOptions doesn't trigger the update
84 | // A checkbox was included just to trigger the update with the click below.
85 | userEvent.click(getByTestId(container, "m1"));
86 | const pre = getByTestId(container, "values");
87 | await waitFor(() => {
88 | expect(pre).toHaveTextContent(
89 | '{"movies":["Space Jam"],"pets":["dog","hamster"]}'
90 | );
91 | });
92 | });
93 | });
94 |
95 | describe("handles reactivity", () => {
96 | test("one input", async () => {
97 | const container = renderForm({
98 | html: ``,
99 | input: { name: "Rodrigo" },
100 | });
101 | userEvent.click(getByTestId(container, "b1"));
102 |
103 | const pre = getByTestId(container, "t1");
104 | await waitFor(() => {
105 | expect(pre.value).toEqual("Rodrigo");
106 | });
107 | });
108 | test("checkbox inputs", async () => {
109 | const container = renderForm({
110 | html: `
111 |
112 |
113 | `,
114 | input: { movies: ["Home Alone"] },
115 | });
116 | userEvent.click(getByTestId(container, "b1"));
117 | await waitFor(() => {
118 | expect(getByTestId(container, "m1").checked).toBe(false);
119 | expect(getByTestId(container, "m2").checked).toBe(true);
120 | });
121 | });
122 | test("radio button", async () => {
123 | const container = renderForm({
124 | html: `
125 |
126 |
127 | `,
128 | input: { gender: "M" },
129 | });
130 | userEvent.click(getByTestId(container, "b1"));
131 | await waitFor(() => {
132 | expect(getByTestId(container, "g1").checked).toBe(true);
133 | expect(getByTestId(container, "g2").checked).toBe(false);
134 | });
135 | });
136 | test("multi-select input", async () => {
137 | const container = renderForm({
138 | html: `
139 |
140 |
148 | `,
149 | input: { pets: ["dog", "hamster"] },
150 | });
151 | userEvent.click(getByTestId(container, "b1"));
152 | await waitFor(() => {
153 | expect(getByTestId(container, "o1").selected).toBe(true);
154 | expect(getByTestId(container, "o2").selected).toBe(false);
155 | expect(getByTestId(container, "o3").selected).toBe(true);
156 | expect(getByTestId(container, "o4").selected).toBe(false);
157 | expect(getByTestId(container, "o5").selected).toBe(false);
158 | expect(getByTestId(container, "o6").selected).toBe(false);
159 | });
160 | });
161 | });
162 |
163 | describe("can handle supplied actions", () => {
164 | test("one action with props", async () => {
165 | const container = renderForm({
166 | actions: [[testAction, "brown"]]
167 | });
168 |
169 | await waitFor(() => {
170 | expect(container.style.getPropertyValue('background')).toEqual("brown");
171 | });
172 | });
173 | test("one action without props", async () => {
174 | const container = renderForm({
175 | actions: [[testAction]]
176 | });
177 |
178 | await waitFor(() => {
179 | expect(container.style.getPropertyValue('background')).toEqual("blue");
180 | });
181 | });
182 | });
183 |
--------------------------------------------------------------------------------