view_primitives 0.1.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 (140) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +198 -0
  5. data/lib/generators/view_primitives/add/add_generator.rb +110 -0
  6. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.html.erb +10 -0
  7. data/lib/generators/view_primitives/add/templates/accordion/accordion_component.rb.tt +22 -0
  8. data/lib/generators/view_primitives/add/templates/accordion/accordion_controller.js +15 -0
  9. data/lib/generators/view_primitives/add/templates/accordion/accordion_item_component.rb.tt +47 -0
  10. data/lib/generators/view_primitives/add/templates/alert/alert_component.rb.tt +62 -0
  11. data/lib/generators/view_primitives/add/templates/alert_dialog/alert_dialog_component.rb.tt +55 -0
  12. data/lib/generators/view_primitives/add/templates/aspect_ratio/aspect_ratio_component.rb.tt +18 -0
  13. data/lib/generators/view_primitives/add/templates/audio/audio_component.rb.tt +51 -0
  14. data/lib/generators/view_primitives/add/templates/avatar/avatar_component.rb.tt +37 -0
  15. data/lib/generators/view_primitives/add/templates/badge/badge_component.rb.tt +35 -0
  16. data/lib/generators/view_primitives/add/templates/banner/banner_component.rb.tt +29 -0
  17. data/lib/generators/view_primitives/add/templates/bottom_nav/bottom_nav_component.rb.tt +38 -0
  18. data/lib/generators/view_primitives/add/templates/breadcrumb/breadcrumb_component.rb.tt +37 -0
  19. data/lib/generators/view_primitives/add/templates/button/button_component.rb.tt +61 -0
  20. data/lib/generators/view_primitives/add/templates/button_group/button_group_component.rb.tt +23 -0
  21. data/lib/generators/view_primitives/add/templates/calendar/calendar_component.rb.tt +121 -0
  22. data/lib/generators/view_primitives/add/templates/calendar/calendar_controller.js +86 -0
  23. data/lib/generators/view_primitives/add/templates/card/card_component.rb.tt +16 -0
  24. data/lib/generators/view_primitives/add/templates/card/card_content_component.rb.tt +16 -0
  25. data/lib/generators/view_primitives/add/templates/card/card_description_component.rb.tt +17 -0
  26. data/lib/generators/view_primitives/add/templates/card/card_footer_component.rb.tt +16 -0
  27. data/lib/generators/view_primitives/add/templates/card/card_header_component.rb.tt +17 -0
  28. data/lib/generators/view_primitives/add/templates/card/card_title_component.rb.tt +17 -0
  29. data/lib/generators/view_primitives/add/templates/carousel/carousel_component.rb.tt +102 -0
  30. data/lib/generators/view_primitives/add/templates/carousel/carousel_controller.js +48 -0
  31. data/lib/generators/view_primitives/add/templates/chart/chart_component.rb.tt +63 -0
  32. data/lib/generators/view_primitives/add/templates/chart/chart_controller.js +29 -0
  33. data/lib/generators/view_primitives/add/templates/chat_bubble/chat_bubble_component.rb.tt +53 -0
  34. data/lib/generators/view_primitives/add/templates/checkbox/checkbox_component.rb.tt +50 -0
  35. data/lib/generators/view_primitives/add/templates/collapsible/collapsible_component.rb.tt +31 -0
  36. data/lib/generators/view_primitives/add/templates/combobox/combobox_component.rb.tt +87 -0
  37. data/lib/generators/view_primitives/add/templates/combobox/combobox_controller.js +38 -0
  38. data/lib/generators/view_primitives/add/templates/command/command_component.rb.tt +85 -0
  39. data/lib/generators/view_primitives/add/templates/command/command_controller.js +50 -0
  40. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_component.rb.tt +47 -0
  41. data/lib/generators/view_primitives/add/templates/context_menu/context_menu_controller.js +20 -0
  42. data/lib/generators/view_primitives/add/templates/data_table/data_table_component.rb.tt +163 -0
  43. data/lib/generators/view_primitives/add/templates/data_table/data_table_controller.js +115 -0
  44. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_component.rb.tt +92 -0
  45. data/lib/generators/view_primitives/add/templates/date_picker/date_picker_controller.js +48 -0
  46. data/lib/generators/view_primitives/add/templates/device_mockup/device_mockup_component.rb.tt +65 -0
  47. data/lib/generators/view_primitives/add/templates/dialog/dialog_component.rb.tt +71 -0
  48. data/lib/generators/view_primitives/add/templates/dialog/dialog_controller.js +15 -0
  49. data/lib/generators/view_primitives/add/templates/drawer/drawer_component.rb.tt +62 -0
  50. data/lib/generators/view_primitives/add/templates/drawer/drawer_controller.js +15 -0
  51. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_controller.js +17 -0
  52. data/lib/generators/view_primitives/add/templates/dropdown_menu/dropdown_menu_component.rb.tt +53 -0
  53. data/lib/generators/view_primitives/add/templates/embed/embed_component.rb.tt +271 -0
  54. data/lib/generators/view_primitives/add/templates/embed/embed_controller.js +43 -0
  55. data/lib/generators/view_primitives/add/templates/figure/figure_component.rb.tt +24 -0
  56. data/lib/generators/view_primitives/add/templates/file_input/file_input_component.rb.tt +31 -0
  57. data/lib/generators/view_primitives/add/templates/floating_label/floating_label_component.rb.tt +54 -0
  58. data/lib/generators/view_primitives/add/templates/footer/footer_component.rb.tt +51 -0
  59. data/lib/generators/view_primitives/add/templates/form_field/form_field_component.rb.tt +51 -0
  60. data/lib/generators/view_primitives/add/templates/gallery/gallery_component.rb.tt +83 -0
  61. data/lib/generators/view_primitives/add/templates/gallery/gallery_controller.js +28 -0
  62. data/lib/generators/view_primitives/add/templates/hover_card/hover_card_component.rb.tt +43 -0
  63. data/lib/generators/view_primitives/add/templates/iframe/iframe_component.rb.tt +59 -0
  64. data/lib/generators/view_primitives/add/templates/image/image_component.rb.tt +38 -0
  65. data/lib/generators/view_primitives/add/templates/indicator/indicator_component.rb.tt +46 -0
  66. data/lib/generators/view_primitives/add/templates/input/input_component.rb.tt +28 -0
  67. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_component.rb.tt +65 -0
  68. data/lib/generators/view_primitives/add/templates/input_otp/input_otp_controller.js +39 -0
  69. data/lib/generators/view_primitives/add/templates/kbd/kbd_component.rb.tt +21 -0
  70. data/lib/generators/view_primitives/add/templates/label/label_component.rb.tt +23 -0
  71. data/lib/generators/view_primitives/add/templates/list_group/list_group_component.rb.tt +16 -0
  72. data/lib/generators/view_primitives/add/templates/list_group/list_group_item_component.rb.tt +31 -0
  73. data/lib/generators/view_primitives/add/templates/map_area/map_area_component.rb.tt +72 -0
  74. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_component.rb.tt +130 -0
  75. data/lib/generators/view_primitives/add/templates/mega_menu/mega_menu_controller.js +23 -0
  76. data/lib/generators/view_primitives/add/templates/menubar/menubar_component.rb.tt +33 -0
  77. data/lib/generators/view_primitives/add/templates/menubar/menubar_controller.js +34 -0
  78. data/lib/generators/view_primitives/add/templates/menubar/menubar_menu_component.rb.tt +34 -0
  79. data/lib/generators/view_primitives/add/templates/navbar/navbar_component.rb.tt +90 -0
  80. data/lib/generators/view_primitives/add/templates/navbar/navbar_controller.js +11 -0
  81. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_component.rb.tt +132 -0
  82. data/lib/generators/view_primitives/add/templates/navigation_menu/navigation_menu_controller.js +25 -0
  83. data/lib/generators/view_primitives/add/templates/number_input/number_input_component.rb.tt +34 -0
  84. data/lib/generators/view_primitives/add/templates/pagination/pagination_component.rb.tt +97 -0
  85. data/lib/generators/view_primitives/add/templates/picture/picture_component.rb.tt +63 -0
  86. data/lib/generators/view_primitives/add/templates/popover/popover_component.rb.tt +56 -0
  87. data/lib/generators/view_primitives/add/templates/popover/popover_controller.js +17 -0
  88. data/lib/generators/view_primitives/add/templates/progress/progress_component.rb.tt +28 -0
  89. data/lib/generators/view_primitives/add/templates/qr_code/qr_code_component.rb.tt +39 -0
  90. data/lib/generators/view_primitives/add/templates/radio_group/radio_group_component.rb.tt +51 -0
  91. data/lib/generators/view_primitives/add/templates/range/range_component.rb.tt +40 -0
  92. data/lib/generators/view_primitives/add/templates/rating/rating_component.rb.tt +42 -0
  93. data/lib/generators/view_primitives/add/templates/rating_input/rating_controller.js +47 -0
  94. data/lib/generators/view_primitives/add/templates/rating_input/rating_input_component.rb.tt +79 -0
  95. data/lib/generators/view_primitives/add/templates/resizable/resizable_component.rb.tt +91 -0
  96. data/lib/generators/view_primitives/add/templates/resizable/resizable_controller.js +38 -0
  97. data/lib/generators/view_primitives/add/templates/scroll_area/scroll_area_component.rb.tt +41 -0
  98. data/lib/generators/view_primitives/add/templates/search_input/search_input_component.rb.tt +50 -0
  99. data/lib/generators/view_primitives/add/templates/select/select_component.rb.tt +45 -0
  100. data/lib/generators/view_primitives/add/templates/separator/separator_component.rb.tt +25 -0
  101. data/lib/generators/view_primitives/add/templates/sheet/sheet_component.rb.tt +78 -0
  102. data/lib/generators/view_primitives/add/templates/sheet/sheet_controller.js +15 -0
  103. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_component.rb.tt +169 -0
  104. data/lib/generators/view_primitives/add/templates/sidebar/sidebar_controller.js +11 -0
  105. data/lib/generators/view_primitives/add/templates/skeleton/skeleton_component.rb.tt +16 -0
  106. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_component.rb.tt +111 -0
  107. data/lib/generators/view_primitives/add/templates/speed_dial/speed_dial_controller.js +22 -0
  108. data/lib/generators/view_primitives/add/templates/spinner/spinner_component.rb.tt +27 -0
  109. data/lib/generators/view_primitives/add/templates/stepper/stepper_component.rb.tt +99 -0
  110. data/lib/generators/view_primitives/add/templates/switch/switch_component.rb.tt +51 -0
  111. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.html.erb +50 -0
  112. data/lib/generators/view_primitives/add/templates/tabs/tabs_component.rb.tt +16 -0
  113. data/lib/generators/view_primitives/add/templates/tabs/tabs_controller.js +26 -0
  114. data/lib/generators/view_primitives/add/templates/tabs/tabs_item_component.rb.tt +15 -0
  115. data/lib/generators/view_primitives/add/templates/textarea/textarea_component.rb.tt +24 -0
  116. data/lib/generators/view_primitives/add/templates/timeline/timeline_component.rb.tt +78 -0
  117. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_component.rb.tt +140 -0
  118. data/lib/generators/view_primitives/add/templates/timepicker/timepicker_controller.js +92 -0
  119. data/lib/generators/view_primitives/add/templates/toaster/toaster_component.rb.tt +152 -0
  120. data/lib/generators/view_primitives/add/templates/toaster/toaster_controller.js +88 -0
  121. data/lib/generators/view_primitives/add/templates/toggle/toggle_component.rb.tt +41 -0
  122. data/lib/generators/view_primitives/add/templates/toggle/toggle_controller.js +12 -0
  123. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_component.rb.tt +32 -0
  124. data/lib/generators/view_primitives/add/templates/toggle_group/toggle_group_controller.js +38 -0
  125. data/lib/generators/view_primitives/add/templates/tooltip/tooltip_component.rb.tt +42 -0
  126. data/lib/generators/view_primitives/add/templates/video/video_component.rb.tt +92 -0
  127. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_component.rb.tt +88 -0
  128. data/lib/generators/view_primitives/add/templates/wysiwyg/wysiwyg_controller.js +40 -0
  129. data/lib/generators/view_primitives/components.rb +62 -0
  130. data/lib/generators/view_primitives/detector.rb +43 -0
  131. data/lib/generators/view_primitives/install/install_generator.rb +65 -0
  132. data/lib/generators/view_primitives/install/templates/application_component.rb.tt +5 -0
  133. data/lib/generators/view_primitives/install/templates/view_primitives.css +67 -0
  134. data/lib/generators/view_primitives/list/list_generator.rb +25 -0
  135. data/lib/view_primitives/class_helper.rb +11 -0
  136. data/lib/view_primitives/component_helper.rb +20 -0
  137. data/lib/view_primitives/railtie.rb +21 -0
  138. data/lib/view_primitives/version.rb +5 -0
  139. data/lib/view_primitives.rb +12 -0
  140. metadata +267 -0
