m9sh 0.1.0 → 0.2.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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/Dockerfile +2 -1
  3. data/GEM_README.md +284 -0
  4. data/LICENSE.txt +21 -0
  5. data/M9SH_CLI.md +453 -0
  6. data/PUBLISHING.md +331 -0
  7. data/README.md +120 -52
  8. data/app/components/m9sh/accordion_component.rb +3 -3
  9. data/app/components/m9sh/alert_component.rb +7 -9
  10. data/app/components/m9sh/base_component.rb +1 -0
  11. data/app/components/m9sh/button_component.rb +3 -2
  12. data/app/components/m9sh/color_customizer_component.rb +624 -0
  13. data/app/components/m9sh/dialog_close_component.rb +30 -0
  14. data/app/components/m9sh/dialog_component.rb +11 -99
  15. data/app/components/m9sh/dialog_content_component.rb +102 -0
  16. data/app/components/m9sh/dialog_description_component.rb +14 -0
  17. data/app/components/m9sh/dialog_footer_component.rb +14 -0
  18. data/app/components/m9sh/dialog_header_component.rb +27 -0
  19. data/app/components/m9sh/dialog_title_component.rb +14 -0
  20. data/app/components/m9sh/dialog_trigger_component.rb +23 -0
  21. data/app/components/m9sh/dropdown_menu_content_component.rb +1 -1
  22. data/app/components/m9sh/dropdown_menu_item_component.rb +1 -1
  23. data/app/components/m9sh/dropdown_menu_trigger_component.rb +1 -1
  24. data/app/components/m9sh/icon_component.rb +78 -0
  25. data/app/components/m9sh/main_component.rb +1 -1
  26. data/app/components/m9sh/menu_component.rb +85 -0
  27. data/app/components/m9sh/navbar_component.rb +186 -0
  28. data/app/components/m9sh/navigation_menu_component.rb +2 -2
  29. data/app/components/m9sh/popover_component.rb +12 -7
  30. data/app/components/m9sh/radio_group_component.rb +45 -13
  31. data/app/components/m9sh/sheet_component.rb +6 -6
  32. data/app/components/m9sh/sidebar_component.rb +6 -1
  33. data/app/components/m9sh/skeleton_component.rb +7 -1
  34. data/app/components/m9sh/tabs_component.rb +76 -48
  35. data/app/components/m9sh/textarea_component.rb +1 -1
  36. data/app/components/m9sh/theme_toggle_component.rb +1 -0
  37. data/app/javascript/controllers/m9sh/popover_controller.js +24 -18
  38. data/app/javascript/controllers/m9sh/sidebar_controller.js +29 -7
  39. data/lib/m9sh/config.rb +5 -5
  40. data/lib/m9sh/registry.rb +2 -2
  41. data/lib/m9sh/registry.yml +37 -0
  42. data/lib/m9sh/version.rb +1 -1
  43. data/lib/tasks/tailwindcss.rake +15 -0
  44. data/m9sh.gemspec +48 -0
  45. data/publish.sh +48 -0
  46. metadata +20 -3
  47. data/fix_namespaces.py +0 -32
@@ -4,11 +4,13 @@ module M9sh
4
4
  class DialogComponent < BaseComponent
5
5
  include Utilities
6
6
 
7
- renders_one :trigger
8
- renders_one :header
9
- renders_one :footer
10
- renders_one :title
11
- renders_one :description
7
+ renders_one :dialog_trigger, lambda { |**attrs|
8
+ DialogTriggerComponent.new(**attrs)
9
+ }
10
+
11
+ renders_one :dialog_content, lambda { |**attrs, &block|
12
+ DialogContentComponent.new(**attrs, &block)
13
+ }
12
14
 
13
15
  def initialize(open: false, **extra_attrs)
14
16
  @open = open
@@ -17,107 +19,17 @@ module M9sh
17
19
 
18
20
  def call
19
21
  tag.div(
22
+ **component_attrs(""),
20
23
  data: {
21
24
  controller: "m9sh--dialog",
22
25
  m9sh__dialog_open_value: @open.to_s
23
26
  }
24
27
  ) do
