okonomi_ui_kit 0.1.5 → 0.1.7

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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/builds/okonomi_ui_kit/application.tailwind.css +313 -4
  3. data/app/helpers/okonomi_ui_kit/component.rb +80 -0
  4. data/app/helpers/okonomi_ui_kit/components/alert.rb +9 -0
  5. data/app/helpers/okonomi_ui_kit/components/badge.rb +31 -0
  6. data/app/helpers/okonomi_ui_kit/components/button_to.rb +34 -0
  7. data/app/helpers/okonomi_ui_kit/components/code.rb +73 -0
  8. data/app/helpers/okonomi_ui_kit/components/link_to.rb +34 -0
  9. data/app/helpers/okonomi_ui_kit/components/page.rb +247 -0
  10. data/app/helpers/okonomi_ui_kit/components/table.rb +207 -0
  11. data/app/helpers/okonomi_ui_kit/components/typography.rb +68 -0
  12. data/app/helpers/okonomi_ui_kit/config.rb +16 -0
  13. data/app/helpers/okonomi_ui_kit/theme.rb +55 -3
  14. data/app/helpers/okonomi_ui_kit/ui_helper.rb +35 -63
  15. data/app/javascript/okonomi_ui_kit/controllers/modal_controller.js +94 -0
  16. data/app/views/okonomi/components/alert/_alert.html.erb +3 -0
  17. data/app/views/okonomi/components/code/_code.html.erb +1 -0
  18. data/app/views/okonomi/components/page/_page.html.erb +5 -0
  19. data/app/views/okonomi/components/table/_table.html.erb +3 -0
  20. data/app/views/okonomi/components/typography/_typography.html.erb +7 -0
  21. data/app/views/okonomi/modals/_confirmation_modal.html.erb +77 -0
  22. data/lib/okonomi_ui_kit/engine.rb +0 -3
  23. data/lib/okonomi_ui_kit/version.rb +1 -1
  24. metadata +23 -7
  25. data/app/helpers/okonomi_ui_kit/badge_helper.rb +0 -23
  26. data/app/helpers/okonomi_ui_kit/page_builder_helper.rb +0 -217
  27. data/app/helpers/okonomi_ui_kit/table_helper.rb +0 -158
  28. data/app/views/okonomi/components/_typography.html.erb +0 -7
