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.
- checksums.yaml +4 -4
- data/lib/generators/ruby_ui/component_generator.rb +5 -1
- data/lib/generators/ruby_ui/dependencies.yml +32 -0
- data/lib/generators/ruby_ui/install/install_generator.rb +1 -1
- data/lib/generators/ruby_ui/install/templates/tailwind.css.erb +1 -1
- data/lib/generators/ruby_ui/javascript_utils.rb +27 -6
- data/lib/ruby_ui/avatar/avatar.rb +3 -0
- data/lib/ruby_ui/avatar/avatar_controller.js +33 -0
- data/lib/ruby_ui/avatar/avatar_fallback.rb +3 -0
- data/lib/ruby_ui/avatar/avatar_image.rb +4 -0
- data/lib/ruby_ui/base.rb +6 -0
- data/lib/ruby_ui/calendar/calendar.rb +3 -1
- data/lib/ruby_ui/calendar/calendar_controller.js +66 -7
- data/lib/ruby_ui/calendar/calendar_days.rb +20 -0
- data/lib/ruby_ui/calendar/calendar_docs.rb +9 -0
- data/lib/ruby_ui/combobox/combobox_badge.rb +17 -0
- data/lib/ruby_ui/combobox/combobox_badge_trigger.rb +47 -0
- data/lib/ruby_ui/combobox/combobox_clear_button.rb +40 -0
- data/lib/ruby_ui/combobox/combobox_controller.js +4 -2
- data/lib/ruby_ui/combobox/combobox_docs.rb +199 -64
- data/lib/ruby_ui/combobox/combobox_input_trigger.rb +64 -0
- data/lib/ruby_ui/combobox/combobox_item_indicator.rb +30 -0
- data/lib/ruby_ui/command/command_controller.js +10 -19
- data/lib/ruby_ui/command/command_dialog.rb +4 -1
- data/lib/ruby_ui/command/command_dialog_content.rb +2 -2
- data/lib/ruby_ui/command/command_dialog_controller.js +34 -0
- data/lib/ruby_ui/command/command_dialog_trigger.rb +2 -2
- data/lib/ruby_ui/data_table/data_table.rb +29 -0
- data/lib/ruby_ui/data_table/data_table_bulk_actions.rb +18 -0
- data/lib/ruby_ui/data_table/data_table_column_toggle.rb +62 -0
- data/lib/ruby_ui/data_table/data_table_column_visibility_controller.js +14 -0
- data/lib/ruby_ui/data_table/data_table_controller.js +57 -0
- data/lib/ruby_ui/data_table/data_table_docs.rb +180 -0
- data/lib/ruby_ui/data_table/data_table_expand_toggle.rb +53 -0
- data/lib/ruby_ui/data_table/data_table_form.rb +39 -0
- data/lib/ruby_ui/data_table/data_table_kaminari_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_manual_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_pagination.rb +100 -0
- data/lib/ruby_ui/data_table/data_table_pagination_bar.rb +15 -0
- data/lib/ruby_ui/data_table/data_table_pagy_adapter.rb +17 -0
- data/lib/ruby_ui/data_table/data_table_per_page_select.rb +35 -0
- data/lib/ruby_ui/data_table/data_table_row_checkbox.rb +30 -0
- data/lib/ruby_ui/data_table/data_table_search.rb +57 -0
- data/lib/ruby_ui/data_table/data_table_search_controller.js +62 -0
- data/lib/ruby_ui/data_table/data_table_select_all_checkbox.rb +21 -0
- data/lib/ruby_ui/data_table/data_table_selection_summary.rb +25 -0
- data/lib/ruby_ui/data_table/data_table_sort_head.rb +112 -0
- data/lib/ruby_ui/data_table/data_table_toolbar.rb +15 -0
- data/lib/ruby_ui/date_picker/date_picker.rb +85 -0
- data/lib/ruby_ui/date_picker/date_picker_docs.rb +23 -0
- data/lib/ruby_ui/native_select/native_select.rb +39 -0
- data/lib/ruby_ui/native_select/native_select_docs.rb +83 -0
- data/lib/ruby_ui/native_select/native_select_group.rb +15 -0
- data/lib/ruby_ui/native_select/native_select_icon.rb +39 -0
- data/lib/ruby_ui/native_select/native_select_option.rb +15 -0
- data/lib/ruby_ui/select/select_value.rb +2 -1
- data/lib/ruby_ui/sheet/sheet.rb +9 -1
- data/lib/ruby_ui/sheet/sheet_controller.js +6 -0
- data/lib/ruby_ui/theme_toggle/theme_toggle.rb +14 -2
- data/lib/ruby_ui/theme_toggle/theme_toggle_controller.js +27 -19
- data/lib/ruby_ui/theme_toggle/theme_toggle_docs.rb +12 -42
- data/lib/ruby_ui/toast/toast.rb +18 -0
- data/lib/ruby_ui/toast/toast_action.rb +27 -0
- data/lib/ruby_ui/toast/toast_cancel.rb +27 -0
- data/lib/ruby_ui/toast/toast_close.rb +40 -0
- data/lib/ruby_ui/toast/toast_controller.js +151 -0
- data/lib/ruby_ui/toast/toast_description.rb +18 -0
- data/lib/ruby_ui/toast/toast_docs.rb +12 -0
- data/lib/ruby_ui/toast/toast_icon.rb +65 -0
- data/lib/ruby_ui/toast/toast_item.rb +72 -0
- data/lib/ruby_ui/toast/toast_region.rb +124 -0
- data/lib/ruby_ui/toast/toast_title.rb +18 -0
- data/lib/ruby_ui/toast/toaster_controller.js +306 -0
- data/lib/ruby_ui/toggle/toggle.rb +101 -0
- data/lib/ruby_ui/toggle/toggle_controller.js +33 -0
- data/lib/ruby_ui/toggle_group/toggle_group.rb +119 -0
- data/lib/ruby_ui/toggle_group/toggle_group_controller.js +126 -0
- data/lib/ruby_ui/toggle_group/toggle_group_item.rb +67 -0
- data/lib/ruby_ui/tooltip/tooltip_content.rb +12 -5
- data/lib/ruby_ui/tooltip/tooltip_controller.js +58 -22
- data/lib/ruby_ui/tooltip/tooltip_docs.rb +13 -0
- data/lib/ruby_ui/tooltip/tooltip_trigger.rb +10 -3
- data/lib/ruby_ui.rb +3 -1
- metadata +66 -10
- data/lib/ruby_ui/theme_toggle/set_dark_mode.rb +0 -16
- 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
|
+
}
|