25
28
  safe_join([
26
- render_trigger,
27
- render_dialog
28
- ])
29
- end
30
- end
31
-
32
- private
33
-
34
- def render_trigger
35
- return unless trigger?
36
-
37
- tag.div(
38
- trigger,
39
- data: {
40
- action: "click->m9sh--dialog#open",
41
- m9sh__dialog_target: "trigger"
42
- }
43
- )
44
- end
45
-
46
- def render_dialog
47
- tag.div(
48
- class: overlay_classes,
49
- data: {
50
- m9sh__dialog_target: "overlay",
51
- action: "click->m9sh--dialog#handleBackdropClick"
52
- },
53
- style: @open ? "" : "display: none;",
54
- role: "dialog",
55
- "aria-modal": "true"
56
- ) do
57
- tag.div(
58
- class: content_classes,
59
- data: { m9sh__dialog_target: "content" }
60
- ) do
61
- safe_join([
62
- render_close_button,
63
- render_header,
64
- render_body,
65
- render_footer
66
- ].compact)
67
- end
68
- end
69
- end
70
-
71
- def render_close_button
72
- tag.button(
73
- type: "button",
74
- class: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
75
- data: { action: "click->m9sh--dialog#close" }
76
- ) do
77
- tag.svg(
78
- class: "h-4 w-4",
79
- xmlns: "http://www.w3.org/2000/svg",
80
- viewBox: "0 0 24 24",
81
- fill: "none",
82
- stroke: "currentColor",
83
- "stroke-width": "2",
84
- "stroke-linecap": "round",
85
- "stroke-linejoin": "round"
86
- ) do
87
- tag.line(x1: "18", y1: "6", x2: "6", y2: "18") +
88
- tag.line(x1: "6", y1: "6", x2: "18", y2: "18")
89
- end
90
- end
91
- end
92
-
93
- def render_header
94
- return unless header? || title? || description?
95
-
96
- tag.div(class: "flex flex-col space-y-1.5 text-center sm:text-left") do
97
- safe_join([
98
- title? ? tag.h2(title, class: "text-lg font-semibold leading-none tracking-tight") : nil,
99
- description? ? tag.p(description, class: "text-sm text-muted-foreground") : nil,
100
- header
29
+ dialog_trigger,
30
+ dialog_content
101
31
  ].compact)
102
32
  end
103
33
  end
104
-
105
- def render_body
106
- tag.div(class: "py-4") { content }
107
- end
108
-
109
- def render_footer
110
- return unless footer?
111
-
112
- tag.div(class: "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2") { footer }
113
- end
114
-
115
- def overlay_classes
116
- "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-fade-in data-[state=closed]:animate-fade-out"
117
- end
118
-
119
- def content_classes
120
- "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg"
121
- end
122
34
  end