@@ -0,0 +1,207 @@
1
+ module OkonomiUiKit
2
+ module Components
3
+ class Table < OkonomiUiKit::Component
4
+ def render(options = {}, &block)
5
+ options = options.with_indifferent_access
6
+ variant = (options.delete(:variant) || :default).to_sym
7
+
8
+ builder = TableBuilder.new(view, theme, self, variant)
9
+ view.render(template_path, builder: builder, options: options, &block)
10
+ end
11
+
12
+ register_styles :default do
13
+ {
14
+ default: {
15
+ body: {
16
+ base: "divide-y divide-gray-200 bg-white"
17
+ },
18
+ th: {
19
+ base: "text-sm font-semibold text-gray-900",
20
+ first: "py-3.5 pr-3",
21
+ last: "relative py-3.5",
22
+ middle: "pl-3 pr-3 py-3.5"
23
+ },
24
+ td: {
25
+ base: "text-sm whitespace-nowrap",
26
+ first: "py-4 pr-3 font-medium text-gray-900",
27
+ last: "relative py-4 font-medium",
28
+ middle: "pl-3 pr-3 py-4 text-gray-500"
29
+ },
30
+ alignment: {
31
+ left: "text-left",
32
+ center: "text-center",
33
+ right: "text-right"
34
+ },
35
+ empty_state: {
36
+ wrapper: "text-center py-8",
37
+ icon: "mx-auto h-12 w-12 text-gray-400",
38
+ title: "mt-2 text-sm font-medium text-gray-900",
39
+ subtitle: "mt-1 text-sm text-gray-500",
40
+ cell: "text-center py-8 text-gray-500"
41
+ }
42
+ }
43
+ }
44
+ end
45
+ end
46
+
47
+ class TableBuilder
48
+ include ActionView::Helpers::TagHelper
49
+ include ActionView::Helpers::CaptureHelper
50
+
51
+ def initialize(template, theme, style_provider, variant = :default)
52
+ @template = template
53
+ @theme = theme
54
+ @style_provider = style_provider
55
+ @variant = variant
56
+ @current_row_cells = []
57
+ @in_header = false
58
+ @in_body = false
59
+ end
60
+
61
+ def head(&block)
62
+ @in_header = true
63
+ @in_body = false
64
+ result = tag.thead(&block)
65
+ @in_header = false
66
+ result
67
+ end
68
+
69
+ def body(&block)
70
+ @in_header = false
71
+ @in_body = true
72
+ result = tag.tbody(class: style(:body, :base), &block)
73
+ @in_body = false
74
+ result
75
+ end
76
+
77
+ def tr(&block)
78
+ @current_row_cells = []
79
+
80
+ # Collect all cells first
81
+ yield if block_given?
82
+
83
+ # Now render each cell with proper first/last detection
84
+ rendered_cells = @current_row_cells.map.with_index do |cell, index|
85
+ is_first = index == 0
86
+ is_last = index == @current_row_cells.length - 1
87
+
88
+ if cell[:type] == :th
89
+ render_th(cell, is_first, is_last)
90
+ else
91
+ render_td(cell, is_first, is_last)
92
+ end
93
+ end
94
+
95
+ result = tag.tr do
96
+ @template.safe_join(rendered_cells)
97
+ end
98
+
99
+ @current_row_cells = []
100
+ result
101
+ end
102
+
103
+ def th(scope: "col", align: :left, **options, &block)
104
+ content = capture(&block) if block_given?
105
+
106
+ # Store cell data for later processing in tr
107
+ cell = { type: :th, scope: scope, align: align, options: options, content: content }
108
+ @current_row_cells << cell
109
+
110
+ # Return empty string for now, actual rendering happens in tr
111
+ ""
112
+ end
113
+
114
+ def td(align: :left, **options, &block)
115
+ content = capture(&block) if block_given?
116
+
117
+ # Store cell data for later processing in tr
118
+ cell = { type: :td, align: align, options: options, content: content }
119
+ @current_row_cells << cell
120
+
121
+ # Return empty string for now, actual rendering happens in tr
122
+ ""
123
+ end
124
+
125
+ def empty_state(title: "No records found", icon: "heroicons/outline/document", colspan: nil, &block)
126
+ content = if block_given?
127
+ capture(&block)
128
+ else
129
+ tag.div(class: style(:empty_state, :wrapper)) do
130
+ icon_content = if @template.respond_to?(:svg_icon)
131
+ @template.svg_icon(icon, class: style(:empty_state, :icon))
132
+ else
133
+ tag.div(class: style(:empty_state, :icon))
134
+ end
135
+
136
+ icon_content + tag.p(title, class: style(:empty_state, :title)) +
137
+ tag.p("Get started by creating a new record.", class: style(:empty_state, :subtitle))
138
+ end
139
+ end
140
+
141
+ tr do
142
+ td(colspan: colspan, class: style(:empty_state, :cell)) do
143
+ content
144
+ end
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def tag
151
+ @template.tag
152
+ end
153
+
154
+ def capture(*args, &block)
155
+ @template.capture(*args, &block)
156
+ end
157
+
158
+ def render_th(cell, is_first, is_last)
159
+ align_class = style(:alignment, cell[:align]) || style(:alignment, :left)
160
+
161
+ position_class = if is_first
162
+ style(:th, :first)
163
+ elsif is_last
164
+ style(:th, :last)
165
+ else
166
+ style(:th, :middle)
167
+ end
168
+
169
+ classes = [
170
+ style(:th, :base),
171
+ position_class,
172
+ align_class,
173
+ cell[:options][:class]
174
+ ].compact.join(' ')
175
+
176
+ options = cell[:options].except(:class)
177
+ tag.th(cell[:content], scope: cell[:scope], class: classes, **options)
178
+ end
179
+
180
+ def render_td(cell, is_first, is_last)
181
+ align_class = style(:alignment, cell[:align]) || style(:alignment, :left)
182
+
183
+ position_class = if is_first
184
+ style(:td, :first)
185
+ elsif is_last
186
+ style(:td, :last)
187
+ else
188
+ style(:td, :middle)
189
+ end
190
+
191
+ classes = [
192
+ style(:td, :base),
193
+ position_class,
194
+ align_class,
195
+ cell[:options][:class]
196
+ ].compact.join(' ')
197
+
198
+ options = cell[:options].except(:class)
199
+ tag.td(cell[:content], class: classes, **options)
200
+ end
201
+
202
+ def style(*keys)
203
+ @style_provider.style(@variant, *keys)
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,68 @@
1
+ module OkonomiUiKit
2
+ module Components
3
+ class Typography < OkonomiUiKit::Component
4
+ TYPOGRAPHY_COMPONENTS = {
5
+ body1: 'p',
6
+ body2: 'p',
7
+ h1: 'h1',
8
+ h2: 'h2',
9
+ h3: 'h3',
10
+ h4: 'h4',
11
+ h5: 'h5',
12
+ h6: 'h6',
13
+ }.freeze
14
+
15
+ def render(text = nil, options = {}, &block)
16
+ options, text = text, nil if block_given?
17
+ options ||= {}
18
+ options = options.with_indifferent_access
19
+
20
+ variant = (options.delete(:variant) || 'body1').to_sym
21
+ component = (TYPOGRAPHY_COMPONENTS[variant] || 'span').to_s
22
+ color = (options.delete(:color) || 'default').to_sym
23
+
24
+ classes = [
25
+ style(:variants, variant) || '',
26
+ style(:colors, color) || '',
27
+ options.delete(:class) || ''
28
+ ].reject(&:blank?).join(' ')
29
+
30
+ view.render(
31
+ template_path,
32
+ text: text,
33
+ options: options,
34
+ variant: variant,
35
+ component: component,
36
+ classes: classes,
37
+ &block
38
+ )
39
+ end
40
+
41
+ register_styles :default do
42
+ {
43
+ variants: {
44
+ body1: "text-base font-normal",
45
+ body2: "text-sm font-normal",
46
+ h1: "text-3xl font-bold",
47
+ h2: "text-2xl font-bold",
48
+ h3: "text-xl font-semibold",
49
+ h4: "text-lg font-semibold",
50
+ h5: "text-base font-semibold",
51
+ h6: "text-sm font-semibold"
52
+ },
53
+ colors: {
54
+ default: "text-default-700",
55
+ dark: "text-default-900",
56
+ muted: "text-default-500",
57
+ primary: "text-primary-600",
58
+ secondary: "text-secondary-600",
59
+ success: "text-success-600",
60
+ danger: "text-danger-600",
61
+ warning: "text-warning-600",
62
+ info: "text-info-600"
63
+ }
64
+ }
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,16 @@
1
+ module OkonomiUiKit
2
+ class Config
3
+ def self.register_styles(theme = :default, &block)
4
+ styles = block.call if block_given?
5
+
6
+ raise ArgumentError, "Styles must be a Hash" unless styles.is_a?(Hash)
7
+
8
+ styles_registry[theme] ||= {}
9
+ styles_registry[theme] = styles_registry[theme].deep_merge(styles)
10
+ end
11
+
12
+ def self.styles_registry
13
+ @styles_registry ||= {}
14
+ end
15
+ end
16
+ end
@@ -26,9 +26,9 @@ module OkonomiUiKit
26
26
  }
