shoelace-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/main.yml +85 -0
  3. data/.gitignore +20 -0
  4. data/Appraisals +25 -0
  5. data/CHANGELOG.md +3 -0
  6. data/CODE_OF_CONDUCT.md +84 -0
  7. data/Gemfile +11 -0
  8. data/LICENSE.txt +21 -0
  9. data/README.md +178 -0
  10. data/Rakefile +27 -0
  11. data/app/helpers/shoelace/form_helper.rb +451 -0
  12. data/bin/console +15 -0
  13. data/bin/setup +8 -0
  14. data/dist/.keep +0 -0
  15. data/dist/types/.keep +0 -0
  16. data/gemfiles/rails_50.gemfile +12 -0
  17. data/gemfiles/rails_51.gemfile +11 -0
  18. data/gemfiles/rails_52.gemfile +11 -0
  19. data/gemfiles/rails_60.gemfile +11 -0
  20. data/gemfiles/rails_61.gemfile +11 -0
  21. data/gemfiles/rails_70.gemfile +11 -0
  22. data/gemfiles/rails_edge.gemfile +14 -0
  23. data/lib/shoelace/engine.rb +8 -0
  24. data/lib/shoelace/rails/version.rb +7 -0
  25. data/lib/shoelace/rails.rb +10 -0
  26. data/lib/shoelace/testing.rb +40 -0
  27. data/package.json +50 -0
  28. data/rollup.config.js +49 -0
  29. data/shoelace-rails.gemspec +35 -0
  30. data/src/index.ts +2 -0
  31. data/src/turbo/index.ts +6 -0
  32. data/src/turbo/polyfills/formdata-event.js +27 -0
  33. data/src/turbo/sl-turbo-form.ts +110 -0
  34. data/src/turbolinks/features/confirm.ts +42 -0
  35. data/src/turbolinks/features/disable.ts +94 -0
  36. data/src/turbolinks/features/remote.ts +107 -0
  37. data/src/turbolinks/index.ts +6 -0
  38. data/src/turbolinks/selectors.ts +38 -0
  39. data/src/turbolinks/start.ts +38 -0
  40. data/src/turbolinks/turbolinks.ts +78 -0
  41. data/src/turbolinks/utils/ajax.ts +146 -0
  42. data/src/turbolinks/utils/csp.ts +20 -0
  43. data/src/turbolinks/utils/csrf.ts +33 -0
  44. data/src/turbolinks/utils/dom.ts +40 -0
  45. data/src/turbolinks/utils/event.ts +57 -0
  46. data/src/turbolinks/utils/form.ts +58 -0
  47. data/test/dummy_app/Gemfile +19 -0
  48. data/test/dummy_app/Rakefile +6 -0
  49. data/test/dummy_app/app/controllers/hotwire_forms_controller.rb +46 -0
  50. data/test/dummy_app/app/controllers/turbolinks_forms_controller.rb +37 -0
  51. data/test/dummy_app/app/models/user.rb +16 -0
  52. data/test/dummy_app/app/packs/entrypoints/hotwire.js +1 -0
  53. data/test/dummy_app/app/packs/entrypoints/turbolinks.js +5 -0
  54. data/test/dummy_app/app/views/hotwire_forms/form.html.erb +45 -0
  55. data/test/dummy_app/app/views/hotwire_forms/show.html.erb +5 -0
  56. data/test/dummy_app/app/views/layouts/application.html.erb +39 -0
  57. data/test/dummy_app/app/views/turbolinks_forms/form.html.erb +44 -0
  58. data/test/dummy_app/app/views/turbolinks_forms/show.html.erb +5 -0
  59. data/test/dummy_app/bin/rails +5 -0
  60. data/test/dummy_app/bin/webpack +18 -0
  61. data/test/dummy_app/bin/yarn +18 -0
  62. data/test/dummy_app/config/application.rb +16 -0
  63. data/test/dummy_app/config/boot.rb +4 -0
  64. data/test/dummy_app/config/environment.rb +2 -0
  65. data/test/dummy_app/config/environments/development.rb +10 -0
  66. data/test/dummy_app/config/environments/test.rb +18 -0
  67. data/test/dummy_app/config/routes.rb +4 -0
  68. data/test/dummy_app/config/webpack/development.js +5 -0
  69. data/test/dummy_app/config/webpack/production.js +1 -0
  70. data/test/dummy_app/config/webpack/test.js +5 -0
  71. data/test/dummy_app/config/webpacker.yml +33 -0
  72. data/test/dummy_app/config.ru +6 -0
  73. data/test/dummy_app/package.json +24 -0
  74. data/test/dummy_app/test/system/hotwire_form_test.rb +65 -0
  75. data/test/dummy_app/test/system/turbolinks_form_test.rb +39 -0
  76. data/test/dummy_app/test/test_helper.rb +68 -0
  77. data/test/helpers/form_helper_test.rb +397 -0
  78. data/test/test_helper.rb +18 -0
  79. data/tsconfig.json +19 -0
  80. data/yarn.lock +249 -0
  81. metadata +196 -0