@@ -0,0 +1,20 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ show(event) {
7
+ event.preventDefault()
8
+ this.panelTarget.hidden = false
9
+ this.panelTarget.style.top = `${event.clientY}px`
10
+ this.panelTarget.style.left = `${event.clientX}px`
11
+ }
12
+
13
+ close() {
14
+ this.panelTarget.hidden = true
15
+ }
16
+
17
+ closeOnClickOutside({ target }) {
18
+ if (!this.panelTarget.contains(target)) this.close()
19
+ }
20
+ }
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DataTableComponent < ApplicationComponent
5
+ # Sortable, filterable data table with client-side pagination.
6
+ #
7
+ # columns: array of { key:, label:, sortable: true }
8
+ # rows: array of hashes (keys must match column keys)
9
+ # per_page: rows per page (default 10, 0 = no pagination)
10
+ # caption: optional <caption> text
11
+
12
+ WRAPPER = "w-full overflow-auto rounded-lg border border-border"
13
+ TOOLBAR = "flex items-center gap-3 border-b border-border bg-background px-4 py-3"
14
+ SEARCH_CLS = "flex h-8 flex-1 items-center gap-2 rounded-md border border-input bg-background " \
15
+ "px-3 text-sm text-muted-foreground focus-within:border-ring focus-within:ring-[3px] " \
16
+ "focus-within:ring-ring/50 transition"
17
+ SEARCH_INPUT = "w-full bg-transparent outline-none placeholder:text-muted-foreground text-foreground text-sm"
18
+ TABLE_CLS = "w-full caption-bottom text-sm"
19
+ THEAD_CLS = "bg-muted/40"
20
+ TH_CLS = "h-10 px-4 text-left align-middle font-medium text-muted-foreground whitespace-nowrap"
21
+ TH_SORT = "cursor-pointer select-none hover:text-foreground transition-colors"
22
+ TR_CLS = "border-t border-border transition-colors hover:bg-muted/30"
23
+ TD_CLS = "px-4 py-3 align-middle"
24
+ FOOTER_CLS = "flex items-center justify-between border-t border-border bg-background px-4 py-3 " \
25
+ "text-sm text-muted-foreground"
26
+ PAGE_BTN = "inline-flex h-8 w-8 items-center justify-center rounded-md border border-border " \
27
+ "hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none " \
28
+ "disabled:opacity-40 transition"
29
+ SORT_ASC = "▲"
30
+ SORT_DESC = "▼"
31
+
32
+ def initialize(columns:, rows:, per_page: 10, caption: nil, **html_attrs)
33
+ @columns = columns
34
+ @rows = rows
35
+ @per_page = per_page.to_i
36
+ @caption = caption
37
+ @extra_class = html_attrs.delete(:class)
38
+ @html_attrs = html_attrs
39
+ end
40
+
41
+ def call
42
+ content_tag(:div,
43
+ class: cn(WRAPPER, @extra_class),
44
+ data: {
45
+ controller: "data-table",
46
+ data_table_per_page_value: @per_page,
47
+ data_table_total_value: @rows.size
48
+ },
49
+ **@html_attrs) do
50
+ concat toolbar
51
+ concat table_element
52
+ concat footer if @per_page > 0
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def toolbar
59
+ content_tag(:div, class: TOOLBAR) do
60
+ concat search_box
61
+ end
62
+ end
63
+
64
+ def search_box
65
+ content_tag(:label, class: SEARCH_CLS) do
66
+ concat search_icon
67
+ concat tag.input(
68
+ type: "search", class: SEARCH_INPUT,
69
+ placeholder: "Search…",
70
+ data: {
71
+ data_table_target: "search",
72
+ action: "input->data-table#filter"
73
+ })
74
+ end
75
+ end
76
+
77
+ def table_element
78
+ content_tag(:table, class: TABLE_CLS) do
79
+ concat content_tag(:caption, @caption, class: "mt-2 text-sm text-muted-foreground") if @caption
80
+ concat thead
81
+ concat tbody
82
+ end
83
+ end
84
+
85
+ def thead
86
+ content_tag(:thead, class: THEAD_CLS) do
87
+ content_tag(:tr) do
88
+ safe_join(@columns.map { |col| th_cell(col) })
89
+ end
90
+ end
91
+ end
92
+
93
+ def th_cell(col)
94
+ key = col[:key].to_s
95
+ label = col[:label] || key.humanize
96
+ sortable = col.fetch(:sortable, false)
97
+
98
+ content_tag(:th, class: cn(TH_CLS, sortable ? TH_SORT : nil),
99
+ data: sortable ? {
100
+ action: "click->data-table#sort",
101
+ data_table_key_param: key
102
+ } : {}) do
103
+ content_tag(:span, class: "flex items-center gap-1") do
104
+ concat label
105
+ if sortable
106
+ concat content_tag(:span, SORT_ASC,
107
+ class: "text-xs opacity-0 data-[active=asc]:opacity-100",
108
+ data: { data_table_target: "sortIndicator", data_table_sort_key: key, data_table_dir: "asc" })
109
+ concat content_tag(:span, SORT_DESC,
110
+ class: "text-xs opacity-0 data-[active=desc]:opacity-100",
111
+ data: { data_table_target: "sortIndicator", data_table_sort_key: key, data_table_dir: "desc" })
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def tbody
118
+ content_tag(:tbody, data: { data_table_target: "body" }) do
119
+ safe_join(@rows.map { |row| tr_row(row) })
120
+ end
121
+ end
122
+
123
+ def tr_row(row)
124
+ content_tag(:tr, class: TR_CLS, data: { data_table_row: true }) do
125
+ safe_join(@columns.map { |col| td_cell(row, col[:key].to_s) })
126
+ end
127
+ end
128
+
129
+ def td_cell(row, key)
130
+ content_tag(:td, row[key.to_sym] || row[key], class: TD_CLS)
131
+ end
132
+
133
+ def footer
134
+ content_tag(:div, class: FOOTER_CLS) do
135
+ concat content_tag(:span, "Page 1",
136
+ data: { data_table_target: "pageLabel" })
137
+ concat(content_tag(:div, class: "flex items-center gap-1") {
138
+ concat page_btn("‹", "click->data-table#prevPage", "Previous page")
139
+ concat page_btn("›", "click->data-table#nextPage", "Next page")
140
+ })
141
+ end
142
+ end
143
+
144
+ def page_btn(label, action, aria)
145
+ content_tag(:button, label, type: "button",
146
+ class: PAGE_BTN,
147
+ "aria-label": aria,
148
+ data: { action: action })
149
+ end
150
+
151
+ def search_icon
152
+ content_tag(:svg,
153
+ safe_join([
154
+ content_tag(:circle, nil, cx: "11", cy: "11", r: "8"),
155
+ content_tag(:path, nil, d: "m21 21-4.3-4.3")
156
+ ]),
157
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
158
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
159
+ "stroke-linecap": "round", "stroke-linejoin": "round",
160
+ class: "size-4 shrink-0", "aria-hidden": "true")
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,115 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["body", "search", "pageLabel", "sortIndicator"]
5
+ static values = {
6
+ perPage: { type: Number, default: 10 },
7
+ total: { type: Number, default: 0 }
8
+ }
9
+
10
+ connect() {
11
+ this.#allRows = Array.from(this.bodyTarget.querySelectorAll("tr[data-data-table-row]"))
12
+ this.#filtered = [...this.#allRows]
13
+ this.#page = 1
14
+ this.#sortKey = null
15
+ this.#sortDir = null
16
+ this.#render()
17
+ }
18
+
19
+ filter() {
20
+ const q = this.searchTarget.value.trim().toLowerCase()
21
+ this.#filtered = q
22
+ ? this.#allRows.filter(row =>
23
+ row.textContent.toLowerCase().includes(q)
24
+ )
25
+ : [...this.#allRows]
26
+ this.#page = 1
27
+ this.#sortKey = null
28
+ this.#sortDir = null
29
+ this.#clearSortIndicators()
30
+ this.#render()
31
+ }
32
+
33
+ sort({ params: { key } }) {
34
+ if (this.#sortKey === key) {
35
+ this.#sortDir = this.#sortDir === "asc" ? "desc" : null
36
+ if (!this.#sortDir) this.#sortKey = null
37
+ } else {
38
+ this.#sortKey = key
39
+ this.#sortDir = "asc"
40
+ }
41
+
42
+ this.#clearSortIndicators()
43
+ if (this.#sortKey) {
44
+ const indicator = this.sortIndicatorTargets.find(
45
+ el => el.dataset.dataTableSortKey === this.#sortKey &&
46
+ el.dataset.dataTableDir === this.#sortDir
47
+ )
48
+ if (indicator) indicator.dataset.active = this.#sortDir
49
+ }
50
+
51
+ if (this.#sortKey) {
52
+ const colIdx = this.#columnIndex(this.#sortKey)
53
+ this.#filtered.sort((a, b) => {
54
+ const av = a.cells[colIdx]?.textContent.trim() ?? ""
55
+ const bv = b.cells[colIdx]?.textContent.trim() ?? ""
56
+ const n = parseFloat(av) - parseFloat(bv)
57
+ const cmp = isNaN(n) ? av.localeCompare(bv) : n
58
+ return this.#sortDir === "asc" ? cmp : -cmp
59
+ })
60
+ }
61
+
62
+ this.#page = 1
63
+ this.#render()
64
+ }
65
+
66
+ prevPage() {
67
+ if (this.#page > 1) { this.#page--; this.#render() }
68
+ }
69
+
70
+ nextPage() {
71
+ if (this.#page < this.#totalPages) { this.#page++; this.#render() }
72
+ }
73
+
74
+ #render() {
75
+ const rows = this.#filtered
76
+ const perPage = this.perPageValue
77
+ const total = rows.length
78
+
79
+ const start = perPage > 0 ? (this.#page - 1) * perPage : 0
80
+ const end = perPage > 0 ? start + perPage : total
81
+
82
+ this.#allRows.forEach(row => { row.style.display = "none" })
83
+ rows.slice(start, end).forEach(row => { row.style.display = "" })
84
+
85
+ if (this.hasPageLabelTarget) {
86
+ if (perPage > 0 && total > 0) {
87
+ this.pageLabelTarget.textContent =
88
+ `Page ${this.#page} of ${this.#totalPages} (${total} rows)`
89
+ } else {
90
+ this.pageLabelTarget.textContent = `${total} rows`
91
+ }
92
+ }
93
+ }
94
+
95
+ #clearSortIndicators() {
96
+ this.sortIndicatorTargets.forEach(el => delete el.dataset.active)
97
+ }
98
+
99
+ #columnIndex(key) {
100
+ const headers = this.element.querySelectorAll("th[data-data-table-key-param]")
101
+ return Array.from(headers).findIndex(h => h.dataset.dataTableKeyParam === key)
102
+ }
103
+
104
+ get #totalPages() {
105
+ return this.perPageValue > 0
106
+ ? Math.max(1, Math.ceil(this.#filtered.length / this.perPageValue))
107
+ : 1
108
+ }
109
+
110
+ #allRows = []
111
+ #filtered = []
112
+ #page = 1
113
+ #sortKey = null
114
+ #sortDir = null
115
+ }
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DatePickerComponent < ApplicationComponent
5
+ # Date picker — text input that opens a calendar popover on focus/click.
6
+ #
7
+ # value: Date or nil — initial selected date
8
+ # name: form field name for the hidden input
9
+ # placeholder: displayed when no date is selected (default: "Pick a date")
10
+ # min/max: Date bounds passed to the calendar
11
+
12
+ WRAPPER = "relative inline-block"
13
+ TRIGGER = "flex h-9 w-48 cursor-pointer items-center gap-2 rounded-md border border-input " \
14
+ "bg-background px-3 text-sm text-foreground shadow-xs " \
15
+ "focus-visible:ring-[3px] focus-visible:ring-ring/50 outline-none transition " \
16
+ "aria-expanded:border-ring"
17
+ ICON_CLS = "size-4 shrink-0 text-muted-foreground"
18
+ POPOVER = "absolute left-0 top-full z-50 mt-1 hidden w-max rounded-lg border border-border " \
19
+ "bg-popover p-0 shadow-md data-[open=true]:block"
20
+
21
+ def initialize(value: nil, name: nil, placeholder: "Pick a date", min: nil, max: nil, **html_attrs)
22
+ @value = value
23
+ @name = name
24
+ @placeholder = placeholder
25
+ @min = min
26
+ @max = max
27
+ @extra_class = html_attrs.delete(:class)
28
+ @html_attrs = html_attrs
29
+ end
30
+
31
+ def call
32
+ content_tag(:div,
33
+ class: cn(WRAPPER, @extra_class),
34
+ data: { controller: "date-picker" },
35
+ **@html_attrs) do
36
+ concat hidden_input if @name
37
+ concat trigger_button
38
+ concat calendar_popover
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def hidden_input
45
+ tag.input(type: "hidden", name: @name,
46
+ value: @value&.iso8601,
47
+ data: { date_picker_target: "hidden" })
48
+ end
49
+
50
+ def trigger_button
51
+ label_text = @value ? @value.strftime("%B %-d, %Y") : @placeholder
52
+ content_tag(:button, type: "button",
53
+ class: TRIGGER,
54
+ "aria-expanded": "false",
55
+ "aria-haspopup": "dialog",
56
+ data: {
57
+ date_picker_target: "trigger",
58
+ action: "click->date-picker#toggle"
59
+ }) do
60
+ concat calendar_icon
61
+ concat content_tag(:span, label_text, data: { date_picker_target: "label" })
62
+ end
63
+ end
64
+
65
+ def calendar_popover
66
+ content_tag(:div,
67
+ class: POPOVER,
68
+ role: "dialog",
69
+ "aria-modal": "true",
70
+ data: {
71
+ date_picker_target: "popover",
72
+ action: "calendar:change->date-picker#dateSelected"
73
+ }) do
74
+ render UI::CalendarComponent.new(
75
+ selected: @value,
76
+ min: @min,
77
+ max: @max
78
+ )
79
+ end
80
+ end
81
+
82
+ def calendar_icon
83
+ content_tag(:svg,
84
+ content_tag(:path, nil,
85
+ d: "M8 2v3m8-3v3M3.5 8h17M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z",
86
+ "stroke-linecap": "round", "stroke-linejoin": "round"),
87
+ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 24 24",
88
+ fill: "none", stroke: "currentColor", "stroke-width": "2",
89
+ class: ICON_CLS, "aria-hidden": "true")
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["trigger", "popover", "label", "hidden"]
5
+
6
+ connect() {
7
+ this.#outsideHandler = (e) => {
8
+ if (!this.element.contains(e.target)) this.close()
9
+ }
10
+ }
11
+
12
+ toggle() {
13
+ this.isOpen ? this.close() : this.open()
14
+ }
15
+
16
+ open() {
17
+ this.popoverTarget.dataset.open = "true"
18
+ this.triggerTarget.setAttribute("aria-expanded", "true")
19
+ document.addEventListener("click", this.#outsideHandler)
20
+ this.isOpen = true
21
+ }
22
+
23
+ close() {
24
+ this.popoverTarget.dataset.open = "false"
25
+ this.triggerTarget.setAttribute("aria-expanded", "false")
26
+ document.removeEventListener("click", this.#outsideHandler)
27
+ this.isOpen = false
28
+ }
29
+
30
+ dateSelected(event) {
31
+ const iso = event.detail.date
32
+ const [year, month, day] = iso.split("-").map(Number)
33
+ const d = new Date(year, month - 1, day)
34
+
35
+ this.labelTarget.textContent = d.toLocaleDateString("default", {
36
+ month: "long",
37
+ day: "numeric",
38
+ year: "numeric"
39
+ })
40
+
41
+ if (this.hasHiddenTarget) this.hiddenTarget.value = iso
42
+
43
+ this.close()
44
+ }
45
+
46
+ #outsideHandler = null
47
+ isOpen = false
48
+ }
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DeviceMockupComponent < ApplicationComponent
5
+ VARIANTS = {
6
+ phone: {
7
+ outer: "relative mx-auto h-[600px] w-[300px] rounded-[2.5rem] border-[14px] " \
8
+ "border-foreground bg-foreground shadow-xl",
9
+ screen: "relative h-full w-full overflow-hidden rounded-[2rem] bg-white dark:bg-zinc-900",
10
+ notch: "absolute left-1/2 top-0 z-10 h-6 w-28 -translate-x-1/2 rounded-b-2xl bg-foreground"
11
+ },
12
+ browser: {
13
+ outer: "relative mx-auto overflow-hidden rounded-xl border border-border bg-background shadow-xl",
14
+ bar: "flex h-10 items-center gap-2 border-b border-border bg-muted px-4",
15
+ dots: "flex gap-1.5",
16
+ screen: "overflow-hidden bg-white dark:bg-zinc-900"
17
+ },
18
+ tablet: {
19
+ outer: "relative mx-auto h-[500px] w-[700px] rounded-[1.75rem] border-[12px] " \
20
+ "border-foreground bg-foreground shadow-xl",
21
+ screen: "relative h-full w-full overflow-hidden rounded-[1.25rem] bg-white dark:bg-zinc-900",
22
+ notch: nil
23
+ }
24
+ }.freeze
25
+
26
+ # variant: :phone (default) | :browser | :tablet
27
+ # url: address bar text for :browser variant
28
+ def initialize(variant: :phone, url: nil, **html_attrs)
29
+ @variant = variant.to_sym
30
+ @url = url
31
+ @extra_class = html_attrs.delete(:class)
32
+ @html_attrs = html_attrs
33
+ end
34
+
35
+ def call
36
+ cfg = VARIANTS.fetch(@variant, VARIANTS[:phone])
37
+
38
+ content_tag(:div, class: cn(cfg[:outer], @extra_class), **@html_attrs) do
39
+ if @variant == :browser
40
+ concat browser_bar(cfg)
41
+ concat content_tag(:div, content, class: cfg[:screen])
42
+ else
43
+ concat content_tag(:div, nil, class: cfg[:notch]) if cfg[:notch]
44
+ concat content_tag(:div, content, class: cfg[:screen])
45
+ end
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def browser_bar(cfg)
52
+ content_tag(:div, class: cfg[:bar]) do
53
+ concat(content_tag(:div, class: cfg[:dots]) {
54
+ %w[bg-red-400 bg-yellow-400 bg-green-400].each do |color|
55
+ concat content_tag(:div, nil, class: "size-3 rounded-full #{color}")
56
+ end
57
+ })
58
+ if @url
59
+ concat content_tag(:div, @url,
60
+ class: "ml-4 flex-1 truncate rounded-md bg-background px-3 py-1 text-xs text-muted-foreground")
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DialogComponent < ApplicationComponent
5
+ renders_one :trigger
6
+ renders_one :footer
7
+
8
+ OVERLAY = "fixed inset-0 z-50 bg-black/50"
9
+ PANEL = "fixed left-[50%] top-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] " \
10
+ "translate-x-[-50%] translate-y-[-50%] gap-4 " \
11
+ "rounded-lg border bg-background p-6 shadow-lg outline-none sm:max-w-lg"
12
+
13
+ def initialize(title: nil, description: nil, **html_attrs)
14
+ @title = title
15
+ @description = description
16
+ @extra_class = html_attrs.delete(:class)
17
+ @html_attrs = html_attrs
18
+ end
19
+
20
+ def call
21
+ content_tag(:div, data: { controller: "dialog" }, **@html_attrs) do
22
+ concat content_tag(:span, trigger, data: { action: "click->dialog#open" }, class: "contents") if trigger
23
+ concat panel
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def panel
30
+ content_tag(:div, data: { dialog_target: "panel" }, hidden: true) do
31
+ concat content_tag(:div, nil,
32
+ class: OVERLAY,
33
+ data: { action: "click->dialog#close" },
34
+ "aria-hidden": "true")
35
+ concat content_tag(:div,
36
+ class: cn(PANEL, @extra_class),
37
+ role: "dialog",
38
+ "aria-modal": "true",
39
+ "aria-label": @title,
40
+ data: { action: "keydown.escape@window->dialog#close" }) {
41
+ concat close_button
42
+ concat header_area
43
+ concat content_tag(:div, content, class: "py-1 text-sm text-foreground")
44
+ concat content_tag(:div, footer, class: "mt-6 flex justify-end gap-2") if footer
45
+ }
46
+ end
47
+ end
48
+
49
+ def header_area
50
+ return "" if @title.nil? && @description.nil?
51
+
52
+ content_tag(:div, class: "mb-4 pr-6") do
53
+ concat content_tag(:h2, @title, class: "text-lg font-semibold leading-none tracking-tight") if @title
54
+ concat content_tag(:p, @description, class: "mt-2 text-sm text-muted-foreground") if @description
55
+ end
56
+ end
57
+
58
+ def close_button
59
+ content_tag(:button,
60
+ close_svg,
61
+ type: "button",
62
+ class: "absolute right-4 top-4 rounded-sm p-1 opacity-70 hover:opacity-100 transition-opacity",
63
+ data: { action: "click->dialog#close" },
64
+ "aria-label": "Close")
65
+ end
66
+
67
+ def close_svg
68
+ raw('<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>')
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ open() {
7
+ this.panelTarget.hidden = false
8
+ document.body.style.overflow = "hidden"
9
+ }
10
+
11
+ close() {
12
+ this.panelTarget.hidden = true
13
+ document.body.style.overflow = ""
14
+ }
15
+ }
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UI
4
+ class DrawerComponent < ApplicationComponent
5
+ renders_one :trigger
6
+ renders_one :footer
7
+
8
+ OVERLAY = "fixed inset-0 z-50 bg-black/50"
9
+ PANEL = "fixed inset-x-0 bottom-0 z-50 rounded-t-xl border-t bg-background shadow-xl overflow-y-auto"
10
+
11
+ def initialize(title: nil, description: nil, **html_attrs)
12
+ @title = title
13
+ @description = description
14
+ @extra_class = html_attrs.delete(:class)
15
+ @html_attrs = html_attrs
16
+ end
17
+
18
+ def call
19
+ content_tag(:div, data: { controller: "drawer" }, **@html_attrs) do
20
+ concat content_tag(:span, trigger, data: { action: "click->drawer#open" }, class: "contents") if trigger
21
+ concat panel
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def panel
28
+ content_tag(:div, data: { drawer_target: "panel" }, hidden: true) do
29
+ concat content_tag(:div, nil,
30
+ class: OVERLAY,
31
+ data: { action: "click->drawer#close" },
32
+ "aria-hidden": "true")
33
+ concat content_tag(:div,
34
+ class: cn(PANEL, @extra_class),
35
+ role: "dialog",
36
+ "aria-modal": "true",
37
+ "aria-label": @title,
38
+ data: { action: "keydown.escape@window->drawer#close" }) {
39
+ concat drag_handle
40
+ concat header_area
41
+ concat content_tag(:div, content, class: "px-4 pb-6 text-sm")
42
+ concat content_tag(:div, footer, class: "px-4 pb-6 flex justify-end gap-2") if footer
43
+ }
44
+ end
45
+ end
46
+
47
+ def drag_handle
48
+ content_tag(:div, class: "flex justify-center pt-3 pb-2") {
49
+ content_tag(:div, nil, class: "h-1.5 w-12 rounded-full bg-muted")
50
+ }
51
+ end
52
+
53
+ def header_area
54
+ return "" if @title.nil? && @description.nil?
55
+
56
+ content_tag(:div, class: "px-4 pb-4") do
57
+ concat content_tag(:h2, @title, class: "text-lg font-semibold text-foreground") if @title
58
+ concat content_tag(:p, @description, class: "mt-1 text-sm text-muted-foreground") if @description
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ open() {
7
+ this.panelTarget.hidden = false
8
+ document.body.style.overflow = "hidden"
9
+ }
10
+
11
+ close() {
12
+ this.panelTarget.hidden = true
13
+ document.body.style.overflow = ""
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ toggle() {
7
+ this.panelTarget.hidden = !this.panelTarget.hidden
8
+ }
9
+
10
+ close() {
11
+ this.panelTarget.hidden = true
12
+ }
13
+
14
+ closeOnClickOutside({ target }) {
15
+ if (!this.element.contains(target)) this.close()
16
+ }
17
+ }