27
27
  },
28
28
  link: {
29
- root: "hover:cursor-pointer",
29
+ root: "hover:cursor-pointer text-sm",
30
30
  outlined: {
31
- root: "inline-flex border items-center justify-center px-4 py-2 font-medium focus:outline-none focus:ring-2 focus:ring-offset-2",
31
+ root: "inline-flex border items-center justify-center px-2 py-1 rounded-md font-medium focus:outline-none focus:ring-2 focus:ring-offset-2",
32
32
  colors: {
33
33
  default: "bg-white text-default-700 border-default-700 hover:bg-default-50",
34
34
  primary: "bg-white text-primary-600 border-primary-600 hover:bg-primary-50",
@@ -40,7 +40,7 @@ module OkonomiUiKit
40
40
  }
41
41
  },
42
42
  contained: {
43
- root: "inline-flex border items-center justify-center px-4 py-2 font-medium focus:outline-none focus:ring-2 focus:ring-offset-2",
43
+ root: "inline-flex border items-center justify-center px-2 py-1 rounded-md font-medium focus:outline-none focus:ring-2 focus:ring-offset-2",
44
44
  colors: {
45
45
  default: "border-default-700 bg-default-600 text-white hover:bg-default-700",
46
46
  primary: "border-primary-700 bg-primary-600 text-white hover:bg-primary-700",
@@ -98,6 +98,58 @@ module OkonomiUiKit
98
98
  hint: {
99
99
  root: "cursor-pointer text-sm text-gray-400"
100
100
  }
101
+ },
102
+ modal: {
103
+ backdrop: "fixed inset-0 bg-gray-500/75 transition-opacity duration-300 ease-out opacity-0",
104
+ container: "fixed inset-0 z-10 w-screen overflow-y-auto",
105
+ wrapper: "flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0",
106
+ panel: {
107
+ base: "relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all duration-300 ease-out sm:my-8 sm:w-full sm:p-6 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
108
+ sizes: {
109
+ sm: "sm:max-w-sm",
110
+ md: "sm:max-w-lg",
111
+ lg: "sm:max-w-2xl",
112
+ xl: "sm:max-w-4xl"
113
+ }
114
+ },
115
+ close_button: {
116
+ wrapper: "absolute top-0 right-0 hidden pt-4 pr-4 sm:block",
117
+ button: "rounded-md bg-white text-gray-400 hover:text-gray-500 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none",
118
+ icon: {
119
+ file: "heroicons/outline/x-mark",
120
+ class: "size-6"
121
+ }
122
+ },
123
+ icon: {
124
+ wrapper: "mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10",
125
+ class: "size-6",
126
+ variants: {
127
+ warning: {
128
+ wrapper: "bg-red-100",
129
+ icon: "text-red-600",
130
+ file: "heroicons/outline/exclamation-triangle"
131
+ },
132
+ info: {
133
+ wrapper: "bg-blue-100",
134
+ icon: "text-blue-600",
135
+ file: "heroicons/outline/information-circle"
136
+ },
137
+ success: {
138
+ wrapper: "bg-green-100",
139
+ icon: "text-green-600",
140
+ file: "heroicons/outline/check-circle"
141
+ }
142
+ }
143
+ },
144
+ content: {
145
+ wrapper: "sm:flex sm:items-start",
146
+ text_wrapper: "mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left",
147
+ title: "text-base font-semibold text-gray-900",
148
+ message: "mt-2 text-sm text-gray-500"
149
+ },
150
+ actions: {
151
+ wrapper: "mt-5 sm:mt-4 sm:flex sm:flex-row-reverse"
152
+ }
101
153
  }
102
154
  }
103
155
  }