123
- end
35
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogContentComponent < BaseComponent
5
+ include Utilities
6
+
7
+ renders_one :dialog_header, lambda { |**attrs, &block|
8
+ DialogHeaderComponent.new(**attrs, &block)
9
+ }
10
+
11
+ renders_one :dialog_footer, lambda { |**attrs, &block|
12
+ DialogFooterComponent.new(**attrs, &block)
13
+ }
14
+
15
+ def initialize(show_close: true, **extra_attrs)
16
+ @show_close = show_close
17
+ super(**extra_attrs)
18
+ end
19
+
20
+ def call
21
+ # Overlay (backdrop)
22
+ tag.div(
23
+ class: overlay_classes,
24
+ data: {
25
+ m9sh__dialog_target: "overlay",
26
+ action: "click->m9sh--dialog#handleBackdropClick"
27
+ },
28
+ style: "display: none;",
29
+ role: "dialog",
30
+ "aria-modal": "true"
31
+ ) do
32
+ # Content container
33
+ tag.div(
34
+ class: content_classes,
35
+ data: { m9sh__dialog_target: "content" },
36
+ role: "document"
37
+ ) do
38
+ safe_join([
39
+ render_close_button,
40
+ dialog_header,
41
+ render_body,
42
+ dialog_footer
43
+ ].compact)
44
+ end
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def render_close_button
51
+ return unless @show_close
52
+
53
+ tag.button(
54
+ type: "button",
55
+ class: "absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
56
+ data: { action: "click->m9sh--dialog#close" },
57
+ aria: { label: "Close" }
58
+ ) do
59
+ safe_join([
60
+ tag.svg(
61
+ class: "h-4 w-4",
62
+ xmlns: "http://www.w3.org/2000/svg",
63
+ viewBox: "0 0 24 24",
64
+ fill: "none",
65
+ stroke: "currentColor",
66
+ "stroke-width": "2",
67
+ "stroke-linecap": "round",
68
+ "stroke-linejoin": "round"
69
+ ) do
70
+ safe_join([
71
+ tag.line(x1: "18", y1: "6", x2: "6", y2: "18"),
72
+ tag.line(x1: "6", y1: "6", x2: "18", y2: "18")
73
+ ])
74
+ end,
75
+ tag.span("Close", class: "sr-only")
76
+ ])
77
+ end
78
+ end
79
+
80
+ def render_body
81
+ return unless content
82
+
83
+ tag.div(class: "grid gap-4 py-4") { content }
84
+ end
85
+
86
+ def overlay_classes
87
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
88
+ end
89
+
90
+ def content_classes
91
+ cn(
92
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-background p-6 shadow-lg duration-200",
93
+ "data-[state=open]:animate-in data-[state=closed]:animate-out",
94
+ "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
95
+ "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
96
+ "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]",
97
+ "data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
98
+ "sm:rounded-lg"
99
+ )
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogDescriptionComponent < BaseComponent
5
+ include Utilities
6
+
7
+ def call
8
+ tag.p(
9
+ content,
10
+ **component_attrs("text-sm text-muted-foreground")
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogFooterComponent < BaseComponent
5
+ include Utilities
6
+
7
+ def call
8
+ tag.div(
9
+ content,
10
+ **component_attrs("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2")
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogHeaderComponent < BaseComponent
5
+ include Utilities
6
+
7
+ renders_one :dialog_title, lambda { |**attrs|
8
+ DialogTitleComponent.new(**attrs)
9
+ }
10
+
11
+ renders_one :dialog_description, lambda { |**attrs|
12
+ DialogDescriptionComponent.new(**attrs)
13
+ }
14
+
15
+ def call
16
+ tag.div(
17
+ **component_attrs("flex flex-col space-y-1.5 text-center sm:text-left")
18
+ ) do
19
+ safe_join([
20
+ dialog_title,
21
+ dialog_description,
22
+ content
23
+ ].compact)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogTitleComponent < BaseComponent
5
+ include Utilities
6
+
7
+ def call
8
+ tag.h2(
9
+ content,
10
+ **component_attrs("text-lg font-semibold leading-none tracking-tight")
11
+ )
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class DialogTriggerComponent < BaseComponent
5
+ include Utilities
6
+
7
+ def initialize(as_child: false, **extra_attrs)
8
+ @as_child = as_child
9
+ super(**extra_attrs)
10
+ end
11
+
12
+ def call
13
+ # Always wrap content in a span with data attributes so clicks are captured
14
+ tag.span(
15
+ content,
16
+ data: {
17
+ action: "click->m9sh--dialog#open",
18
+ m9sh__dialog_target: "trigger"
19
+ }
20
+ )
21
+ end
22
+ end
23
+ end
@@ -13,7 +13,7 @@ module M9sh
13
13
  transition_leave_start: "transform opacity-100 scale-100",
14
14
  transition_leave_end: "transform opacity-0 scale-95"
15
15
  },
16
- class: "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg hidden",
16
+ class: "absolute z-50 min-w-[8rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg hidden",
17
17
  style: "position: absolute;",
18
18
  **(@extra_attrs || {})
19
19
  ) do
@@ -30,7 +30,7 @@ module M9sh
30
30
  private
31
31
 
32
32
  def item_classes
33
- "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
33
+ "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm text-foreground outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
34
34
  end
35
35
  end
36
36
  end
@@ -9,7 +9,7 @@ module M9sh
9
9
  m9sh__dropdown_menu_target: "trigger"
10
10
  },
11
11
  type: "button",
12
- class: "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
12
+ class: "inline-flex items-center justify-center rounded-md text-sm font-medium text-foreground ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
13
13
  **(@extra_attrs || {})
14
14
  ) do