@@ -0,0 +1,78 @@
1
+ import { Snapshot, SnapshotRenderer, ErrorRenderer, uuid } from "turbolinks"
2
+ import { formSubmitSelector } from "./selectors"
3
+ import { matches } from "./utils/dom"
4
+ import { delegate } from "./utils/event"
5
+
6
+ const nullCallback = function () {}
7
+ const nullDelegate = {
8
+ viewInvalidated: nullCallback,
9
+ viewWillRender: nullCallback,
10
+ viewRendered: nullCallback,
11
+ }
12
+
13
+ const renderWithTurbolinks = (htmlContent) => {
14
+ const currentSnapshot = Snapshot.fromHTMLElement(document.documentElement)
15
+ const newSnapshot = Snapshot.fromHTMLString(htmlContent)
16
+ let renderer = new SnapshotRenderer(currentSnapshot, newSnapshot, false)
17
+
18
+ if (!renderer.shouldRender()) {
19
+ renderer = new ErrorRenderer(htmlContent)
20
+ }
21
+
22
+ renderer.delegate = nullDelegate
23
+ renderer.render(nullCallback)
24
+ }
25
+
26
+ const findActiveElement = (shadowRoot: ShadowRoot) => {
27
+ let el = shadowRoot.activeElement
28
+
29
+ while (el && el.shadowRoot && el.shadowRoot.activeElement) {
30
+ el = el.shadowRoot.activeElement
31
+ }
32
+
33
+ return el
34
+ }
35
+
36
+ export const addShadowDomSupportToTurbolinks = (turbolinksController) => {
37
+ // From https://github.com/turbolinks/turbolinks/blob/71b7a7d0546a573735af99113b622180e8a813c2/src/util.ts#L9
38
+ // © 2019 Basecamp, LLC.
39
+ const originalClosest: typeof turbolinksController.closest = turbolinksController.closest
40
+
41
+ turbolinksController.closest = (node, selector) => {
42
+ if (!!node.shadowRoot) {
43
+ const rootActiveElement = findActiveElement(node.shadowRoot) || node
44
+
45
+ if (matches(rootActiveElement, selector)) {
46
+ return rootActiveElement
47
+ }
48
+ } else {
49
+ return originalClosest(node, selector)
50
+ }
51
+ }
52
+ }
53
+
54
+ // Turbolinks does not automatically handle form responses. This creates a handler that does it.
55
+ // From https://github.com/turbolinks/turbolinks/issues/85#issuecomment-299617076
56
+ export const handleResponse = (turbolinksInstance) => {
57
+ return (event: CustomEvent<[XMLHttpRequest, string]>) => {
58
+ const [xhr, _status] = event.detail
59
+
60
+ if (xhr.getResponseHeader("Content-Type").startsWith("text/html")) {
61
+ turbolinksInstance.restorationIdentifier = uuid()
62
+ turbolinksInstance.clearCache()
63
+ turbolinksInstance.dispatch("turbolinks:before-cache")
64
+ turbolinksInstance.controller.pushHistoryWithLocationAndRestorationIdentifier(
65
+ xhr.responseURL,
66
+ turbolinksInstance.restorationIdentifier
67
+ )
68
+ renderWithTurbolinks(xhr.responseText)
69
+ window.scroll(0, 0)
70
+ turbolinksInstance.dispatch("turbolinks:load")
71
+ }
72
+ }
73
+ }
74
+
75
+ export const startTurbolinks = (turbolinksInstance) => {
76
+ delegate(document, formSubmitSelector, "ajax:complete", handleResponse(turbolinksInstance))
77
+ addShadowDomSupportToTurbolinks(turbolinksInstance)
78
+ }
@@ -0,0 +1,146 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ import { cspNonce } from "./csp"
4
+ import { CSRFProtection } from "./csrf"
5
+
6
+ const AcceptHeaders = {
7
+ "*": "*/*",
8
+ text: "text/plain",
9
+ html: "text/html",
10
+ xml: "application/xml, text/xml",
11
+ json: "application/json, text/javascript",
12
+ script: "text/javascript, application/javascript, application/ecmascript, application/x-ecmascript",
13
+ }
14
+
15
+ export const ajax = (options) => {
16
+ options = prepareOptions(options)
17
+ var xhr = createXHR(options, function () {
18
+ const response = processResponse(
19
+ xhr.response != null ? xhr.response : xhr.responseText,
20
+ xhr.getResponseHeader("Content-Type")
21
+ )
22
+
23
+ if (Math.floor(xhr.status / 100) === 2) {
24
+ if (typeof options.success === "function") {
25
+ options.success(response, xhr.statusText, xhr)
26
+ }
27
+ } else {
28
+ if (typeof options.error === "function") {
29
+ options.error(response, xhr.statusText, xhr)
30
+ }
31
+ }
32
+ return typeof options.complete === "function" ? options.complete(xhr, xhr.statusText) : undefined
33
+ })
34
+
35
+ if (options.beforeSend != null && !options.beforeSend(xhr, options)) {
36
+ return false
37
+ }
38
+
39
+ if (xhr.readyState === XMLHttpRequest.OPENED) {
40
+ return xhr.send(options.data)
41
+ }
42
+ }
43
+
44
+ const prepareOptions = (options) => {
45
+ options.url = options.url || location.href
46
+ options.type = options.type.toUpperCase()
47
+ // append data to url if it's a GET request
48
+ if (options.type === "GET" && options.data) {
49
+ if (options.url.indexOf("?") < 0) {
50
+ options.url += "?" + options.data
51
+ } else {
52
+ options.url += "&" + options.data
53
+ }
54
+ }
55
+
56
+ // Use "*" as default dataType
57
+ if (AcceptHeaders[options.dataType] == null) {
58
+ options.dataType = "*"
59
+ }
60
+
61
+ options.accept = AcceptHeaders[options.dataType]
62
+ if (options.dataType !== "*") {
63
+ options.accept += ", */*; q=0.01"
64
+ }
65
+
66
+ return options
67
+ }
68
+
69
+ const createXHR = (options, done) => {
70
+ const xhr = new XMLHttpRequest()
71
+
72
+ // Open and set up xhr
73
+ xhr.open(options.type, options.url, true)
74
+ xhr.setRequestHeader("Accept", options.accept)
75
+
76
+ // Set Content-Type only when sending a string
77
+ // Sending FormData will automatically set Content-Type to multipart/form-data
78
+ if (typeof options.data === "string") {
79
+ xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
80
+ }
81
+
82
+ if (!options.crossDomain) {
83
+ xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest")
84
+ CSRFProtection(xhr)
85
+ }
86
+
87
+ xhr.withCredentials = !!options.withCredentials
88
+ xhr.onreadystatechange = function () {
89
+ if (xhr.readyState === XMLHttpRequest.DONE) {
90
+ return done(xhr)
91
+ }
92
+ }
93
+ return xhr
94
+ }
95
+
96
+ const processResponse = (response, type) => {
97
+ if (typeof response === "string" && typeof type === "string") {
98
+ if (type.match(/\bjson\b/)) {
99
+ try {
100
+ response = JSON.parse(response)
101
+ } catch (error) {
102
+ // no-op...
103
+ }
104
+ } else if (type.match(/\b(?:java|ecma)script\b/)) {
105
+ const script = document.createElement("script")
106
+ script.setAttribute("nonce", cspNonce())
107
+ script.text = response
108
+ document.head.appendChild(script).parentNode.removeChild(script)
109
+ } else if (type.match(/\b(xml|html|svg)\b/)) {
110
+ const parser = new DOMParser()
111
+ type = type.replace(/;.+/, "") // remove something like ';charset=utf-8'
112
+
113
+ try {
114
+ response = parser.parseFromString(response, type)
115
+ } catch (error1) {}
116
+ }
117
+ }
118
+
119
+ return response
120
+ }
121
+
122
+ // Default way to get an element's href. May be overridden at Rails.href.
123
+ export const href = (element) => element.href
124
+
125
+ // Determines if the request is a cross domain request.
126
+ export const isCrossDomain = (url) => {
127
+ const originAnchor = document.createElement("a")
128
+ originAnchor.href = location.href
129
+ const urlAnchor = document.createElement("a")
130
+
131
+ try {
132
+ urlAnchor.href = url
133
+ // If URL protocol is false or is a string containing a single colon
134
+ // *and* host are false, assume it is not a cross-domain request
135
+ // (should only be the case for IE7 and IE compatibility mode).
136
+ // Otherwise, evaluate protocol and host of the URL against the origin
137
+ // protocol and host.
138
+ return !(
139
+ ((!urlAnchor.protocol || urlAnchor.protocol === ":") && !urlAnchor.host) ||
140
+ originAnchor.protocol + "//" + originAnchor.host === urlAnchor.protocol + "//" + urlAnchor.host
141
+ )
142
+ } catch (e) {
143
+ // If there is an error parsing the URL, assume it is crossDomain.
144
+ return true
145
+ }
146
+ }
@@ -0,0 +1,20 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ let nonce = null
4
+
5
+ const loadCSPNonce = () => {
6
+ if (nonce) {
7
+ return nonce
8
+ }
9
+
10
+ const cspMetaTag: HTMLMetaElement = document.querySelector("meta[name=csp-nonce]")
11
+
12
+ if (cspMetaTag) {
13
+ nonce = cspMetaTag.content
14
+ }
15
+
16
+ return nonce
17
+ }
18
+
19
+ // Returns the Content-Security-Policy nonce for inline scripts.
20
+ export const cspNonce = () => (nonce != null ? nonce : loadCSPNonce())
@@ -0,0 +1,33 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ const $ = (selector) => Array.prototype.slice.call(document.querySelectorAll(selector))
4
+
5
+ // Up-to-date Cross-Site Request Forgery token
6
+ export const csrfToken = () => {
7
+ const meta: HTMLMetaElement = document.querySelector("meta[name=csrf-token]")
8
+ return meta && meta.content
9
+ }
10
+
11
+ // URL param that must contain the CSRF token
12
+ export const csrfParam = () => {
13
+ const meta: HTMLMetaElement = document.querySelector("meta[name=csrf-param]")
14
+ return meta && meta.content
15
+ }
16
+
17
+ // Make sure that every Ajax request sends the CSRF token
18
+ export const CSRFProtection = (xhr) => {
19
+ const token = csrfToken()
20
+ if (token != null) {
21
+ return xhr.setRequestHeader("X-CSRF-Token", token)
22
+ }
23
+ }
24
+
25
+ // Make sure that all forms have actual up-to-date tokens (cached forms contain old ones)
26
+ export const refreshCSRFTokens = () => {
27
+ const token = csrfToken()
28
+ const param = csrfParam()
29
+
30
+ if (token != null && param != null) {
31
+ return $('sl-form input[name="' + param + '"]').forEach((input) => (input.value = token))
32
+ }
33
+ }
@@ -0,0 +1,40 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ const elementPrototype = Element.prototype as any
4
+
5
+ const m: (this: Element, selector: string) => boolean =
6
+ elementPrototype.matches ||
7
+ elementPrototype.matchesSelector ||
8
+ elementPrototype.mozMatchesSelector ||
9
+ elementPrototype.msMatchesSelector ||
10
+ elementPrototype.oMatchesSelector ||
11
+ elementPrototype.webkitMatchesSelector
12
+
13
+ // Checks if the given native dom element matches the selector
14
+ // element::
15
+ // native DOM element
16
+ // selector::
17
+ // CSS selector string or
18
+ // a JavaScript object with `selector` and `exclude` properties
19
+ // Examples: "form", { selector: "form", exclude: "form[data-remote='true']"}
20
+ export const matches = (element, selector) => {
21
+ if (selector.exclude != null) {
22
+ return m.call(element, selector.selector) && !m.call(element, selector.exclude)
23
+ } else {
24
+ return m.call(element, selector)
25
+ }
26
+ }
27
+
28
+ // get and set data on a given element using "expando properties"
29
+ // See: https://developer.mozilla.org/en-US/docs/Glossary/Expando
30
+ const expando = "_ujsData"
31
+
32
+ export const getData = (element, key) => (element[expando] != null ? element[expando][key] : undefined)
33
+
34
+ export const setData = (element, key, value) => {
35
+ if (element[expando] == null) {
36
+ element[expando] = {}
37
+ }
38
+
39
+ return (element[expando][key] = value)
40
+ }
@@ -0,0 +1,57 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ import { matches } from "./dom"
4
+
5
+ // Triggers a custom event on an element and returns false if the event result is false
6
+ // obj::
7
+ // a native DOM element
8
+ // name::
9
+ // string that corresponds to the event you want to trigger
10
+ // e.g. 'click', 'submit'
11
+ // data::
12
+ // data you want to pass when you dispatch an event
13
+ export const fire = (obj, name, data = {}) => {
14
+ const event = new CustomEvent(name, {
15
+ bubbles: true,
16
+ cancelable: true,
17
+ detail: data,
18
+ })
19
+
20
+ obj.dispatchEvent(event)
21
+ return !event.defaultPrevented
22
+ }
23
+
24
+ // Helper function, needed to provide consistent behavior in IE
25
+ export const stopEverything = (event) => {
26
+ fire(event.target, "ujs:everythingStopped")
27
+
28
+ event.preventDefault()
29
+ event.stopPropagation()
30
+
31
+ return event.stopImmediatePropagation()
32
+ }
33
+
34
+ // Delegates events
35
+ // to a specified parent `element`, which fires event `handler`
36
+ // for the specified `selector` when an event of `eventType` is triggered
37
+ // element::
38
+ // parent element that will listen for events e.g. document
39
+ // selector::
40
+ // CSS selector; or an object that has `selector` and `exclude` properties (see: Rails.matches)
41
+ // eventType::
42
+ // string representing the event e.g. 'submit', 'click'
43
+ // handler::
44
+ // the event handler to be called
45
+ export const delegate = (element, selector, eventType, handler) =>
46
+ element.addEventListener(eventType, function (event) {
47
+ let { target } = event
48
+
49
+ while (!!(target instanceof Element) && !matches(target, selector)) {
50
+ target = target.parentNode
51
+ }
52
+
53
+ if (target instanceof Element && handler.call(target, event) === false) {
54
+ event.preventDefault()
55
+ return event.stopPropagation()
56
+ }
57
+ })
@@ -0,0 +1,58 @@
1
+ // This code was heavily inspired by the rails-ujs project.
2
+ // Copyright (c) 2007-2021 Rails Core team.
3
+ import { matches } from "./dom"
4
+
5
+ const toArray = (e) => Array.prototype.slice.call(e)
6
+
7
+ export const serializeElement = (element, additionalParam) => {
8
+ let inputs = [element]
9
+ if (matches(element, "form")) {
10
+ inputs = toArray(element.elements)
11
+ }
12
+ const params = []
13
+
14
+ inputs.forEach(function (input) {
15
+ if (!input.name || input.disabled) {
16
+ return
17
+ }
18
+
19
+ if (matches(input, "fieldset[disabled] *")) {
20
+ return
21
+ }
22
+
23
+ if (matches(input, "select")) {
24
+ return toArray(input.options).forEach(function (option) {
25
+ if (option.selected) {
26
+ return params.push({ name: input.name, value: option.value })
27
+ }
28
+ })
29
+ } else if (input.checked || ["radio", "checkbox", "submit"].indexOf(input.type) === -1) {
30
+ return params.push({ name: input.name, value: input.value })
31
+ }
32
+ })
33
+
34
+ if (additionalParam) {
35
+ params.push(additionalParam)
36
+ }
37
+
38
+ return params
39
+ .map(function (param) {
40
+ if (param.name != null) {
41
+ return `${encodeURIComponent(param.name)}=${encodeURIComponent(param.value)}`
42
+ } else {
43
+ return param
44
+ }
45
+ })
46
+ .join("&")
47
+ }
48
+
49
+ // Helper function that returns form elements that match the specified CSS selector
50
+ // If form is actually a "form" element this will return associated elements outside the from that have
51
+ // the html form attribute set
52
+ export const formElements = (form, selector) => {
53
+ if (matches(form, "form")) {
54
+ return toArray(form.elements).filter((el) => matches(el, selector))
55
+ } else {
56
+ return toArray(form.querySelectorAll(selector))
57
+ }
58
+ }
@@ -0,0 +1,19 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gem 'rails', '~> 7.0.0'
4
+ gem 'webrick'
5
+ gem 'bootsnap', '>= 1.4.4', require: false
6
+ gem 'rexml'
7
+ gem 'shoelace-rails', path: '../../../shoelace-rails'
8
+ gem 'webpacker', '6.0.0.rc.5'
9
+
10
+ group :development, :test do
11
+ gem 'pry-byebug'
12
+ end
13
+
14
+ group :test do
15
+ gem 'capybara', '>= 3.26'
16
+ gem 'capybara-shadowdom'
17
+ gem 'selenium-webdriver', '>= 4.0.0.rc3'
18
+ gem 'webdrivers'
19
+ end
@@ -0,0 +1,6 @@
1
+ # Add your own tasks in files placed in lib/tasks ending in .rake,
2
+ # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
3
+
4
+ require_relative "config/application"
5
+
6
+ Rails.application.load_tasks
@@ -0,0 +1,46 @@
1
+ class HotwireFormsController < ActionController::Base
2
+ layout "application"
3
+
4
+ def show
5
+ @user = User.new(session[:user])
6
+ end
7
+
8
+ # GET /users/new
9
+ def new
10
+ @user = User.new(params[:user]&.permit!)
11
+
12
+ render 'form'
13
+ end
14
+
15
+ # GET /users/1/edit
16
+ def edit
17
+ @user = User.new(name: "Yuki Nishijima")
18
+
19
+ render 'form'
20
+ end
21
+
22
+ # POST /users
23
+ def create
24
+ @user = User.new(user_params)
25
+
26
+ if @user.valid?
27
+ session[:user] = user_params.to_h
28
+ redirect_to hotwire_form_path(1)
29
+ else
30
+ render 'form', status: 422
31
+ end
32
+ end
33
+
34
+ # PATCH/PUT /users/1
35
+ def update
36
+ @user = User.new(user_params)
37
+
38
+ render(@user.valid? ? 'show' : 'form')
39
+ end
40
+
41
+ private
42
+
43
+ def user_params
44
+ params.require(:user).permit!
45
+ end
46
+ end
@@ -0,0 +1,37 @@
1
+ class TurbolinksFormsController < ActionController::Base
2
+ layout "application"
3
+
4
+ # GET /users/new
5
+ def new
6
+ @user = User.new
7
+
8
+ render 'form'
9
+ end
10
+
11
+ # GET /users/1/edit
12
+ def edit
13
+ @user = User.new(name: "Yuki Nishijima")
14
+
15
+ render 'form'
16
+ end
17
+
18
+ # POST /users
19
+ def create
20
+ @user = User.new(user_params)
21
+
22
+ render(@user.valid? ? 'show' : 'form')
23
+ end
24
+
25
+ # PATCH/PUT /users/1
26
+ def update
27
+ @user = User.new(user_params)
28
+
29
+ render(@user.valid? ? 'show' : 'form')
30
+ end
31
+
32
+ private
33
+
34
+ def user_params
35
+ params.require(:user).permit!
36
+ end
37
+ end
@@ -0,0 +1,16 @@
1
+ class User
2
+ include ActiveModel::Model
3
+ include ActiveModel::Attributes
4
+
5
+ attribute :name
6
+ attribute :description
7
+ attribute :color
8
+ attribute :score
9
+ attribute :current_city
10
+ attribute :previous_city
11
+ attribute :past_cities
12
+ attribute :remember_me
13
+ attribute :subscribe_to_emails
14
+
15
+ validates :name, presence: true
16
+ end
@@ -0,0 +1 @@
1
+ import "@hotwired/turbo-rails"
@@ -0,0 +1,5 @@
1
+ import Turbolinks from "turbolinks"
2
+ import Rails from '@rails/ujs'
3
+
4
+ Turbolinks.start()
5
+ Rails.start()
@@ -0,0 +1,45 @@
1
+ <% locations = { tokyo: "Tokyo", new_york: "New York", london: "London" } %>
2
+ <%= sl_turbo_form_for(@user, url: hotwire_forms_path) do |form| %>
3
+ <div>
4
+ <%= form.text_field :name do %>
5
+ <span slot="help-text" style="color: rgb(var(--sl-color-danger-600));">
6
+ <%= @user.errors.full_messages_for(:name).first %>
7
+ </span>
8
+ <% end %>
9
+ </div>
10
+
11
+ <div>
12
+ <%= form.color_field :color %>
13
+ </div>
14
+
15
+ <div>
16
+ <%= form.range_field :score, min: 0, max: 100, step: 1 %>
17
+ </div>
18
+
19
+ <div>
20
+ <%= form.collection_radio_buttons :current_city, locations, :first, :last %>
21
+ </div>
22
+
23
+ <div>
24
+ <%= form.collection_select :previous_city, locations, :first, :last, {}, { placeholder: "Select one" } %>
25
+ </div>
26
+
27
+ <div>
28
+ <%= form.collection_select :past_cities, locations, :first, :last, {}, { placeholder: "Select two or more", multiple: true, clearable: true } %>
29
+ </div>
30
+
31
+ <div>
32
+ <%= form.check_box :remember_me %>
33
+ </div>
34
+
35
+ <div>
36
+ <%= form.switch_field :subscribe_to_emails, value: "1" %>
37
+ </div>
38
+
39
+ <div>
40
+ <%= form.text_area :description %>
41
+ </div>
42
+
43
+ <%= form.submit %>
44
+ <%= form.submit "Submit without Turbo", data: { turbo: false } %>
45
+ <% end %>
@@ -0,0 +1,5 @@
1
+ <% @user.attributes.each do |attribute_name, value| %>
2
+ <div>
3
+ <%= attribute_name.titleize %>: <%= value %>
4
+ </div>
5
+ <% end %>
@@ -0,0 +1,39 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Shoelace Test</title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%= csrf_meta_tags %>
7
+ <%= csp_meta_tag %>
8
+
9
+ <style>
10
+ body {
11
+ font-family: var(--sl-font-sans);
12
+ font-size: var(--sl-font-size-medium);
13
+ font-weight: var(--sl-font-weight-normal);
14
+ letter-spacing: var(--sl-letter-spacing-normal);
15
+ color: var(--sl-color-gray-800);
16
+ line-height: var(--sl-line-height-normal);
17
+ }
18
+
19
+ sl-form div, sl-turbo-form div {
20
+ margin-bottom: 2em;
21
+ }
22
+ </style>
23
+
24
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.65/dist/themes/light.css">
25
+ <script type="module" src="https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.0.0-beta.65/dist/shoelace.js"></script>
26
+
27
+ <% if request.path.include?("hotwire") %>
28
+ <%= javascript_pack_tag 'hotwire' %>
29
+ <% else %>
30
+ <%= javascript_pack_tag 'turbolinks' %>
31
+ <% end %>
32
+ </head>
33
+
34
+ <body>
35
+ <div style="width: 950px; margin: auto">
36
+ <%= yield %>
37
+ </div>
38
+ </body>
39
+ </html>