@@ -26,42 +26,6 @@ module OkonomiUiKit
26
26
  @_okonomi_ui_kit_theme ||= OkonomiUiKit::Theme::DEFAULT_THEME
27
27
  end
28
28
 
29
- def link_to(name = nil, options = nil, html_options = nil, &block)
30
- html_options, options, name = options, name, block if block_given?
31
-
32
- html_options ||= {}
33
- html_options[:class] ||= ''
34
-
35
- variant = (html_options.delete(:variant) || 'text').to_sym
36
- color = (html_options.delete(:color) || 'default').to_sym
37
-
38
- html_options[:class] = button_class(variant:, color:, classes: html_options[:class])
39
-
40
- if block_given?
41
- @template.link_to(options, html_options, &block)
42
- else
43
- @template.link_to(name, options, html_options)
44
- end
45
- end
46
-
47
- def button_to(name = nil, options = nil, html_options = nil, &block)
48
- html_options, options, name = options, name, block if block_given?
49
-
50
- html_options ||= {}
51
- html_options[:class] ||= ''
52
-
53
- variant = (html_options.delete(:variant) || 'contained').to_sym
54
- color = (html_options.delete(:color) || 'default').to_sym
55
-
56
- html_options[:class] = button_class(variant:, color:, classes: html_options[:class])
57
-
58
- if block_given?
59
- @template.button_to(options, html_options, &block)
60
- else
61
- @template.button_to(name, options, html_options)
62
- end
63
- end
64
-
65
29
  def button_class(variant: 'contained', color: 'default', classes: '')
