├── .babelrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── jest.config.js ├── logo.svg ├── package.json ├── rollup.config.js ├── src ├── Form.svelte ├── actions │ ├── actions.js │ └── form │ │ └── getValues.js ├── index.js ├── selectTextOnFocus.js ├── tests │ ├── Form.spec.js │ ├── TestWrapper.svelte │ └── utils │ │ ├── actionWithUpdate.js │ │ └── testAction.js └── utils │ └── serialize.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ] 12 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist/ 4 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | }; -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { default as default } from './Form.svelte'; 2 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/tests/TestWrapper.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 | 17 | 18 |{JSON.stringify(values)}19 | 20 | 21 | -------------------------------------------------------------------------------- /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/tests/utils/testAction.js: -------------------------------------------------------------------------------- 1 | export function testAction(node, options = "blue") { 2 | node.parentNode.style.setProperty('background', options) 3 | } -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------