ruby_ui 1.1.0 → 1.3.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/ruby_ui/component_generator.rb +5 -1
  3. data/lib/generators/ruby_ui/dependencies.yml +32 -0
  4. data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
  5. data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
  6. data/lib/generators/ruby_ui/javascript_utils.rb +27 -6
  7. data/lib/ruby_ui/avatar/avatar.rb +3 -0
  8. data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
  9. data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
  10. data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
  11. data/lib/ruby_ui/base.rb +6 -0
  12. data/lib/ruby_ui/calendar/calendar.rb +3 -1
  13. data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
  14. data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
  15. data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
  16. data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
  17. data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
  18. data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
  19. data/lib/ruby_ui/combobox/combobox_controller.js +4 -2
  20. data/lib/ruby_ui/combobox/combobox_docs.rb +199 -64
  21. data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
  22. data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
  23. data/lib/ruby_ui/command/command_controller.js +10 -19
  24. data/lib/ruby_ui/command/command_dialog.rb +4 -1
  25. data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
  26. data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
  27. data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
  28. data/lib/ruby_ui/data_table/data_table.rb +29 -0
  29. data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
  30. data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
  31. data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
  32. data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
  33. data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
  34. data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
  35. data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
  36. data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
  37. data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
  38. data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
  39. data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
  40. data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
  41. data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
  42. data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
  43. data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
  44. data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
  45. data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
  46. data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
  47. data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
  48. data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
  49. data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
  50. data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
  51. data/lib/ruby_ui/native_select/native_select.rb +39 -0
  52. data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
  53. data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
  54. data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
  55. data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
  56. data/lib/ruby_ui/select/select_value.rb +2 -1
  57. data/lib/ruby_ui/sheet/sheet.rb +9 -1
  58. data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
  59. data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
  60. data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
  61. data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
  62. data/lib/ruby_ui/toast/toast.rb +18 -0
  63. data/lib/ruby_ui/toast/toast_action.rb +27 -0
  64. data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
  65. data/lib/ruby_ui/toast/toast_close.rb +40 -0
  66. data/lib/ruby_ui/toast/toast_controller.js +151 -0
  67. data/lib/ruby_ui/toast/toast_description.rb +18 -0
  68. data/lib/ruby_ui/toast/toast_docs.rb +12 -0
  69. data/lib/ruby_ui/toast/toast_icon.rb +65 -0
  70. data/lib/ruby_ui/toast/toast_item.rb +72 -0
  71. data/lib/ruby_ui/toast/toast_region.rb +124 -0
  72. data/lib/ruby_ui/toast/toast_title.rb +18 -0
  73. data/lib/ruby_ui/toast/toaster_controller.js +306 -0
  74. data/lib/ruby_ui/toggle/toggle.rb +101 -0
  75. data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
  76. data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
  77. data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
  78. data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
  79. data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
  80. data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
  81. data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
  82. data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
  83. data/lib/ruby_ui.rb +3 -1
  84. metadata +66 -10
  85. data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
  86. data/lib/ruby_ui/theme_toggle/set_light_mode.rb +0 -16