66
30
  [
67
31
  get_theme.dig(:components, :link, :root) || '',
@@ -71,40 +35,48 @@ module OkonomiUiKit
71
35
  ].join(' ')
72
36
  end
73
37
 
74
- def page(&block)
75
- @template.page(&block)
38
+ def confirmation_modal(title:, message:, confirm_text: "Confirm", cancel_text: "Cancel", variant: :warning, size: :md, **options, &block)
39
+ modal_options = {
40
+ title: title,
41
+ message: message,
42
+ confirm_text: confirm_text,
43
+ cancel_text: cancel_text,
44
+ variant: variant,
45
+ size: size,
46
+ has_custom_actions: block_given?,
47
+ **options
48
+ }
49
+ @template.render("okonomi/modals/confirmation_modal", options: modal_options, ui: self, &block)
76
50
  end
77
51
 
78
- TYPOGRAPHY_COMPONENTS = {
79
- body1: 'p',
80
- body2: 'p',
81
- h1: 'h1',
82
- h2: 'h2',
83
- h3: 'h3',
84
- h4: 'h4',
85
- h5: 'h5',
86
- h6: 'h6',
87
- }.freeze
52
+ def modal_data_attributes(options)
53
+ return "" unless options[:data]
54
+
55
+ options[:data].map { |k, v| "data-#{k.to_s.dasherize}=\"#{v}\"" }.join(' ').html_safe
56
+ end
88
57
 
89
- def typography(text = nil, options = nil, &block)
90
- options, text = text, nil if block_given?
91
- options ||= {}
58
+ def modal_panel_class(size)
59
+ [
60
+ get_theme.dig(:components, :modal, :panel, :base),
61
+ get_theme.dig(:components, :modal, :panel, :sizes, size)
62
+ ].compact.join(' ')
63
+ end
92
64
 
93
- variant = (options.delete(:variant) || 'body1').to_sym
94
- component = (TYPOGRAPHY_COMPONENTS[variant] || 'span').to_s
95
- color = (options.delete(:color) || 'default').to_sym
96
- classes = [
97
- get_theme.dig(:components, :typography, :variants, variant) || '',
98
- get_theme.dig(:components, :typography, :colors, color) || '',
99
- options.delete(:class) || ''
100
- ]
65
+ def modal_icon_wrapper_class(variant)
66
+ [
67
+ get_theme.dig(:components, :modal, :icon, :wrapper),
68
+ get_theme.dig(:components, :modal, :icon, :variants, variant, :wrapper)
69
+ ].compact.join(' ')
70
+ end
101
71
 
102
- if block_given?
103
- @template.render("okonomi/components/typography", options:, variant:, component:, classes:, &block)
72
+ def method_missing(method_name, *args, &block)
73
+ component_name = "OkonomiUiKit::Components::#{method_name.to_s.camelize}"
74
+ if Object.const_defined?(component_name)
75
+ return component_name.constantize.new(@template, get_theme).render(*args, &block)
104
76
  else
105
- @template.render("okonomi/components/typography", text:, options:, variant:, component:, classes:)
77
+ super
106
78
  end
107
79
  end
108
80
  end
109
81
  end
