shoelace-rails 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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>