keystone_ui 0.6.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 (131) hide show
  1. checksums.yaml +7 -0
  2. data/app/assets/javascripts/keystone_ui/accordion_controller.js +21 -0
  3. data/app/assets/javascripts/keystone_ui/auto_submit_controller.js +7 -0
  4. data/app/assets/javascripts/keystone_ui/chartjs.js +24 -0
  5. data/app/assets/javascripts/keystone_ui/clipboard_controller.js +25 -0
  6. data/app/assets/javascripts/keystone_ui/color_picker_controller.js +158 -0
  7. data/app/assets/javascripts/keystone_ui/column_picker_controller.js +47 -0
  8. data/app/assets/javascripts/keystone_ui/dismiss_controller.js +7 -0
  9. data/app/assets/javascripts/keystone_ui/dropdown_controller.js +16 -0
  10. data/app/assets/javascripts/keystone_ui/file_upload_controller.js +61 -0
  11. data/app/assets/javascripts/keystone_ui/index.js +33 -0
  12. data/app/assets/javascripts/keystone_ui/line_chart_controller.js +38 -0
  13. data/app/assets/javascripts/keystone_ui/modal_controller.js +19 -0
  14. data/app/assets/javascripts/keystone_ui/multi_select_controller.js +35 -0
  15. data/app/assets/javascripts/keystone_ui/stat_card_info_controller.js +9 -0
  16. data/app/assets/javascripts/keystone_ui/swipe_deck_controller.js +123 -0
  17. data/app/assets/javascripts/keystone_ui/tab_switcher_controller.js +19 -0
  18. data/app/assets/tailwind/keystone_ui_engine/color_picker.css +31 -0
  19. data/app/assets/tailwind/keystone_ui_engine/engine.css +7 -0
  20. data/app/assets/tailwind/keystone_ui_engine/nav.css +175 -0
  21. data/app/assets/tailwind/keystone_ui_engine/theme.css +32 -0
  22. data/app/components/keystone/ui/accordion_component.html.erb +19 -0
  23. data/app/components/keystone/ui/accordion_component.rb +51 -0
  24. data/app/components/keystone/ui/alert_component.html.erb +15 -0
  25. data/app/components/keystone/ui/alert_component.rb +58 -0
  26. data/app/components/keystone/ui/badge_component.html.erb +1 -0
  27. data/app/components/keystone/ui/badge_component.rb +28 -0
  28. data/app/components/keystone/ui/bottom_nav_component.html.erb +3 -0
  29. data/app/components/keystone/ui/bottom_nav_component.rb +13 -0
  30. data/app/components/keystone/ui/bottom_nav_item_component.html.erb +4 -0
  31. data/app/components/keystone/ui/bottom_nav_item_component.rb +24 -0
  32. data/app/components/keystone/ui/button_component.html.erb +1 -0
  33. data/app/components/keystone/ui/button_component.rb +57 -0
  34. data/app/components/keystone/ui/card_component.html.erb +9 -0
  35. data/app/components/keystone/ui/card_component.rb +33 -0
  36. data/app/components/keystone/ui/card_link_component.html.erb +3 -0
  37. data/app/components/keystone/ui/card_link_component.rb +25 -0
  38. data/app/components/keystone/ui/chart_card_component.html.erb +6 -0
  39. data/app/components/keystone/ui/chart_card_component.rb +35 -0
  40. data/app/components/keystone/ui/code_component.html.erb +6 -0
  41. data/app/components/keystone/ui/code_component.rb +40 -0
  42. data/app/components/keystone/ui/color_picker_component.html.erb +37 -0
  43. data/app/components/keystone/ui/color_picker_component.rb +31 -0
  44. data/app/components/keystone/ui/column.rb +21 -0
  45. data/app/components/keystone/ui/column_picker_component.html.erb +20 -0
  46. data/app/components/keystone/ui/column_picker_component.rb +35 -0
  47. data/app/components/keystone/ui/copy_button_component.html.erb +11 -0
  48. data/app/components/keystone/ui/copy_button_component.rb +39 -0
  49. data/app/components/keystone/ui/cta_banner_component.html.erb +9 -0
  50. data/app/components/keystone/ui/cta_banner_component.rb +39 -0
  51. data/app/components/keystone/ui/data_table_component.html.erb +54 -0
  52. data/app/components/keystone/ui/data_table_component.rb +173 -0
  53. data/app/components/keystone/ui/disclosure_component.html.erb +9 -0
  54. data/app/components/keystone/ui/disclosure_component.rb +46 -0
  55. data/app/components/keystone/ui/feature_grid_component.html.erb +17 -0
  56. data/app/components/keystone/ui/feature_grid_component.rb +55 -0
  57. data/app/components/keystone/ui/file_upload_component.html.erb +18 -0
  58. data/app/components/keystone/ui/file_upload_component.rb +85 -0
  59. data/app/components/keystone/ui/form_component.html.erb +3 -0
  60. data/app/components/keystone/ui/form_component.rb +27 -0
  61. data/app/components/keystone/ui/form_field_component.html.erb +36 -0
  62. data/app/components/keystone/ui/form_field_component.rb +83 -0
  63. data/app/components/keystone/ui/form_page_component.html.erb +10 -0
  64. data/app/components/keystone/ui/form_page_component.rb +21 -0
  65. data/app/components/keystone/ui/funnel_component.html.erb +14 -0
  66. data/app/components/keystone/ui/funnel_component.rb +85 -0
  67. data/app/components/keystone/ui/grid_component.html.erb +3 -0
  68. data/app/components/keystone/ui/grid_component.rb +38 -0
  69. data/app/components/keystone/ui/hero_component.html.erb +27 -0
  70. data/app/components/keystone/ui/hero_component.rb +74 -0
  71. data/app/components/keystone/ui/input_component.html.erb +1 -0
  72. data/app/components/keystone/ui/input_component.rb +57 -0
  73. data/app/components/keystone/ui/line_chart_component.html.erb +3 -0
  74. data/app/components/keystone/ui/line_chart_component.rb +48 -0
  75. data/app/components/keystone/ui/mobile_actions_component.html.erb +11 -0
  76. data/app/components/keystone/ui/mobile_actions_component.rb +21 -0
  77. data/app/components/keystone/ui/mobile_header_component.html.erb +11 -0
  78. data/app/components/keystone/ui/mobile_header_component.rb +38 -0
  79. data/app/components/keystone/ui/modal_component.html.erb +13 -0
  80. data/app/components/keystone/ui/modal_component.rb +68 -0
  81. data/app/components/keystone/ui/multi_select_component.html.erb +21 -0
  82. data/app/components/keystone/ui/multi_select_component.rb +40 -0
  83. data/app/components/keystone/ui/nav_dropdown_component.html.erb +11 -0
  84. data/app/components/keystone/ui/nav_dropdown_component.rb +33 -0
  85. data/app/components/keystone/ui/nav_item_component.html.erb +1 -0
  86. data/app/components/keystone/ui/nav_item_component.rb +21 -0
  87. data/app/components/keystone/ui/navbar_component.html.erb +34 -0
  88. data/app/components/keystone/ui/navbar_component.rb +32 -0
  89. data/app/components/keystone/ui/option_card_component.html.erb +9 -0
  90. data/app/components/keystone/ui/option_card_component.rb +28 -0
  91. data/app/components/keystone/ui/page_component.html.erb +3 -0
  92. data/app/components/keystone/ui/page_component.rb +40 -0
  93. data/app/components/keystone/ui/page_header_component.html.erb +17 -0
  94. data/app/components/keystone/ui/page_header_component.rb +42 -0
  95. data/app/components/keystone/ui/panel_component.html.erb +3 -0
  96. data/app/components/keystone/ui/panel_component.rb +25 -0
  97. data/app/components/keystone/ui/pipeline_component.html.erb +34 -0
  98. data/app/components/keystone/ui/pipeline_component.rb +48 -0
  99. data/app/components/keystone/ui/progress_component.html.erb +8 -0
  100. data/app/components/keystone/ui/progress_component.rb +35 -0
  101. data/app/components/keystone/ui/radio_card_component.html.erb +9 -0
  102. data/app/components/keystone/ui/radio_card_component.rb +42 -0
  103. data/app/components/keystone/ui/section_component.html.erb +18 -0
  104. data/app/components/keystone/ui/section_component.rb +32 -0
  105. data/app/components/keystone/ui/select_component.html.erb +8 -0
  106. data/app/components/keystone/ui/select_component.rb +38 -0
  107. data/app/components/keystone/ui/settings_link_component.html.erb +4 -0
  108. data/app/components/keystone/ui/settings_link_component.rb +26 -0
  109. data/app/components/keystone/ui/show_page_component.html.erb +4 -0
  110. data/app/components/keystone/ui/show_page_component.rb +21 -0
  111. data/app/components/keystone/ui/stat_card_component.html.erb +22 -0
  112. data/app/components/keystone/ui/stat_card_component.rb +92 -0
  113. data/app/components/keystone/ui/swipe_deck_component.html.erb +39 -0
  114. data/app/components/keystone/ui/swipe_deck_component.rb +52 -0
  115. data/app/components/keystone/ui/tab_switcher_component.html.erb +14 -0
  116. data/app/components/keystone/ui/tab_switcher_component.rb +33 -0
  117. data/app/components/keystone/ui/textarea_component.html.erb +1 -0
  118. data/app/components/keystone/ui/textarea_component.rb +38 -0
  119. data/app/helpers/keystone_ui_helper.rb +195 -0
  120. data/config/importmap.rb +19 -0
  121. data/lib/generators/keystone/install_generator.rb +90 -0
  122. data/lib/keystone_ui/configuration.rb +24 -0
  123. data/lib/keystone_ui/engine.rb +47 -0
  124. data/lib/keystone_ui/safelist.rb +106 -0
  125. data/lib/keystone_ui/version.rb +5 -0
  126. data/lib/keystone_ui.rb +9 -0
  127. data/the_local/agents/keystone_ui-develop.md +297 -0
  128. data/the_local/agents/keystone_ui-info.md +81 -0
  129. data/the_local/agents/keystone_ui-install.md +137 -0
  130. data/the_local/interface.yml +110 -0
  131. metadata +203 -0