@@ -0,0 +1,306 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ const VARIANTS = ["default", "success", "error", "warning", "info", "loading"]
4
+
5
+ let streamActionRegistered = false
6
+
7
+ function registerStreamAction() {
8
+ if (streamActionRegistered) return
9
+ if (typeof window === "undefined") return
10
+ const Turbo = window.Turbo
11
+ if (!Turbo?.StreamActions) return
12
+ Turbo.StreamActions.toast = function () {
13
+ const detail = {}
14
+ for (const attr of this.attributes) {
15
+ if (attr.name === "action" || attr.name === "target" || attr.name === "targets") continue
16
+ detail[attr.name] = attr.value
17
+ }
18
+ if (detail.duration != null && detail.duration !== "") detail.duration = Number(detail.duration)
19
+ if (detail.dismissible != null) detail.dismissible = detail.dismissible !== "false"
20
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", { detail }))
21
+ }
22
+ streamActionRegistered = true
23
+ }
24
+
25
+ // Connects to data-controller="ruby-ui--toaster"
26
+ export default class extends Controller {
27
+ static targets = ["skeleton", "toast", "actionTpl", "cancelTpl", "closeTpl"]
28
+ static values = {
29
+ position: { type: String, default: "bottom-right" },
30
+ expand: { type: Boolean, default: false },
31
+ max: { type: Number, default: 3 },
32
+ duration: { type: Number, default: 4000 },
33
+ gap: { type: Number, default: 14 },
34
+ offset: { type: Number, default: 24 },
35
+ theme: { type: String, default: "system" },
36
+ richColors: { type: Boolean, default: false },
37
+ closeButton: { type: Boolean, default: false },
38
+ hotkey: { type: String, default: "alt+t" },
39
+ dir: { type: String, default: "ltr" },
40
+ }
41
+
42
+ connect() {
43
+ this._heights = new Map()
44
+ this._resizeObservers = new WeakMap()
45
+ this._expanded = this.expandValue
46
+ this._listEl = this.element.querySelector("ol") || (this.element.tagName === "OL" ? this.element : null)
47
+ this._registerGlobalApi()
48
+ registerStreamAction()
49
+ if (!this._listEl) return
50
+
51
+ this._onPointerEnter = () => this._setExpanded(true)
52
+ this._onPointerLeave = () => { if (!this.expandValue) this._setExpanded(false) }
53
+ this._onWindowToast = (e) => this._spawn(e.detail || {})
54
+ this._onWindowDismissAll = () => this._dismissById(null)
55
+ this._onKey = this._onKey.bind(this)
56
+
57
+ window.addEventListener("ruby-ui:toast", this._onWindowToast)
58
+ window.addEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
59
+ this._listEl.addEventListener("pointerenter", this._onPointerEnter)
60
+ this._listEl.addEventListener("pointerleave", this._onPointerLeave)
61
+ document.addEventListener("keydown", this._onKey)
62
+ }
63
+
64
+ disconnect() {
65
+ window.removeEventListener("ruby-ui:toast", this._onWindowToast)
66
+ window.removeEventListener("ruby-ui:toast:dismiss-all", this._onWindowDismissAll)
67
+ this._listEl?.removeEventListener("pointerenter", this._onPointerEnter)
68
+ this._listEl?.removeEventListener("pointerleave", this._onPointerLeave)
69
+ document.removeEventListener("keydown", this._onKey)
70
+ }
71
+
72
+ toastTargetConnected(el) {
73
+ if (typeof ResizeObserver !== "undefined") {
74
+ const ro = new ResizeObserver(() => {
75
+ this._heights.set(el, el.offsetHeight)
76
+ this._reflow()
77
+ })
78
+ ro.observe(el)
79
+ this._resizeObservers.set(el, ro)
80
+ }
81
+ this._heights.set(el, el.offsetHeight || 64)
82
+ this._reflow()
83
+ }
84
+
85
+ toastTargetDisconnected(el) {
86
+ this._resizeObservers.get(el)?.disconnect()
87
+ this._resizeObservers.delete(el)
88
+ this._heights.delete(el)
89
+ this._reflow()
90
+ }
91
+
92
+ _spawn(detail) {
93
+ const variant = VARIANTS.includes(detail.variant) ? detail.variant : "default"
94
+ const tpl = this._skeletonFor(variant)
95
+ if (!tpl) return null
96
+ if (detail.position) {
97
+ this.element.setAttribute("data-position", detail.position)
98
+ this.positionValue = detail.position
99
+ }
100
+ const node = tpl.content.firstElementChild.cloneNode(true)
101
+
102
+ node.id = detail.id || `toast-${this._uuid()}`
103
+ if (detail.duration != null) {
104
+ const dur = detail.duration === Infinity ? 0 : detail.duration
105
+ node.setAttribute("data-ruby-ui--toast-duration-value", String(dur))
106
+ }
107
+ if (detail.dismissible === false) {
108
+ node.setAttribute("data-ruby-ui--toast-dismissible-value", "false")
109
+ }
110
+ if (detail.className) node.className += ` ${detail.className}`
111
+
112
+ const titleEl = node.querySelector('[data-slot="title"]')
113
+ if (titleEl) titleEl.textContent = detail.title || detail.message || ""
114
+ const descEl = node.querySelector('[data-slot="description"]')
115
+ if (descEl) {
116
+ if (detail.description) descEl.textContent = detail.description
117
+ else descEl.remove()
118
+ }
119
+
120
+ if (detail.action && detail.action.label && this.hasActionTplTarget) {
121
+ const btn = this._cloneSlot(this.actionTplTarget)
122
+ btn.textContent = detail.action.label
123
+ btn.addEventListener("click", (ev) => {
124
+ try { detail.action.onClick?.(ev) } finally {
125
+ node.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
126
+ }
127
+ })
128
+ node.appendChild(btn)
129
+ }
130
+
131
+ if (detail.cancel && detail.cancel.label && this.hasCancelTplTarget) {
132
+ const btn = this._cloneSlot(this.cancelTplTarget)
133
+ btn.textContent = detail.cancel.label
134
+ node.appendChild(btn)
135
+ }
136
+
137
+ if (detail.closeButton && this.hasCloseTplTarget) {
138
+ const x = this._cloneSlot(this.closeTplTarget)
139
+ node.classList.add("pr-10")
140
+ node.appendChild(x)
141
+ }
142
+
143
+ this._listEl.appendChild(node)
144
+ return node.id
145
+ }
146
+
147
+ _dismissById(id) {
148
+ if (!id) {
149
+ this.toastTargets.forEach((el) =>
150
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
151
+ )
152
+ return
153
+ }
154
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
155
+ if (el) el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
156
+ }
157
+
158
+ _skeletonFor(variant) {
159
+ return this.skeletonTargets.find((t) => t.dataset.variant === variant)
160
+ }
161
+
162
+ _cloneSlot(tpl) {
163
+ return tpl.content.firstElementChild.cloneNode(true)
164
+ }
165
+
166
+ _setExpanded(value) {
167
+ if (this._expanded === value) return
168
+ this._expanded = value
169
+ document.dispatchEvent(new CustomEvent(value ? "ruby-ui:toast:pause" : "ruby-ui:toast:resume"))
170
+ this._reflow()
171
+ }
172
+
173
+ _reflow() {
174
+ if (!this._listEl) return
175
+ const isBottom = this.positionValue.startsWith("bottom")
176
+ const items = this.toastTargets
177
+ const order = isBottom ? items.slice().reverse() : items.slice()
178
+ const heights = order.map(el => this._heights.get(el) || el.offsetHeight || 64)
179
+ const gap = this.gapValue
180
+ const peekOffset = 16
181
+ const peekScaleStep = 0.05
182
+ const peekOpacityStep = 0.2
183
+
184
+ const expandedHeight = heights.reduce((a, b) => a + b, 0) + gap * Math.max(0, heights.length - 1)
185
+ const collapsedHeight = (heights[0] || 0) + Math.min(2, Math.max(0, heights.length - 1)) * peekOffset
186
+ this._listEl.style.minHeight = `${this._expanded ? expandedHeight : collapsedHeight}px`
187
+
188
+ let acc = 0
189
+ order.forEach((el, i) => {
190
+ const visible = i < this.maxValue
191
+ let yOffset, scale, opacity
192
+
193
+ if (this._expanded) {
194
+ yOffset = acc + i * gap
195
+ scale = 1
196
+ opacity = visible ? 1 : 0
197
+ } else {
198
+ yOffset = i * peekOffset
199
+ scale = Math.max(0.85, 1 - i * peekScaleStep)
200
+ opacity = visible ? Math.max(0, 1 - i * peekOpacityStep) : 0
201
+ }
202
+
203
+ const sign = isBottom ? -1 : 1
204
+ const ty = sign * yOffset
205
+
206
+ el.style.setProperty("--opacity", String(opacity))
207
+ el.style.setProperty("--scale", String(scale))
208
+ el.style.setProperty("--y-offset", `${ty}px`)
209
+ el.style.transformOrigin = isBottom ? "center bottom" : "center top"
210
+ el.style.top = isBottom ? "auto" : "0"
211
+ el.style.bottom = isBottom ? "0" : "auto"
212
+ el.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`
213
+ el.style.zIndex = String(1000 - i)
214
+ el.style.pointerEvents = visible ? "auto" : "none"
215
+ el.tabIndex = visible ? 0 : -1
216
+
217
+ acc += heights[i] || 0
218
+ })
219
+
220
+ this._enforceMax(items)
221
+ }
222
+
223
+ _enforceMax(items) {
224
+ if (items.length <= this.maxValue) return
225
+ const isBottom = this.positionValue.startsWith("bottom")
226
+ const dropping = items.length - this.maxValue
227
+ const candidates = isBottom ? items.slice(0, dropping) : items.slice(-dropping)
228
+ candidates.forEach(el => {
229
+ if (el.dataset.state !== "closing") {
230
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:force-dismiss", { bubbles: true }))
231
+ }
232
+ })
233
+ }
234
+
235
+ _onKey(e) {
236
+ const parts = (this.hotkeyValue || "alt+t").split("+")
237
+ const key = parts.pop()
238
+ const wantAlt = parts.includes("alt")
239
+ const wantCtrl = parts.includes("ctrl")
240
+ const wantMeta = parts.includes("meta")
241
+ if (e.key.toLowerCase() !== key.toLowerCase()) return
242
+ if (wantAlt !== e.altKey) return
243
+ if (wantCtrl !== e.ctrlKey) return
244
+ if (wantMeta !== e.metaKey) return
245
+ e.preventDefault()
246
+ const first = this._listEl.firstElementChild
247
+ first?.focus()
248
+ }
249
+
250
+ _registerGlobalApi() {
251
+ const fire = (variant, message, opts = {}) =>
252
+ window.dispatchEvent(new CustomEvent("ruby-ui:toast", {
253
+ detail: { ...opts, variant, message: opts.title || message }
254
+ }))
255
+
256
+ const api = (message, opts) => fire("default", message, opts)
257
+ api.success = (m, o) => fire("success", m, o)
258
+ api.error = (m, o) => fire("error", m, o)
259
+ api.warning = (m, o) => fire("warning", m, o)
260
+ api.info = (m, o) => fire("info", m, o)
261
+ api.loading = (m, o = {}) => fire("loading", m, { ...o, duration: o.duration ?? 0 })
262
+ api.dismiss = (id) => {
263
+ if (id) this._dismissById(id)
264
+ else window.dispatchEvent(new CustomEvent("ruby-ui:toast:dismiss-all"))
265
+ }
266
+ api.promise = (p, msgs = {}) => {
267
+ const id = `toast-${this._uuid()}`
268
+ fire("loading", typeof msgs.loading === "function" ? msgs.loading() : (msgs.loading || "Loading..."), { id, duration: 0 })
269
+ Promise.resolve(p).then(
270
+ (val) => this._mutate(id, "success", typeof msgs.success === "function" ? msgs.success(val) : msgs.success),
271
+ (err) => this._mutate(id, "error", typeof msgs.error === "function" ? msgs.error(err) : msgs.error)
272
+ )
273
+ return id
274
+ }
275
+
276
+ window.RubyUI = window.RubyUI || {}
277
+ window.RubyUI.toast = api
278
+ }
279
+
280
+ _mutate(id, variant, text) {
281
+ const el = this._listEl.querySelector(`#${CSS.escape(id)}`)
282
+ if (!el) return
283
+ el.dataset.variant = variant
284
+ el.setAttribute("role", variant === "error" ? "alert" : "status")
285
+ this._swapIcon(el, variant)
286
+ const t = el.querySelector('[data-slot="title"]')
287
+ if (t && text) t.textContent = text
288
+ const dur = String(this.durationValue)
289
+ el.setAttribute("data-ruby-ui--toast-duration-value", dur)
290
+ el.dispatchEvent(new CustomEvent("ruby-ui:toast:restart", { bubbles: true }))
291
+ }
292
+
293
+ _swapIcon(el, variant) {
294
+ const iconHost = el.querySelector('[data-slot="icon"]')
295
+ if (!iconHost) return
296
+ const tpl = this._skeletonFor(variant)
297
+ if (!tpl) return
298
+ const sourceIcon = tpl.content.firstElementChild?.querySelector('[data-slot="icon"]')
299
+ iconHost.innerHTML = sourceIcon ? sourceIcon.innerHTML : ""
300
+ }
301
+
302
+ _uuid() {
303
+ if (typeof crypto !== "undefined" && crypto.randomUUID) return crypto.randomUUID()
304
+ return Math.random().toString(36).slice(2) + Date.now().toString(36)
305
+ }
306
+ }
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class Toggle < Base
5
+ BASE_CLASSES = [
6
+ "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap",
7
+ "transition-[color,box-shadow] outline-none",
8
+ "hover:bg-muted hover:text-muted-foreground",
9
+ "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
10
+ "disabled:pointer-events-none disabled:opacity-50",
11
+ "aria-invalid:border-destructive aria-invalid:ring-destructive/20",
12
+ "data-[state=on]:bg-accent data-[state=on]:text-accent-foreground",
13
+ "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
14
+ ].freeze
15
+
16
+ VARIANT_CLASSES = {
17
+ default: "bg-transparent",
18
+ outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground"
19
+ }.freeze
20
+
21
+ SIZE_CLASSES = {
22
+ sm: "h-8 min-w-8 px-1.5",
23
+ default: "h-9 min-w-9 px-2",
24
+ lg: "h-10 min-w-10 px-2.5"
25
+ }.freeze
26
+
27
+ def self.classes_for(variant:, size:)
28
+ [BASE_CLASSES, VARIANT_CLASSES.fetch(variant, VARIANT_CLASSES[:default]), SIZE_CLASSES.fetch(size, SIZE_CLASSES[:default])]
29
+ end
30
+
31
+ def initialize(
32
+ pressed: false,
33
+ name: nil,
34
+ value: "1",
35
+ unpressed_value: nil,
36
+ variant: :default,
37
+ size: :default,
38
+ disabled: false,
39
+ wrapper: {},
40
+ **attrs
41
+ )
42
+ @pressed = pressed
43
+ @name = name
44
+ @value = value
45
+ @unpressed_value = unpressed_value
46
+ @variant = variant.to_sym
47
+ @size = size.to_sym
48
+ @disabled = disabled
49
+ @wrapper = wrapper
50
+ super(**attrs)
51
+ end
52
+
53
+ def view_template(&block)
54
+ span(**wrapper_attrs) do
55
+ button(**attrs, &block)
56
+ render_hidden_input if @name
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def wrapper_attrs
63
+ mix(wrapper_default_attrs, @wrapper)
64
+ end
65
+
66
+ def wrapper_default_attrs
67
+ {
68
+ class: "contents",
69
+ data: {
70
+ controller: "ruby-ui--toggle",
71
+ action: "click->ruby-ui--toggle#toggle",
72
+ "ruby-ui--toggle-pressed-value": @pressed.to_s,
73
+ "ruby-ui--toggle-value-value": @value.to_s,
74
+ "ruby-ui--toggle-unpressed-value-value": @unpressed_value.to_s
75
+ }
76
+ }
77
+ end
78
+
79
+ def render_hidden_input
80
+ input(
81
+ type: "hidden",
82
+ name: @name,
83
+ value: @pressed ? @value : @unpressed_value.to_s,
84
+ data: {"ruby-ui--toggle-target": "input"}
85
+ )
86
+ end
87
+
88
+ def default_attrs
89
+ base = {type: "button"}
90
+ base[:disabled] = true if @disabled
91
+ base.merge(
92
+ aria: {pressed: @pressed.to_s},
93
+ data: {
94
+ state: @pressed ? "on" : "off",
95
+ "ruby-ui--toggle-target": "button"
96
+ },
97
+ class: self.class.classes_for(variant: @variant, size: @size)
98
+ )
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,33 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="ruby-ui--toggle"
4
+ // Sits on a wrapper element; the visible <button> and optional hidden <input>
5
+ // are descendants so Stimulus can target them.
6
+ export default class extends Controller {
7
+ static targets = ["button", "input"]
8
+ static values = {
9
+ pressed: Boolean,
10
+ value: String,
11
+ unpressedValue: String
12
+ }
13
+
14
+ toggle(event) {
15
+ if (this.buttonTarget.disabled) return
16
+ this.pressedValue = !this.pressedValue
17
+ }
18
+
19
+ pressedValueChanged(current, previous) {
20
+ if (this.hasButtonTarget) {
21
+ this.buttonTarget.setAttribute("aria-pressed", current ? "true" : "false")
22
+ this.buttonTarget.dataset.state = current ? "on" : "off"
23
+ }
24
+
25
+ if (this.hasInputTarget) {
26
+ this.inputTarget.value = current ? this.valueValue : this.unpressedValueValue
27
+ }
28
+
29
+ if (previous !== undefined) {
30
+ this.dispatch("change", { detail: { pressed: current }, bubbles: true })
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUI
4
+ class ToggleGroup < Base
5
+ SPACING_GAP = {0 => nil, 1 => "gap-1", 2 => "gap-2", 3 => "gap-3", 4 => "gap-4"}.freeze
6
+ VALID_TYPES = [:single, :multiple].freeze
7
+ VALID_ORIENTATIONS = [:horizontal, :vertical].freeze
8
+
9
+ def initialize(
10
+ type: :single,
11
+ name: nil,
12
+ value: nil,
13
+ variant: :default,
14
+ size: :default,
15
+ disabled: false,
16
+ spacing: 0,
17
+ orientation: :horizontal,
18
+ **attrs
19
+ )
20
+ @type = type.to_sym
21
+ raise ArgumentError, "type must be :single or :multiple" unless VALID_TYPES.include?(@type)
22
+
23
+ @orientation = orientation.to_sym
24
+ raise ArgumentError, "orientation must be :horizontal or :vertical" unless VALID_ORIENTATIONS.include?(@orientation)
25
+
26
+ raise ArgumentError, "spacing must be an Integer 0..4" unless spacing.is_a?(Integer) && (0..4).cover?(spacing)
27
+
28
+ @name = name
29
+ @value = value
30
+ @variant = variant.to_sym
31
+ @size = size.to_sym
32
+ @disabled = disabled
33
+ @spacing = spacing
34
+ super(**attrs)
35
+ end
36
+
37
+ def view_template(&block)
38
+ div(**attrs) do
39
+ yield(self)
40
+ render_hidden_inputs
41
+ end
42
+ end
43
+
44
+ def item_context
45
+ {
46
+ type: @type,
47
+ variant: @variant,
48
+ size: @size,
49
+ disabled: @disabled,
50
+ selected_values: selected_values,
51
+ spacing: @spacing,
52
+ orientation: @orientation
53
+ }
54
+ end
55
+
56
+ def ToggleGroupItem(**kwargs, &block)
57
+ render RubyUI::ToggleGroupItem.new(group_context: item_context, **kwargs), &block
58
+ end
59
+
60
+ private
61
+
62
+ def selected_values
63
+ case @type
64
+ when :single then @value.nil? ? [] : [@value.to_s]
65
+ when :multiple then Array(@value).map(&:to_s)
66
+ end
67
+ end
68
+
69
+ def render_hidden_inputs
70
+ return unless @name
71
+
72
+ if @type == :single
73
+ input(
74
+ type: "hidden",
75
+ name: @name,
76
+ value: selected_values.first.to_s,
77
+ data: {"ruby-ui--toggle-group-target": "input"}
78
+ )
79
+ else
80
+ selected_values.each do |v|
81
+ input(
82
+ type: "hidden",
83
+ name: "#{@name}[]",
84
+ value: v,
85
+ data: {"ruby-ui--toggle-group-target": "input"}
86
+ )
87
+ end
88
+ end
89
+ end
90
+
91
+ def default_attrs
92
+ {
93
+ role: (@type == :single) ? "radiogroup" : "group",
94
+ data: {
95
+ controller: "ruby-ui--toggle-group",
96
+ "ruby-ui--toggle-group-type-value": @type.to_s,
97
+ "ruby-ui--toggle-group-name-value": @name.to_s,
98
+ orientation: @orientation.to_s,
99
+ spacing: @spacing.to_s
100
+ },
101
+ class: container_classes
102
+ }
103
+ end
104
+
105
+ def container_classes
106
+ base = if @orientation == :vertical
107
+ "flex w-fit flex-col items-stretch rounded-md"
108
+ else
109
+ "flex w-fit items-center rounded-md"
110
+ end
111
+
112
+ [
113
+ base,
114
+ SPACING_GAP[@spacing],
115
+ (@spacing == 0 && @variant == :outline) ? "shadow-xs" : nil
116
+ ].compact
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,126 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="ruby-ui--toggle-group"
4
+ export default class extends Controller {
5
+ static targets = ["item", "input"]
6
+ static values = { type: String, name: String }
7
+
8
+ connect() {
9
+ this.reconcile()
10
+ }
11
+
12
+ select(event) {
13
+ const item = event.currentTarget
14
+ if (item.disabled) return
15
+
16
+ if (this.typeValue === "single") {
17
+ this.itemTargets.forEach(el => this.setPressed(el, el === item))
18
+ } else {
19
+ this.setPressed(item, !this.isPressed(item))
20
+ }
21
+
22
+ this.rebuildInputs()
23
+ this.updateRovingTabindex(item)
24
+ }
25
+
26
+ navigate(event) {
27
+ if (this.typeValue !== "single") return
28
+ const items = this.enabledItems()
29
+ if (items.length === 0) return
30
+
31
+ const isRtl = document.documentElement.dir === "rtl"
32
+ const currentIndex = items.indexOf(event.currentTarget)
33
+ let nextIndex = currentIndex
34
+
35
+ switch (event.key) {
36
+ case "ArrowRight":
37
+ case "ArrowDown":
38
+ nextIndex = (currentIndex + (isRtl && event.key === "ArrowRight" ? -1 : 1) + items.length) % items.length
39
+ break
40
+ case "ArrowLeft":
41
+ case "ArrowUp":
42
+ nextIndex = (currentIndex + (isRtl && event.key === "ArrowLeft" ? 1 : -1) + items.length) % items.length
43
+ break
44
+ case "Home":
45
+ nextIndex = 0
46
+ break
47
+ case "End":
48
+ nextIndex = items.length - 1
49
+ break
50
+ case " ":
51
+ case "Enter":
52
+ event.preventDefault()
53
+ event.currentTarget.click()
54
+ return
55
+ default:
56
+ return
57
+ }
58
+
59
+ event.preventDefault()
60
+ const next = items[nextIndex]
61
+ this.updateRovingTabindex(next)
62
+ next.focus()
63
+ }
64
+
65
+ reconcile() {
66
+ if (this.typeValue === "single") {
67
+ const pressed = this.itemTargets.find(el => this.isPressed(el))
68
+ const first = pressed || this.enabledItems()[0]
69
+ this.itemTargets.forEach(el => {
70
+ el.setAttribute("tabindex", el === first ? "0" : "-1")
71
+ })
72
+ } else {
73
+ this.itemTargets.forEach(el => el.setAttribute("tabindex", "0"))
74
+ }
75
+ this.rebuildInputs()
76
+ }
77
+
78
+ isPressed(item) {
79
+ return item.dataset.state === "on"
80
+ }
81
+
82
+ setPressed(item, pressed) {
83
+ item.dataset.state = pressed ? "on" : "off"
84
+ if (this.typeValue === "single") {
85
+ item.setAttribute("aria-checked", pressed ? "true" : "false")
86
+ } else {
87
+ item.setAttribute("aria-pressed", pressed ? "true" : "false")
88
+ }
89
+ }
90
+
91
+ updateRovingTabindex(focusedItem) {
92
+ if (this.typeValue !== "single") return
93
+ this.itemTargets.forEach(el => {
94
+ el.setAttribute("tabindex", el === focusedItem ? "0" : "-1")
95
+ })
96
+ }
97
+
98
+ enabledItems() {
99
+ return this.itemTargets.filter(el => !el.disabled)
100
+ }
101
+
102
+ rebuildInputs() {
103
+ if (!this.nameValue) return
104
+ this.inputTargets.forEach(el => el.remove())
105
+
106
+ const pressed = this.itemTargets.filter(el => this.isPressed(el))
107
+
108
+ if (this.typeValue === "single") {
109
+ const val = pressed[0]?.dataset.value || ""
110
+ this.element.appendChild(this.buildInput(this.nameValue, val))
111
+ } else {
112
+ pressed.forEach(item => {
113
+ this.element.appendChild(this.buildInput(`${this.nameValue}[]`, item.dataset.value))
114
+ })
115
+ }
116
+ }
117
+
118
+ buildInput(name, value) {
119
+ const input = document.createElement("input")
120
+ input.type = "hidden"
121
+ input.name = name
122
+ input.value = value
123
+ input.setAttribute("data-ruby-ui--toggle-group-target", "input")
124
+ return input
125
+ }
126
+ }