110
- end
82
+ end
@@ -0,0 +1,94 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static targets = ["container", "backdrop", "panel"]
5
+ static values = {
6
+ size: String,
7
+ autoOpen: Boolean
8
+ }
9
+
10
+ connect() {
11
+ this.close()
12
+ if (this.autoOpenValue) {
13
+ this.open()
14
+ }
15
+
16
+ // Bind escape key handler
17
+ this.handleEscape = this.handleEscape.bind(this)
18
+ }
19
+
20
+ disconnect() {
21
+ this.unlockBodyScroll()
22
+ document.removeEventListener('keydown', this.handleEscape)
23
+ }
24
+
25
+ open() {
26
+ this.containerTarget.style.display = 'block'
27
+ this.lockBodyScroll()
28
+ document.addEventListener('keydown', this.handleEscape)
29
+
30
+ // Focus trap - focus first focusable element in modal
31
+ this.focusFirstElement()
32
+
33
+ // Add entrance animations
34
+ requestAnimationFrame(() => {
35
+ this.backdropTarget.classList.remove('opacity-0')
36
+ this.backdropTarget.classList.add('opacity-100')
37
+
38
+ this.panelTarget.classList.remove('opacity-0', 'translate-y-4', 'sm:translate-y-0', 'sm:scale-95')
39
+ this.panelTarget.classList.add('opacity-100', 'translate-y-0', 'sm:scale-100')
40
+ })
41
+ }
42
+
43
+ close() {
44
+ // Add exit animations
45
+ this.backdropTarget.classList.remove('opacity-100')
46
+ this.backdropTarget.classList.add('opacity-0')
47
+
48
+ this.panelTarget.classList.remove('opacity-100', 'translate-y-0', 'sm:scale-100')
49
+ this.panelTarget.classList.add('opacity-0', 'translate-y-4', 'sm:translate-y-0', 'sm:scale-95')
50
+
51
+ // Hide after animation completes
52
+ setTimeout(() => {
53
+ this.containerTarget.style.display = 'none'
54
+ this.unlockBodyScroll()
55
+ document.removeEventListener('keydown', this.handleEscape)
56
+ }, 200)
57
+ }
58
+
59
+ confirm() {
60
+ // Dispatch confirm event for custom handling
61
+ this.dispatch('confirm', { detail: { modal: this } })
62
+ this.close()
63
+ }
64
+
65
+ cancel() {
66
+ // Dispatch cancel event for custom handling
67
+ this.dispatch('cancel', { detail: { modal: this } })
68
+ this.close()
69
+ }
70
+
71
+ handleEscape(event) {
72
+ if (event.key === 'Escape') {
73
+ this.close()
74
+ }
75
+ }
76
+
77
+ lockBodyScroll() {
78
+ document.body.style.overflow = 'hidden'
79
+ }
80
+
81
+ unlockBodyScroll() {
82
+ document.body.style.overflow = ''
83
+ }
84
+
85
+ focusFirstElement() {
86
+ const focusableElements = this.panelTarget.querySelectorAll(
87
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
88
+ )
89
+
90
+ if (focusableElements.length > 0) {
91
+ focusableElements[0].focus()
92
+ }
93
+ }
94
+ }
@@ -0,0 +1,3 @@
1
+ <div class="hover:bg-blue-700">
2
+ <%= title %>
3
+ </div>
@@ -0,0 +1 @@
1
+ <pre<%= language ? " data-language=\"#{language}\"".html_safe : "" %> class="<%= classes %>"<%= tag.attributes(options) %>><code><%= content %></code></pre>
@@ -0,0 +1,5 @@
1
+ <div class="flex flex-col gap-8 p-8 <%= options[:class] || '' %>">
2
+ <% content = capture { yield(builder) } %>
3
+ <%= builder.render_content %>
4
+ <%= content if builder.render_content.blank? %>
5
+ </div>
@@ -0,0 +1,3 @@
1
+ <table class="min-w-full divide-y divide-gray-300" <%= tag.attributes(options) %>>
2
+ <%= yield builder %>
3
+ </table>
@@ -0,0 +1,7 @@
1
+ <%= content_tag component, options.merge(class: classes) do %>
2
+ <% if defined?(text) && text.present? %>
3
+ <%= text %>
4
+ <% else %>
5
+ <%= yield %>
6
+ <% end %>
7
+ <% end %>