15
15
  content
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class IconComponent < BaseComponent
5
+ include Utilities
6
+
7
+ def initialize(
8
+ name:,
9
+ size: nil,
10
+ class_name: nil,
11
+ **extra_attrs
12
+ )
13
+ @name = name
14
+ @size = size
15
+ @class_name = class_name
16
+ super(class_name: class_name, **extra_attrs)
17
+ end
18
+
19
+ def call
20
+ icon_path = Rails.root.join("app", "assets", "images", "icons", "#{@name}.svg")
21
+
22
+ unless File.exist?(icon_path)
23
+ Rails.logger.warn("Icon not found: #{@name}")
24
+ return tag.span("[Icon: #{@name}]", class: "text-muted-foreground")
25
+ end
26
+
27
+ svg_content = File.read(icon_path)
28
+
29
+ # Simple regex-based attribute modification
30
+ svg_content = modify_svg_attributes(svg_content)
31
+
32
+ svg_content.html_safe
33
+ end
34
+
35
+ private
36
+
37
+ def modify_svg_attributes(svg)
38
+ # Extract existing SVG opening tag
39
+ svg_tag_match = svg.match(/<svg([^>]*)>/)
40
+ return svg unless svg_tag_match
41
+
42
+ existing_attrs = svg_tag_match[1]
43
+
44
+ # Build new attributes
45
+ new_attrs = []
46
+
47
+ # Add or update size
48
+ if @size
49
+ # Remove existing width/height
50
+ existing_attrs = existing_attrs.gsub(/\s*(width|height)="[^"]*"/, '')
51
+ new_attrs << "width=\"#{@size}\""
52
+ new_attrs << "height=\"#{@size}\""
53
+ end
54
+
55
+ # Add classes
56
+ if @class_name
57
+ if existing_attrs =~ /class="([^"]*)"/
58
+ existing_class = $1
59
+ merged_classes = "#{existing_class} #{@class_name}".strip
60
+ existing_attrs = existing_attrs.gsub(/class="[^"]*"/, "class=\"#{merged_classes}\"")
61
+ else
62
+ new_attrs << "class=\"#{@class_name}\""
63
+ end
64
+ end
65
+
66
+ # Add extra attributes
67
+ @extra_attrs.each do |key, value|
68
+ next if key == :class
69
+ attr_name = key.to_s.gsub('_', '-')
70
+ new_attrs << "#{attr_name}=\"#{value}\""
71
+ end
72
+
73
+ # Reconstruct SVG tag
74
+ all_attrs = [existing_attrs, new_attrs.join(' ')].reject(&:blank?).join(' ')
75
+ svg.sub(/<svg[^>]*>/, "<svg#{all_attrs.present? ? ' ' + all_attrs : ''}>")
76
+ end
77
+ end
78
+ end
@@ -6,7 +6,7 @@ module M9sh
6
6
 
7
7
  def call
8
8
  tag.div(
9
- **component_attrs("flex-1 flex flex-col m-4 rounded-lg shadow-xl bg-card overflow-hidden border border-border"),
9
+ **component_attrs("flex-1 flex flex-col p-6 md:px-12 md:py-20 md:rounded-lg md:shadow-xl bg-card overflow-hidden"),
10
10
  data: { slot: "main" }
11
11
  ) do
12
12
  content
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module M9sh
4
+ class MenuComponent < BaseComponent
5
+ include Utilities
6
+
7
+ renders_many :items, types: {
8
+ item: "ItemComponent",
9
+ separator: "SeparatorComponent"
10
+ }
11
+
12
+ def call
13
+ tag.div(**component_attrs("py-1 space-y-1")) do
14
+ safe_join(items)
15
+ end
16
+ end
17
+
18
+ class ItemComponent < BaseComponent
19
+ include Utilities
20
+
21
+ def initialize(href: nil, icon: nil, disabled: false, **extra_attrs)
22
+ @href = href
23
+ @icon = icon
24
+ @disabled = disabled
25
+ super(**extra_attrs)
26
+ end
27
+
28
+ def call
29
+ if @href
30
+ render_link
31
+ else
32
+ render_button
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def render_link
39
+ tag.a(
40
+ href: @href,
41
+ **component_attrs(item_classes)
42
+ ) do
43
+ render_content
44
+ end
45
+ end
46
+
47
+ def render_button
48
+ tag.button(
49
+ type: "button",
50
+ disabled: @disabled,
51
+ **component_attrs(item_classes)
52
+ ) do
53
+ render_content
54
+ end
55
+ end
56
+
57
+ def render_content
58
+ if @icon
59
+ safe_join([
60
+ @icon,
61
+ tag.span(content, class: "flex-1")
62
+ ])
63
+ else
64
+ content
65
+ end
66
+ end
67
+
68
+ def item_classes
69
+ base = "group flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors "
70
+ base += "text-foreground hover:bg-accent hover:text-accent-foreground "
71
+ base += "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 "
72
+ base += @disabled ? "opacity-50 cursor-not-allowed" : "cursor-pointer"
73
+ base
74
+ end
75
+ end
76
+
77
+ class SeparatorComponent < BaseComponent
78
+ include Utilities
79
+
80
+ def call
81
+ tag.div(**component_attrs("my-1 h-px bg-border"))
82
+ end
83
+ end
84
+ end
85
+ end