kiso 0.6.1.pre → 0.6.3.pre

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b28c57b532dc53dad377cca677637615db58881e3109d6a6a5ecbbd1cce5b02e
4
- data.tar.gz: eca25e39e3e60d918dac9df9c4bbd2b696c558649bb48d8bc74f156e5171f56b
3
+ metadata.gz: fc54430908619f2cb1581ca13089cfceb7b9c527f7050a0079cfced84ed088ea
4
+ data.tar.gz: ea34c1970ff36648de7571bac67bec65cf5a57083a4afb4afe933c89eb27d1b2
5
5
  SHA512:
6
- metadata.gz: ab5fd9a21f185c579e8fd745b5709915e5e4aa30c2fcc38126c616ecbaa08495dce298997bb247ccc077205f84d82f2b33bb8cff764bfc7ce6a33527396dff8b
7
- data.tar.gz: d83b4e694f3559f20c0da252dbb631224fcd23a5514595a8d57f9631dd68a65f79d6377e0594afe4c07968df9330c906b444f9e83a882b82fcf540fbcc364158
6
+ metadata.gz: '02761138a1d470cec63228c7d3e5ffe2d9d93d271427814059cb6193450492051755bba4514f945c225a669276540b9a6bd223469ec6eb0b05b13ae361ef2601'
7
+ data.tar.gz: 2bc1f27fc05aa8da6aecf7e3b6eaae668e02afc45caa3636ebc55270738588dd4dc61102554e0cadfe33e99cba0a72a9e4e56decb3094b4646d5955a28e03ca3
@@ -1,16 +1,15 @@
1
1
  /* ── Button ──────────────────────────────────────────────────────────────────
2
2
  Default display value for the Button component.
3
3
 
4
- Why CSS instead of a Tailwind class?
5
- The theme module applies `inline-flex` via Tailwind, but Tailwind utilities
6
- live in @layer utilities the highest-priority layer. That means when JS
7
- removes a `hidden` class, the button's `inline-flex` (also in utilities)
8
- has no cascade advantage to restore visibility. Placing the default display
9
- in @layer components (lower priority) lets utility classes like `hidden`
10
- override it naturally, while still restoring inline-flex when `hidden` is
11
- removed.
4
+ Why CSS in addition to the theme class?
5
+ The theme module includes `inline-flex` so the class string is
6
+ self-contained (works on link_to, button_to, etc. without data-slot).
7
+ This @layer components rule provides a lower-priority fallback for
8
+ kui(:button) elements so that utility classes like `hidden` can override
9
+ display naturally, restoring inline-flex when `hidden` is removed.
12
10
 
13
- Fixes: https://github.com/steveclarke/kiso/issues/200 */
11
+ Fixes: https://github.com/steveclarke/kiso/issues/200
12
+ See also: https://github.com/steveclarke/kiso/issues/229 */
14
13
 