@@ -0,0 +1,25 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ successMessage: { type: String, default: "Copied!" },
6
+ errorMessage: { type: String, default: "Failed!" }
7
+ }
8
+
9
+ async copy() {
10
+ const text = this.element.dataset.clipboardText
11
+ try {
12
+ await navigator.clipboard.writeText(text)
13
+ this.flash(this.successMessageValue)
14
+ } catch {
15
+ this.flash(this.errorMessageValue)
16
+ }
17
+ }
18
+
19
+ flash(message) {
20
+ const label = this.element.querySelector("span") || this.element.lastChild
21
+ const original = label.textContent
22
+ label.textContent = message
23
+ setTimeout(() => { label.textContent = original }, 1500)
24
+ }
25
+ }
@@ -0,0 +1,158 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["swatch", "hexLabel", "input", "panel", "svArea", "svGradient", "svCursor", "hueSlider"]
5
+ static values = { value: String }
6
+
7
+ connect() {
8
+ this.hue = 0
9
+ this.sat = 1
10
+ this.val = 1
11
+ this.dragging = false
12
+
13
+ if (this.valueValue) {
14
+ this.setFromHex(this.valueValue)
15
+ }
16
+
17
+ this.boundPointerMove = this.svMove.bind(this)
18
+ this.boundPointerUp = this.svUp.bind(this)
19
+ this.boundClickOutside = this.clickOutside.bind(this)
20
+ }
21
+
22
+ disconnect() {
23
+ document.removeEventListener("pointermove", this.boundPointerMove)
24
+ document.removeEventListener("pointerup", this.boundPointerUp)
25
+ document.removeEventListener("click", this.boundClickOutside)
26
+ }
27
+
28
+ toggle(event) {
29
+ event.stopPropagation()
30
+ const panel = this.panelTarget
31
+ const isHidden = panel.classList.contains("hidden")
32
+
33
+ if (isHidden) {
34
+ panel.classList.remove("hidden")
35
+ document.addEventListener("click", this.boundClickOutside)
36
+ } else {
37
+ this.close()
38
+ }
39
+ }
40
+
41
+ close() {
42
+ this.panelTarget.classList.add("hidden")
43
+ document.removeEventListener("click", this.boundClickOutside)
44
+ }
45
+
46
+ clickOutside(event) {
47
+ if (!this.element.contains(event.target)) {
48
+ this.close()
49
+ }
50
+ }
51
+
52
+ hueChanged() {
53
+ this.hue = parseInt(this.hueSliderTarget.value)
54
+ this.updateSvGradient()
55
+ this.updateColor()
56
+ }
57
+
58
+ svDown(event) {
59
+ event.preventDefault()
60
+ this.dragging = true
61
+ document.addEventListener("pointermove", this.boundPointerMove)
62
+ document.addEventListener("pointerup", this.boundPointerUp)
63
+ this.updateSvFromPointer(event)
64
+ }
65
+
66
+ svMove(event) {
67
+ if (!this.dragging) return
68
+ event.preventDefault()
69
+ this.updateSvFromPointer(event)
70
+ }
71
+
72
+ svUp() {
73
+ this.dragging = false
74
+ document.removeEventListener("pointermove", this.boundPointerMove)
75
+ document.removeEventListener("pointerup", this.boundPointerUp)
76
+ }
77
+
78
+ updateSvFromPointer(event) {
79
+ const rect = this.svAreaTarget.getBoundingClientRect()
80
+ const x = Math.max(0, Math.min(event.clientX - rect.left, rect.width))
81
+ const y = Math.max(0, Math.min(event.clientY - rect.top, rect.height))
82
+
83
+ this.sat = x / rect.width
84
+ this.val = 1 - y / rect.height
85
+
86
+ this.svCursorTarget.style.left = `${(this.sat * 100)}%`
87
+ this.svCursorTarget.style.top = `${((1 - this.val) * 100)}%`
88
+
89
+ this.updateColor()
90
+ }
91
+
92
+ updateColor() {
93
+ const hex = this.hsvToHex(this.hue, this.sat, this.val)
94
+ this.swatchTarget.style.backgroundColor = hex
95
+ this.hexLabelTarget.textContent = hex
96
+ this.inputTarget.value = hex
97
+
98
+ this.inputTarget.dispatchEvent(new Event("change", { bubbles: true }))
99
+ }
100
+
101
+ updateSvGradient() {
102
+ const hueColor = `hsl(${this.hue}, 100%, 50%)`
103
+ this.svGradientTarget.style.background = `linear-gradient(to right, #fff, ${hueColor})`
104
+ }
105
+
106
+ setFromHex(hex) {
107
+ const { h, s, v } = this.hexToHsv(hex)
108
+ this.hue = h
109
+ this.sat = s
110
+ this.val = v
111
+
112
+ if (this.hasHueSliderTarget) this.hueSliderTarget.value = h
113
+ this.updateSvGradient()
114
+
115
+ if (this.hasSvCursorTarget) {
116
+ this.svCursorTarget.style.left = `${(s * 100)}%`
117
+ this.svCursorTarget.style.top = `${((1 - v) * 100)}%`
118
+ }
119
+ }
120
+
121
+ hsvToHex(h, s, v) {
122
+ const c = v * s
123
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
124
+ const m = v - c
125
+ let r, g, b
126
+
127
+ if (h < 60) { r = c; g = x; b = 0 }
128
+ else if (h < 120) { r = x; g = c; b = 0 }
129
+ else if (h < 180) { r = 0; g = c; b = x }
130
+ else if (h < 240) { r = 0; g = x; b = c }
131
+ else if (h < 300) { r = x; g = 0; b = c }
132
+ else { r = c; g = 0; b = x }
133
+
134
+ const toHex = (n) => Math.round((n + m) * 255).toString(16).padStart(2, "0")
135
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
136
+ }
137
+
138
+ hexToHsv(hex) {
139
+ const r = parseInt(hex.slice(1, 3), 16) / 255
140
+ const g = parseInt(hex.slice(3, 5), 16) / 255
141
+ const b = parseInt(hex.slice(5, 7), 16) / 255
142
+
143
+ const max = Math.max(r, g, b)
144
+ const min = Math.min(r, g, b)
145
+ const d = max - min
146
+
147
+ let h = 0
148
+ if (d !== 0) {
149
+ if (max === r) h = 60 * (((g - b) / d) % 6)
150
+ else if (max === g) h = 60 * ((b - r) / d + 2)
151
+ else h = 60 * ((r - g) / d + 4)
152
+ }
153
+ if (h < 0) h += 360
154
+
155
+ const s = max === 0 ? 0 : d / max
156
+ return { h: Math.round(h), s, v: max }
157
+ }
158
+ }
@@ -0,0 +1,47 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu"]
5
+ static values = { saveUrl: String }
6
+
7
+ connect() {
8
+ this._close = this.close.bind(this)
9
+ document.addEventListener("click", this._close)
10
+ }
11
+
12
+ disconnect() {
13
+ document.removeEventListener("click", this._close)
14
+ }
15
+
16
+ toggle(event) {
17
+ event.stopPropagation()
18
+ this.menuTarget.classList.toggle("hidden")
19
+ }
20
+
21
+ close(event) {
22
+ if (!this.element.contains(event.target)) {
23
+ this.menuTarget.classList.add("hidden")
24
+ }
25
+ }
26
+
27
+ save() {
28
+ const allCheckboxes = this.element.querySelectorAll("input[type=checkbox]")
29
+ const hiddenColumns = Array.from(allCheckboxes)
30
+ .filter(cb => !cb.checked)
31
+ .map(cb => cb.value)
32
+
33
+ if (this.hasSaveUrlValue) {
34
+ const token = document.querySelector('meta[name="csrf-token"]')?.content
35
+ fetch(this.saveUrlValue, {
36
+ method: "PATCH",
37
+ headers: {
38
+ "Content-Type": "application/json",
39
+ "X-CSRF-Token": token
40
+ },
41
+ body: JSON.stringify({ hidden_columns: hiddenColumns })
42
+ }).then(() => {
43
+ Turbo.visit(window.location.href, { action: "replace" })
44
+ })
45
+ }
46
+ }
47
+ }
@@ -0,0 +1,7 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ close() {
5
+ this.element.remove()
6
+ }
7
+ }
@@ -0,0 +1,16 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu"]
5
+
6
+ toggle(event) {
7
+ event.stopPropagation()
8
+ this.menuTarget.classList.toggle("hidden")
9
+ }
10
+
11
+ hide(event) {
12
+ if (!this.element.contains(event.target)) {
13
+ this.menuTarget.classList.add("hidden")
14
+ }
15
+ }
16
+ }
@@ -0,0 +1,61 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["input", "dropZone", "fileName"]
5
+
6
+ connect() {
7
+ this._onDragOver = this.dragOver.bind(this)
8
+ this._onDragLeave = this.dragLeave.bind(this)
9
+ this._onDrop = this.drop.bind(this)
10
+
11
+ this.dropZoneTarget.addEventListener("dragover", this._onDragOver)
12
+ this.dropZoneTarget.addEventListener("dragleave", this._onDragLeave)
13
+ this.dropZoneTarget.addEventListener("drop", this._onDrop)
14
+ }
15
+
16
+ disconnect() {
17
+ this.dropZoneTarget.removeEventListener("dragover", this._onDragOver)
18
+ this.dropZoneTarget.removeEventListener("dragleave", this._onDragLeave)
19
+ this.dropZoneTarget.removeEventListener("drop", this._onDrop)
20
+ }
21
+
22
+ dragOver(event) {
23
+ event.preventDefault()
24
+ this.dropZoneTarget.classList.add("border-accent-500", "bg-accent-50", "dark:bg-accent-900/10")
25
+ this.dropZoneTarget.classList.remove("border-gray-300", "dark:border-zinc-600")
26
+ }
27
+
28
+ dragLeave(event) {
29
+ event.preventDefault()
30
+ this.dropZoneTarget.classList.remove("border-accent-500", "bg-accent-50", "dark:bg-accent-900/10")
31
+ this.dropZoneTarget.classList.add("border-gray-300", "dark:border-zinc-600")
32
+ }
33
+
34
+ drop(event) {
35
+ event.preventDefault()
36
+ this.dragLeave(event)
37
+
38
+ const files = event.dataTransfer.files
39
+ if (files.length > 0) {
40
+ this.inputTarget.files = files
41
+ this.updateFileName()
42
+ }
43
+ }
44
+
45
+ select() {
46
+ this.updateFileName()
47
+ }
48
+
49
+ updateFileName() {
50
+ if (!this.hasFileNameTarget) return
51
+
52
+ const files = this.inputTarget.files
53
+ if (files.length === 0) {
54
+ this.fileNameTarget.textContent = ""
55
+ } else if (files.length === 1) {
56
+ this.fileNameTarget.textContent = files[0].name
57
+ } else {
58
+ this.fileNameTarget.textContent = `${files.length} files selected`
59
+ }
60
+ }
61
+ }
@@ -0,0 +1,33 @@
1
+ import ColorPickerController from "keystone_ui/color_picker_controller"
2
+ import MultiSelectController from "keystone_ui/multi_select_controller"
3
+ import SwipeDeckController from "keystone_ui/swipe_deck_controller"
4
+ import ColumnPickerController from "keystone_ui/column_picker_controller"
5
+ import FileUploadController from "keystone_ui/file_upload_controller"
6
+ import DropdownController from "keystone_ui/dropdown_controller"
7
+ import DismissController from "keystone_ui/dismiss_controller"
8
+ import ModalController from "keystone_ui/modal_controller"
9
+ import ClipboardController from "keystone_ui/clipboard_controller"
10
+ import TabSwitcherController from "keystone_ui/tab_switcher_controller"
11
+ import AccordionController from "keystone_ui/accordion_controller"
12
+ import StatCardInfoController from "keystone_ui/stat_card_info_controller"
13
+ import AutoSubmitController from "keystone_ui/auto_submit_controller"
14
+ import LineChartController from "keystone_ui/line_chart_controller"
15
+
16
+ export function registerControllers(application) {
17
+ application.register("color-picker", ColorPickerController)
18
+ application.register("multi-select", MultiSelectController)
19
+ application.register("swipe-deck", SwipeDeckController)
20
+ application.register("column-picker", ColumnPickerController)
21
+ application.register("file-upload", FileUploadController)
22
+ application.register("dropdown", DropdownController)
23
+ application.register("dismiss", DismissController)
24
+ application.register("modal", ModalController)
25
+ application.register("clipboard", ClipboardController)
26
+ application.register("tab-switcher", TabSwitcherController)
27
+ application.register("accordion", AccordionController)
28
+ application.register("stat-card-info", StatCardInfoController)
29
+ application.register("auto-submit", AutoSubmitController)
30
+ application.register("line-chart", LineChartController)
31
+ }
32
+
33
+ export { ColorPickerController, MultiSelectController, SwipeDeckController, ColumnPickerController, FileUploadController, DropdownController, DismissController, ModalController, ClipboardController, TabSwitcherController, AccordionController, StatCardInfoController, AutoSubmitController, LineChartController }
@@ -0,0 +1,38 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+ import { Chart, registerables } from "chart.js"
3
+
4
+ Chart.register(...registerables)
5
+
6
+ export default class extends Controller {
7
+ static targets = ["canvas"]
8
+ static values = { data: Object }
9
+
10
+ connect() {
11
+ this.chart = new Chart(this.canvasTarget, {
12
+ type: "line",
13
+ data: this.resolveColors(this.dataValue),
14
+ options: {
15
+ responsive: true,
16
+ maintainAspectRatio: false,
17
+ interaction: { mode: "index", intersect: false }
18
+ }
19
+ })
20
+ }
21
+
22
+ disconnect() {
23
+ this.chart?.destroy()
24
+ }
25
+
26
+ resolveColors(data) {
27
+ const styles = getComputedStyle(this.element)
28
+ const resolve = (color) => {
29
+ const match = typeof color === "string" && color.match(/^var\((--[^)]+)\)$/)
30
+ return match ? styles.getPropertyValue(match[1]).trim() || color : color
31
+ }
32
+
33
+ data.datasets.forEach((dataset) => {
34
+ if (dataset.borderColor) dataset.borderColor = resolve(dataset.borderColor)
35
+ })
36
+ return data
37
+ }
38
+ }
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["backdrop"]
5
+
6
+ open() {
7
+ this.backdropTarget.classList.remove("hidden")
8
+ }
9
+
10
+ close() {
11
+ this.backdropTarget.classList.add("hidden")
12
+ }
13
+
14
+ closeOnBackdrop(event) {
15
+ if (event.target === this.backdropTarget) {
16
+ this.close()
17
+ }
18
+ }
19
+ }
@@ -0,0 +1,35 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["menu", "label"]
5
+ static values = { label: String }
6
+
7
+ connect() {
8
+ this._close = this.close.bind(this)
9
+ document.addEventListener("click", this._close)
10
+ }
11
+
12
+ disconnect() {
13
+ document.removeEventListener("click", this._close)
14
+ }
15
+
16
+ toggle(event) {
17
+ event.stopPropagation()
18
+ this.menuTarget.classList.toggle("hidden")
19
+ }
20
+
21
+ close(event) {
22
+ if (!this.element.contains(event.target)) {
23
+ this.menuTarget.classList.add("hidden")
24
+ }
25
+ }
26
+
27
+ updateLabel() {
28
+ const checked = this.element.querySelectorAll("input[type=checkbox]:checked")
29
+ if (checked.length === 0) {
30
+ this.labelTarget.textContent = `All ${this.labelValue}`
31
+ } else {
32
+ this.labelTarget.textContent = `${checked.length} selected`
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,9 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ toggle() {
7
+ this.panelTarget.classList.toggle("hidden")
8
+ }
9
+ }
@@ -0,0 +1,123 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["card", "stack"]
5
+ static values = { emptyTitle: String, emptySubtitle: String }
6
+
7
+ connect() {
8
+ this.currentIndex = 0
9
+ this.setupTouch()
10
+ }
11
+
12
+ complete() {
13
+ const card = this.currentCard
14
+ if (!card) return
15
+
16
+ const itemId = card.dataset.itemId
17
+ const valueInput = card.querySelector("[data-swipe-deck-value]")
18
+ const value = valueInput ? valueInput.value : null
19
+
20
+ this.dispatch("complete", { detail: { itemId, value, card } })
21
+ this.animateOut(card, "right", "ring-2 ring-green-400")
22
+ }
23
+
24
+ skip() {
25
+ const card = this.currentCard
26
+ if (!card) return
27
+
28
+ const itemId = card.dataset.itemId
29
+ this.dispatch("skip", { detail: { itemId, card } })
30
+ this.animateOut(card, "left", "ring-2 ring-red-400")
31
+ }
32
+
33
+ get currentCard() {
34
+ return this.cardTargets[this.currentIndex]
35
+ }
36
+
37
+ animateOut(card, direction, colorClass) {
38
+ if (colorClass) {
39
+ colorClass.split(" ").forEach(c => card.classList.add(c))
40
+ }
41
+ const translateX = direction === "right" ? "150%" : "-150%"
42
+ const rotate = direction === "right" ? "15" : "-15"
43
+ card.style.transition = "transform 0.3s ease-out, opacity 0.3s ease-out"
44
+ card.style.transform = `translateX(${translateX}) rotate(${rotate}deg)`
45
+ card.style.opacity = "0"
46
+
47
+ setTimeout(() => {
48
+ card.style.display = "none"
49
+ this.currentIndex++
50
+ this.updateStack()
51
+ }, 300)
52
+ }
53
+
54
+ updateStack() {
55
+ const remaining = this.cardTargets.slice(this.currentIndex)
56
+ remaining.forEach((card, index) => {
57
+ card.style.transition = "transform 0.3s ease-out"
58
+ card.style.transform = `scale(${1 - index * 0.05}) translateY(${index * 8}px)`
59
+ card.style.zIndex = remaining.length - index
60
+ })
61
+
62
+ if (remaining.length === 0) {
63
+ const title = this.emptyTitleValue || "All done!"
64
+ const subtitle = this.emptySubtitleValue
65
+ let html = `
66
+ <div class="text-center py-16" data-testid="swipe-empty">
67
+ <p class="text-2xl font-semibold text-gray-900 dark:text-white">${title}</p>`
68
+ if (subtitle) {
69
+ html += `
70
+ <p class="mt-2 text-gray-500 dark:text-gray-400">${subtitle}</p>`
71
+ }
72
+ html += `
73
+ </div>`
74
+ this.stackTarget.innerHTML = html
75
+ }
76
+ }
77
+
78
+ clearSwipeColor(card) {
79
+ card.classList.remove("ring-2", "ring-green-400", "ring-red-400")
80
+ }
81
+
82
+ setupTouch() {
83
+ let startX = 0
84
+ let currentX = 0
85
+
86
+ const isInput = (e) => e.target.closest("input, textarea, select")
87
+
88
+ this.element.addEventListener("touchstart", (e) => {
89
+ if (isInput(e)) return
90
+ startX = e.touches[0].clientX
91
+ })
92
+
93
+ this.element.addEventListener("touchmove", (e) => {
94
+ if (isInput(e) || !this.currentCard) return
95
+ currentX = e.touches[0].clientX
96
+ const diff = currentX - startX
97
+ this.currentCard.style.transition = "none"
98
+ this.currentCard.style.transform = `translateX(${diff}px) rotate(${diff * 0.1}deg)`
99
+ this.clearSwipeColor(this.currentCard)
100
+ if (Math.abs(diff) > 30) {
101
+ this.currentCard.classList.add("ring-2", diff > 0 ? "ring-green-400" : "ring-red-400")
102
+ }
103
+ })
104
+
105
+ this.element.addEventListener("touchend", (e) => {
106
+ if (isInput(e) || !this.currentCard) return
107
+ const diff = currentX - startX
108
+ if (Math.abs(diff) > 100) {
109
+ if (diff > 0) {
110
+ this.complete()
111
+ } else {
112
+ this.skip()
113
+ }
114
+ } else {
115
+ this.clearSwipeColor(this.currentCard)
116
+ this.currentCard.style.transition = "transform 0.3s ease-out"
117
+ this.currentCard.style.transform = ""
118
+ }
119
+ startX = 0
120
+ currentX = 0
121
+ })
122
+ }
123
+ }
@@ -0,0 +1,19 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["tab"]
5
+
6
+ select(event) {
7
+ const index = parseInt(event.currentTarget.dataset.index)
8
+
9
+ this.tabTargets.forEach((tab) => {
10
+ if (parseInt(tab.dataset.index) === index) {
11
+ tab.setAttribute("data-active", "")
12
+ } else {
13
+ tab.removeAttribute("data-active")
14
+ }
15
+ })
16
+
17
+ this.dispatch("change", { detail: { index } })
18
+ }
19
+ }
@@ -0,0 +1,31 @@
1
+ /* Color picker hue slider styling */
2
+ input[type="range"].keystone-hue-slider {
3
+ -webkit-appearance: none;
4
+ appearance: none;
5
+ height: 0.75rem;
6
+ border-radius: 0.5rem;
7
+ background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
8
+ outline: none;
9
+ }
10
+
11
+ input[type="range"].keystone-hue-slider::-webkit-slider-thumb {
12
+ -webkit-appearance: none;
13
+ appearance: none;
14
+ width: 1rem;
15
+ height: 1rem;
16
+ border-radius: 50%;
17
+ background: white;
18
+ border: 2px solid rgba(0, 0, 0, 0.3);
19
+ cursor: pointer;
20
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
21
+ }
22
+
23
+ input[type="range"].keystone-hue-slider::-moz-range-thumb {
24
+ width: 1rem;
25
+ height: 1rem;
26
+ border-radius: 50%;
27
+ background: white;
28
+ border: 2px solid rgba(0, 0, 0, 0.3);
29
+ cursor: pointer;
30
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
31
+ }
@@ -0,0 +1,7 @@
1
+ /* Keystone UI components — tells Tailwind to scan for classes in component files */
2
+ @source "../../components/**/*.{erb,rb}";
3
+
4
+ /* Safelist: grid-cols classes built from static constants in GridComponent.
5
+ Belt-and-suspenders — ensures these classes survive JIT purging even if
6
+ the scanner misses the Ruby hash literals. */
7
+ @source inline("grid-cols-1 grid-cols-2 grid-cols-3 grid-cols-4 grid-cols-5 grid-cols-6 grid-cols-7 grid-cols-8 grid-cols-9 grid-cols-10 grid-cols-11 grid-cols-12 sm:grid-cols-1 sm:grid-cols-2 sm:grid-cols-3 sm:grid-cols-4 sm:grid-cols-5 sm:grid-cols-6 sm:grid-cols-7 sm:grid-cols-8 sm:grid-cols-9 sm:grid-cols-10 sm:grid-cols-11 sm:grid-cols-12 md:grid-cols-1 md:grid-cols-2 md:grid-cols-3 md:grid-cols-4 md:grid-cols-5 md:grid-cols-6 md:grid-cols-7 md:grid-cols-8 md:grid-cols-9 md:grid-cols-10 md:grid-cols-11 md:grid-cols-12 lg:grid-cols-1 lg:grid-cols-2 lg:grid-cols-3 lg:grid-cols-4 lg:grid-cols-5 lg:grid-cols-6 lg:grid-cols-7 lg:grid-cols-8 lg:grid-cols-9 lg:grid-cols-10 lg:grid-cols-11 lg:grid-cols-12");