15
14
  @layer components {
16
15
  [data-slot="button"] {
@@ -0,0 +1,120 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Automatic loading state for buttons inside Turbo forms.
5
+ *
6
+ * When the button's form submits via Turbo, the controller saves the button's
7
+ * original content, injects an animated spinner SVG, and disables the button.
8
+ * On `turbo:submit-end`, the original content and state are restored.
9
+ *
10
+ * Attach via `data-controller="kiso--button-loading"` on the `<button>` element.
11
+ * The Button partial adds this automatically when `loading_auto: true`.
12
+ *
13
+ * @example
14
+ * <form action="/save" method="post" data-turbo="true">
15
+ * <button type="submit"
16
+ * data-controller="kiso--button-loading"
17
+ * data-slot="button"
18
+ * class="...">
19
+ * Save
20
+ * </button>
21
+ * </form>
22
+ *
23
+ * @fires kiso--button-loading:loading - When the button enters loading state
24
+ * @fires kiso--button-loading:complete - When the button exits loading state
25
+ */
26
+ export default class extends Controller {
27
+ connect() {
28
+ this._form = this.element.closest("form")
29
+ if (!this._form) return
30
+
31
+ this._handleSubmitStart = this._handleSubmitStart.bind(this)
32
+ this._handleSubmitEnd = this._handleSubmitEnd.bind(this)
33
+
34
+ this._form.addEventListener("turbo:submit-start", this._handleSubmitStart)
35
+ this._form.addEventListener("turbo:submit-end", this._handleSubmitEnd)
36
+ }
37
+
38
+ disconnect() {
39
+ if (!this._form) return
40
+
41
+ this._form.removeEventListener("turbo:submit-start", this._handleSubmitStart)
42
+ this._form.removeEventListener("turbo:submit-end", this._handleSubmitEnd)
43
+ this._restore()
44
+ }
45
+
46
+ /**
47
+ * Saves original button content and enters loading state.
48
+ *
49
+ * @param {CustomEvent} event - turbo:submit-start event
50
+ * @private
51
+ */
52
+ _handleSubmitStart(event) {
53
+ // Only react if this button triggered the submission
54
+ if (event.detail?.formSubmission?.submitter !== this.element) return
55
+
56
+ this._savedNodes = Array.from(this.element.childNodes).map((n) => n.cloneNode(true))
57
+ this._wasDisabled = this.element.disabled
58
+
59
+ // Create spinner SVG via DOM APIs (safe, no innerHTML)
60
+ const spinner = document.createElementNS("http://www.w3.org/2000/svg", "svg")
61
+ spinner.setAttribute("viewBox", "0 0 24 24")
62
+ spinner.setAttribute("fill", "none")
63
+ spinner.setAttribute("stroke", "currentColor")
64
+ spinner.setAttribute("stroke-width", "2")
65
+ spinner.setAttribute("stroke-linecap", "round")
66
+ spinner.setAttribute("stroke-linejoin", "round")
67
+ spinner.setAttribute("class", "animate-spin shrink-0 pointer-events-none size-4")
68
+
69
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
70
+ path.setAttribute("d", "M21 12a9 9 0 1 1-6.219-8.56")
71
+ spinner.appendChild(path)
72
+
73
+ // Replace content: spinner + original text
74
+ const textContent = this.element.textContent?.trim()
75
+ while (this.element.firstChild) {
76
+ this.element.removeChild(this.element.firstChild)
77
+ }
78
+ this.element.appendChild(spinner)
79
+ if (textContent) {
80
+ this.element.appendChild(document.createTextNode(` ${textContent}`))
81
+ }
82
+
83
+ this.element.disabled = true
84
+ this.element.setAttribute("aria-busy", "true")
85
+
86
+ this.dispatch("loading")
87
+ }
88
+
89
+ /**
90
+ * Restores original button content and state on turbo:submit-end.
91
+ *
92
+ * @private
93
+ */
94
+ _handleSubmitEnd() {
95
+ this._restore()
96
+ this.dispatch("complete")
97
+ }
98
+
99
+ /**
100
+ * Restores the button to its pre-loading state.
101
+ *
102
+ * @private
103
+ */
104
+ _restore() {
105
+ if (!this._savedNodes) return
106
+
107
+ while (this.element.firstChild) {
108
+ this.element.removeChild(this.element.firstChild)
109
+ }
110
+ for (const node of this._savedNodes) {
111
+ this.element.appendChild(node)
112
+ }
113
+
114
+ this.element.disabled = this._wasDisabled
115
+ this.element.removeAttribute("aria-busy")
116
+
117
+ this._savedNodes = undefined
118
+ this._wasDisabled = undefined
119
+ }
120
+ }
@@ -158,7 +158,10 @@ export default class extends Controller {
158
158
  if (item.dataset.disabled === "true") return
159
159
 
160
160
  this.dispatch("select", { detail: { item } })
161
- this.close()
161
+ // Defer close to the next frame so other click handlers on the item
162
+ // (e.g. dialog triggers, custom actions) complete before the menu
163
+ // hides and removes elements from view. See #233.
164
+ requestAnimationFrame(() => this.close())
162
165
  }
163
166
 
164
167
  /**
@@ -6,6 +6,7 @@ declare const KisoUi: {
6
6
 
7
7
  export default KisoUi
8
8
  export const KisoAlertController: typeof Controller
9
+ export const KisoButtonLoadingController: typeof Controller
9
10
  export const KisoComboboxController: typeof Controller
10
11
  export const KisoCommandController: typeof Controller
11
12
  export const KisoCommandDialogController: typeof Controller
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import KisoAlertController from "./alert_controller.js"
22
+ import KisoButtonLoadingController from "./button_loading_controller.js"
22
23
  import KisoComboboxController from "./combobox_controller.js"
23
24
  import KisoCommandController from "./command_controller.js"
24
25
  import KisoCommandDialogController from "./command_dialog_controller.js"
@@ -43,6 +44,7 @@ const KisoUi = {
43
44
  */
44
45
  start(application) {
45
46
  application.register("kiso--alert", KisoAlertController)
47
+ application.register("kiso--button-loading", KisoButtonLoadingController)
46
48
  application.register("kiso--combobox", KisoComboboxController)
47
49
  application.register("kiso--command", KisoCommandController)
48
50
  application.register("kiso--command-dialog", KisoCommandDialogController)
@@ -64,6 +66,7 @@ const KisoUi = {
64
66
  export default KisoUi
65
67
  export {
66
68
  KisoAlertController,
69
+ KisoButtonLoadingController,
67
70
  KisoComboboxController,
68
71
  KisoCommandController,
69
72
  KisoCommandDialogController,
@@ -1,14 +1,20 @@
1
1
  <%# locals: (color: :primary, variant: :solid, size: :md, block: false,
2
2
  type: :button, href: nil, method: nil, disabled: false,
3
- form: {}, css_classes: "", **component_options) %>
3
+ loading: false, loading_auto: false, form: {}, css_classes: "", **component_options) %>
4
4
  <%# Polymorphic button that renders as <button>, <a>, or button_to depending on props.
5
5
  With href: renders an anchor tag. With href: + method: (non-GET) renders a Rails
6
- button_to form for safe non-GET navigation. Without href: renders a plain <button>. %>
6
+ button_to form for safe non-GET navigation. Without href: renders a plain <button>.
7
+ With loading: true, prepends an animated spinner and disables the button.
8
+ With loading_auto: true, attaches kiso--button-loading Stimulus controller for
9
+ automatic loading state on Turbo form submissions. %>
7
10
  <%
8
11
  css = Kiso::Themes::Button.render(
9
12
  color: color, variant: variant, size: size, block: block, class: css_classes)
10
- data = kiso_prepare_options(component_options, slot: "button")
13
+ data = kiso_prepare_options(component_options, slot: "button",
14
+ **(loading_auto ? { controller: "kiso--button-loading" } : {}))
11
15
  use_button_to = href.present? && method.present? && method.to_s != "get"
16
+ is_disabled = disabled || loading
17
+ component_options[:"aria-busy"] = true if loading
12
18
  %>
13
19
  <% if use_button_to %>
14
20
  <%= button_to href,
@@ -16,21 +22,24 @@
16
22
  class: css,
17
23
  form_class: "contents",
18
24
  data: data,
19
- disabled: disabled || nil,
25
+ disabled: is_disabled || nil,
20
26
  form: form.presence,
21
27
  **component_options do %>
28
+ <%= kiso_component_icon(:spinner, class: "animate-spin") if loading %>
22
29
  <%= yield %>
23
30
  <% end %>
24
31
  <% elsif href.present? %>
25
32
  <% component_options[:href] = href
26
- component_options[:"aria-disabled"] = true if disabled %>
33
+ component_options[:"aria-disabled"] = true if is_disabled %>
27
34
  <%= content_tag :a, class: css, data: data, **component_options do %>
35
+ <%= kiso_component_icon(:spinner, class: "animate-spin") if loading %>
28
36
  <%= yield %>
29
37
  <% end %>
30
38
  <% else %>
31
39
  <% component_options[:type] = type
32
- component_options[:disabled] = true if disabled %>
40
+ component_options[:disabled] = true if is_disabled %>
33
41
  <%= content_tag :button, class: css, data: data, **component_options do %>
42
+ <%= kiso_component_icon(:spinner, class: "animate-spin") if loading %>
34
43
  <%= yield %>
35
44
  <% end %>
36
45
  <% end %>
@@ -1,15 +1,40 @@
1
- <%# locals: (variant: :default, inset: false, disabled: false, css_classes: "", **component_options) %>
2
- <%= content_tag :div,
3
- class: Kiso::Themes::DropdownMenuItem.render(variant: variant, class: css_classes),
4
- role: "menuitem",
5
- tabindex: "-1",
6
- data: kiso_prepare_options(component_options, slot: "dropdown-menu-item",
7
- kiso__dropdown_menu_target: "item",
8
- action: "click->kiso--dropdown-menu#selectItem",
9
- inset: (inset ? "" : nil),
10
- variant: (variant == :destructive ? "destructive" : nil),
11
- disabled: (disabled ? "true" : nil)),
12
- aria: {disabled: (disabled || nil)},
13
- **component_options do %>
14
- <%= yield %>
1
+ <%# locals: (variant: :default, inset: false, disabled: false,
2
+ href: nil, method: nil, form: {},
3
+ css_classes: "", **component_options) %>
4
+ <%# Clickable menu action. Polymorphic tag: <div> by default, <a> when href:
5
+ is provided, button_to form when href: + method: (non-GET). %>
6
+ <%
7
+ css = Kiso::Themes::DropdownMenuItem.render(variant: variant, class: css_classes)
8
+ data = kiso_prepare_options(component_options, slot: "dropdown-menu-item",
9
+ kiso__dropdown_menu_target: "item",
10
+ action: "click->kiso--dropdown-menu#selectItem",
11
+ inset: (inset ? "" : nil),
12
+ variant: (variant == :destructive ? "destructive" : nil),
13
+ disabled: (disabled ? "true" : nil))
14
+ component_options[:role] = "menuitem"
15
+ component_options[:tabindex] = "-1"
16
+ component_options[:aria] = { disabled: (disabled || nil) }
17
+ use_button_to = href.present? && method.present? && method.to_s != "get"
18
+ %>
19
+ <% if use_button_to %>
20
+ <%= button_to href,
21
+ method: method,
22
+ class: css,
23
+ form_class: "contents w-full",
24
+ data: data,
25
+ disabled: disabled || nil,
26
+ form: form.presence,
27
+ **component_options do %>
28
+ <%= yield %>
29
+ <% end %>
30
+ <% elsif href.present? %>
31
+ <% component_options[:href] = href
32
+ component_options[:"aria-disabled"] = true if disabled %>
33
+ <%= content_tag :a, class: css, data: data, **component_options do %>
34
+ <%= yield %>
35
+ <% end %>
36
+ <% else %>
37
+ <%= content_tag :div, class: css, data: data, **component_options do %>
38
+ <%= yield %>
39
+ <% end %>
15
40
  <% end %>
@@ -14,7 +14,7 @@ module Kiso
14
14
  # - +size+ — :xs, :sm, :md (default), :lg, :xl
15
15
  # - +block+ — +true+ for full-width, +false+ (default)
16
16
  Button = ClassVariants.build(
17
- base: "items-center justify-center gap-2 font-medium whitespace-nowrap shrink-0 " \
17
+ base: "inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap shrink-0 " \
18
18
  "transition-all " \
19
19
  "focus-visible:outline-2 focus-visible:outline-offset-2 " \
20
20
  "disabled:pointer-events-none disabled:opacity-50 " \
data/lib/kiso/version.rb CHANGED
@@ -5,5 +5,5 @@ module Kiso
5
5
  # Updated by +bin/release+.
6
6
  #
7
7
  # @return [String]
8
- VERSION = "0.6.1.pre"
8
+ VERSION = "0.6.3.pre"
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kiso
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1.pre
4
+ version: 0.6.3.pre
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Clarke
@@ -100,6 +100,7 @@ files:
100
100
  - app/helpers/kiso/theme_helper.rb
101
101
  - app/helpers/kiso/ui_context_helper.rb
102
102
  - app/javascript/controllers/kiso/alert_controller.js
103
+ - app/javascript/controllers/kiso/button_loading_controller.js
103
104
  - app/javascript/controllers/kiso/combobox_controller.js
104
105
  - app/javascript/controllers/kiso/command_controller.js
105
106
  - app/javascript/controllers/kiso/command_dialog_controller.js