shadcn-phlex 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.
- checksums.yaml +7 -0
- data/README.md +195 -0
- data/app.css +20 -0
- data/css/shadcn-source.css +3 -0
- data/css/shadcn-tailwind.css +160 -0
- data/css/themes/mauve.css +62 -0
- data/css/themes/mist.css +62 -0
- data/css/themes/neutral.css +74 -0
- data/css/themes/olive.css +62 -0
- data/css/themes/stone.css +62 -0
- data/css/themes/taupe.css +62 -0
- data/css/themes/zinc.css +62 -0
- data/js/controllers/accordion_controller.js +135 -0
- data/js/controllers/checkbox_controller.js +52 -0
- data/js/controllers/collapsible_controller.js +85 -0
- data/js/controllers/combobox_controller.js +168 -0
- data/js/controllers/command_controller.js +171 -0
- data/js/controllers/context_menu_controller.js +132 -0
- data/js/controllers/dark_mode_controller.js +106 -0
- data/js/controllers/dialog_controller.js +205 -0
- data/js/controllers/drawer_controller.js +161 -0
- data/js/controllers/dropdown_menu_controller.js +189 -0
- data/js/controllers/hover_card_controller.js +85 -0
- data/js/controllers/index.js +89 -0
- data/js/controllers/menubar_controller.js +171 -0
- data/js/controllers/navigation_menu_controller.js +160 -0
- data/js/controllers/popover_controller.js +151 -0
- data/js/controllers/radio_group_controller.js +78 -0
- data/js/controllers/scroll_area_controller.js +117 -0
- data/js/controllers/select_controller.js +198 -0
- data/js/controllers/sheet_controller.js +130 -0
- data/js/controllers/slider_controller.js +142 -0
- data/js/controllers/switch_controller.js +40 -0
- data/js/controllers/tabs_controller.js +96 -0
- data/js/controllers/toast_controller.js +206 -0
- data/js/controllers/toggle_controller.js +30 -0
- data/js/controllers/toggle_group_controller.js +73 -0
- data/js/controllers/tooltip_controller.js +146 -0
- data/lib/generators/shadcn_phlex/component_generator.rb +79 -0
- data/lib/generators/shadcn_phlex/install_generator.rb +217 -0
- data/lib/shadcn/base.rb +27 -0
- data/lib/shadcn/engine.rb +24 -0
- data/lib/shadcn/kit.rb +1158 -0
- data/lib/shadcn/themes/accent_colors.rb +106 -0
- data/lib/shadcn/themes/base_colors.rb +313 -0
- data/lib/shadcn/ui/accordion.rb +135 -0
- data/lib/shadcn/ui/alert.rb +79 -0
- data/lib/shadcn/ui/alert_dialog.rb +220 -0
- data/lib/shadcn/ui/aspect_ratio.rb +35 -0
- data/lib/shadcn/ui/avatar.rb +134 -0
- data/lib/shadcn/ui/badge.rb +48 -0
- data/lib/shadcn/ui/breadcrumb.rb +180 -0
- data/lib/shadcn/ui/button.rb +63 -0
- data/lib/shadcn/ui/button_group.rb +58 -0
- data/lib/shadcn/ui/card.rb +133 -0
- data/lib/shadcn/ui/checkbox.rb +72 -0
- data/lib/shadcn/ui/collapsible.rb +76 -0
- data/lib/shadcn/ui/combobox.rb +229 -0
- data/lib/shadcn/ui/command.rb +256 -0
- data/lib/shadcn/ui/context_menu.rb +319 -0
- data/lib/shadcn/ui/dialog.rb +226 -0
- data/lib/shadcn/ui/direction.rb +23 -0
- data/lib/shadcn/ui/drawer.rb +217 -0
- data/lib/shadcn/ui/dropdown_menu.rb +384 -0
- data/lib/shadcn/ui/empty.rb +97 -0
- data/lib/shadcn/ui/field.rb +126 -0
- data/lib/shadcn/ui/hover_card.rb +75 -0
- data/lib/shadcn/ui/input.rb +36 -0
- data/lib/shadcn/ui/input_group.rb +32 -0
- data/lib/shadcn/ui/input_otp.rb +112 -0
- data/lib/shadcn/ui/item.rb +115 -0
- data/lib/shadcn/ui/kbd.rb +45 -0
- data/lib/shadcn/ui/label.rb +28 -0
- data/lib/shadcn/ui/menubar.rb +345 -0
- data/lib/shadcn/ui/native_select.rb +31 -0
- data/lib/shadcn/ui/navigation_menu.rb +238 -0
- data/lib/shadcn/ui/pagination.rb +224 -0
- data/lib/shadcn/ui/popover.rb +147 -0
- data/lib/shadcn/ui/progress.rb +40 -0
- data/lib/shadcn/ui/radio_group.rb +92 -0
- data/lib/shadcn/ui/resizable.rb +108 -0
- data/lib/shadcn/ui/scroll_area.rb +75 -0
- data/lib/shadcn/ui/select.rb +235 -0
- data/lib/shadcn/ui/separator.rb +36 -0
- data/lib/shadcn/ui/sheet.rb +231 -0
- data/lib/shadcn/ui/sidebar.rb +420 -0
- data/lib/shadcn/ui/skeleton.rb +23 -0
- data/lib/shadcn/ui/slider.rb +72 -0
- data/lib/shadcn/ui/sonner.rb +177 -0
- data/lib/shadcn/ui/spinner.rb +58 -0
- data/lib/shadcn/ui/switch.rb +75 -0
- data/lib/shadcn/ui/table.rb +154 -0
- data/lib/shadcn/ui/tabs.rb +154 -0
- data/lib/shadcn/ui/text_field.rb +146 -0
- data/lib/shadcn/ui/textarea.rb +32 -0
- data/lib/shadcn/ui/theme_toggle.rb +74 -0
- data/lib/shadcn/ui/toggle.rb +66 -0
- data/lib/shadcn/ui/toggle_group.rb +75 -0
- data/lib/shadcn/ui/tooltip.rb +78 -0
- data/lib/shadcn/ui/typography.rb +217 -0
- data/lib/shadcn/version.rb +5 -0
- data/lib/shadcn-phlex.rb +6 -0
- data/lib/shadcn.rb +80 -0
- data/package.json +14 -0
- data/skills/shadcn-phlex/SKILL.md +190 -0
- data/skills/shadcn-phlex/evals/evals.json +90 -0
- data/skills/shadcn-phlex/references/component-catalog.md +355 -0
- data/skills/shadcn-phlex/rules/composition.md +235 -0
- data/skills/shadcn-phlex/rules/forms.md +151 -0
- data/skills/shadcn-phlex/rules/helpers.md +54 -0
- data/skills/shadcn-phlex/rules/stimulus.md +61 -0
- data/skills/shadcn-phlex/rules/styling.md +177 -0
- metadata +209 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix ToggleGroup behavior
|
|
4
|
+
// Supports single (default) and multiple selection
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["item"]
|
|
7
|
+
static values = {
|
|
8
|
+
type: { type: String, default: "single" }, // "single" or "multiple"
|
|
9
|
+
value: { type: Array, default: [] },
|
|
10
|
+
disabled: { type: Boolean, default: false },
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
connect() {
|
|
14
|
+
this._syncState()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggle(event) {
|
|
18
|
+
if (this.disabledValue) return
|
|
19
|
+
const item = event.currentTarget
|
|
20
|
+
const value = item.dataset.value
|
|
21
|
+
if (!value) return
|
|
22
|
+
|
|
23
|
+
if (this.typeValue === "single") {
|
|
24
|
+
// Toggle off if already selected, otherwise select
|
|
25
|
+
if (this.valueValue.includes(value)) {
|
|
26
|
+
this.valueValue = []
|
|
27
|
+
} else {
|
|
28
|
+
this.valueValue = [value]
|
|
29
|
+
}
|
|
30
|
+
} else {
|
|
31
|
+
// Toggle in/out of array
|
|
32
|
+
if (this.valueValue.includes(value)) {
|
|
33
|
+
this.valueValue = this.valueValue.filter((v) => v !== value)
|
|
34
|
+
} else {
|
|
35
|
+
this.valueValue = [...this.valueValue, value]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
this.dispatch("change", { detail: { value: this.valueValue } })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
keydown(event) {
|
|
43
|
+
const items = this.itemTargets
|
|
44
|
+
const current = items.indexOf(event.currentTarget)
|
|
45
|
+
if (current === -1) return
|
|
46
|
+
|
|
47
|
+
let nextIndex
|
|
48
|
+
switch (event.key) {
|
|
49
|
+
case "ArrowRight":
|
|
50
|
+
case "ArrowDown":
|
|
51
|
+
event.preventDefault()
|
|
52
|
+
nextIndex = (current + 1) % items.length
|
|
53
|
+
items[nextIndex].focus()
|
|
54
|
+
break
|
|
55
|
+
case "ArrowLeft":
|
|
56
|
+
case "ArrowUp":
|
|
57
|
+
event.preventDefault()
|
|
58
|
+
nextIndex = (current - 1 + items.length) % items.length
|
|
59
|
+
items[nextIndex].focus()
|
|
60
|
+
break
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
valueValueChanged() { this._syncState() }
|
|
65
|
+
|
|
66
|
+
_syncState() {
|
|
67
|
+
this.itemTargets.forEach((item) => {
|
|
68
|
+
const isPressed = this.valueValue.includes(item.dataset.value)
|
|
69
|
+
item.dataset.state = isPressed ? "on" : "off"
|
|
70
|
+
item.setAttribute("aria-pressed", String(isPressed))
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Replicates Radix Tooltip behavior
|
|
4
|
+
// Show on hover/focus with delay, hide on leave/blur, positioning
|
|
5
|
+
export default class extends Controller {
|
|
6
|
+
static targets = ["trigger", "content"]
|
|
7
|
+
static values = {
|
|
8
|
+
open: { type: Boolean, default: false },
|
|
9
|
+
side: { type: String, default: "top" },
|
|
10
|
+
sideOffset: { type: Number, default: 4 },
|
|
11
|
+
delayDuration: { type: Number, default: 200 },
|
|
12
|
+
skipDelayDuration: { type: Number, default: 300 },
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
connect() {
|
|
16
|
+
this._showTimeout = null
|
|
17
|
+
this._hideTimeout = null
|
|
18
|
+
this._hideTimeouts = []
|
|
19
|
+
this._syncState()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
disconnect() {
|
|
23
|
+
clearTimeout(this._showTimeout)
|
|
24
|
+
clearTimeout(this._hideTimeout)
|
|
25
|
+
this._hideTimeouts.forEach(id => clearTimeout(id))
|
|
26
|
+
this._hideTimeouts = []
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
mouseEnter() {
|
|
30
|
+
clearTimeout(this._hideTimeout)
|
|
31
|
+
this._showTimeout = setTimeout(() => { this.openValue = true }, this.delayDurationValue)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
mouseLeave() {
|
|
35
|
+
clearTimeout(this._showTimeout)
|
|
36
|
+
this._hideTimeout = setTimeout(() => { this.openValue = false }, 100)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
focusIn() {
|
|
40
|
+
clearTimeout(this._hideTimeout)
|
|
41
|
+
this.openValue = true
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
focusOut() {
|
|
45
|
+
this.openValue = false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
contentMouseEnter() {
|
|
49
|
+
clearTimeout(this._hideTimeout)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
contentMouseLeave() {
|
|
53
|
+
this._hideTimeout = setTimeout(() => { this.openValue = false }, 100)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
openValueChanged() { this._syncState() }
|
|
57
|
+
|
|
58
|
+
_syncState() {
|
|
59
|
+
if (!this._hideTimeouts) return
|
|
60
|
+
const state = this.openValue ? "open" : "closed"
|
|
61
|
+
this.element.dataset.state = state
|
|
62
|
+
|
|
63
|
+
this.contentTargets.forEach((el) => {
|
|
64
|
+
el.dataset.state = state
|
|
65
|
+
if (this.openValue) {
|
|
66
|
+
el.hidden = false
|
|
67
|
+
el.setAttribute("role", "tooltip")
|
|
68
|
+
this._position(el)
|
|
69
|
+
} else {
|
|
70
|
+
this._hideAfterAnimation(el)
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
// Link trigger aria-describedby
|
|
75
|
+
if (this.hasTriggerTarget && this.hasContentTarget) {
|
|
76
|
+
if (this.openValue) {
|
|
77
|
+
const id = this._ensureId(this.contentTarget, "tooltip")
|
|
78
|
+
this.triggerTarget.setAttribute("aria-describedby", id)
|
|
79
|
+
} else {
|
|
80
|
+
this.triggerTarget.removeAttribute("aria-describedby")
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_position(content) {
|
|
86
|
+
const trigger = this.hasTriggerTarget ? this.triggerTarget : null
|
|
87
|
+
if (!trigger) return
|
|
88
|
+
|
|
89
|
+
const triggerRect = trigger.getBoundingClientRect()
|
|
90
|
+
const offset = this.sideOffsetValue
|
|
91
|
+
|
|
92
|
+
content.style.position = "fixed"
|
|
93
|
+
content.style.zIndex = "50"
|
|
94
|
+
content.style.pointerEvents = "auto"
|
|
95
|
+
|
|
96
|
+
// Measure
|
|
97
|
+
const contentRect = content.getBoundingClientRect()
|
|
98
|
+
let top, left
|
|
99
|
+
|
|
100
|
+
switch (this.sideValue) {
|
|
101
|
+
case "top":
|
|
102
|
+
top = triggerRect.top - contentRect.height - offset
|
|
103
|
+
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
|
|
104
|
+
content.dataset.side = "top"
|
|
105
|
+
break
|
|
106
|
+
case "bottom":
|
|
107
|
+
top = triggerRect.bottom + offset
|
|
108
|
+
left = triggerRect.left + (triggerRect.width - contentRect.width) / 2
|
|
109
|
+
content.dataset.side = "bottom"
|
|
110
|
+
break
|
|
111
|
+
case "left":
|
|
112
|
+
top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
|
|
113
|
+
left = triggerRect.left - contentRect.width - offset
|
|
114
|
+
content.dataset.side = "left"
|
|
115
|
+
break
|
|
116
|
+
case "right":
|
|
117
|
+
top = triggerRect.top + (triggerRect.height - contentRect.height) / 2
|
|
118
|
+
left = triggerRect.right + offset
|
|
119
|
+
content.dataset.side = "right"
|
|
120
|
+
break
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Flip if overflowing
|
|
124
|
+
if (this.sideValue === "top" && top < 0) {
|
|
125
|
+
top = triggerRect.bottom + offset
|
|
126
|
+
content.dataset.side = "bottom"
|
|
127
|
+
} else if (this.sideValue === "bottom" && top + contentRect.height > window.innerHeight) {
|
|
128
|
+
top = triggerRect.top - contentRect.height - offset
|
|
129
|
+
content.dataset.side = "top"
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
left = Math.max(8, Math.min(left, window.innerWidth - contentRect.width - 8))
|
|
133
|
+
|
|
134
|
+
content.style.top = `${top}px`
|
|
135
|
+
content.style.left = `${left}px`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
_hideAfterAnimation(el) {
|
|
139
|
+
this._hideTimeouts.push(setTimeout(() => { if (el.dataset.state === "closed") el.hidden = true }, 150))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
_ensureId(el, prefix) {
|
|
143
|
+
if (!el.id) el.id = `${prefix}-${Math.random().toString(36).slice(2, 9)}`
|
|
144
|
+
return el.id
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ShadcnPhlex
|
|
6
|
+
class ComponentGenerator < Rails::Generators::Base
|
|
7
|
+
desc "Copy a shadcn component into your app"
|
|
8
|
+
argument :name, type: :string, desc: "Component name (e.g., button, card, dialog)"
|
|
9
|
+
|
|
10
|
+
COMPONENTS = %w[
|
|
11
|
+
accordion alert alert_dialog aspect_ratio avatar badge breadcrumb
|
|
12
|
+
button button_group card checkbox collapsible context_menu dialog
|
|
13
|
+
direction drawer dropdown_menu empty field hover_card input
|
|
14
|
+
input_group item kbd label menubar native_select navigation_menu
|
|
15
|
+
pagination popover progress radio_group scroll_area select
|
|
16
|
+
separator sheet skeleton slider sonner spinner switch table tabs
|
|
17
|
+
textarea toggle toggle_group tooltip typography
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Components that need a Stimulus controller
|
|
21
|
+
INTERACTIVE = %w[
|
|
22
|
+
accordion checkbox collapsible combobox command context_menu dialog
|
|
23
|
+
drawer dropdown_menu hover_card menubar navigation_menu popover
|
|
24
|
+
radio_group scroll_area select sheet slider switch tabs toast
|
|
25
|
+
toggle toggle_group tooltip
|
|
26
|
+
].freeze
|
|
27
|
+
|
|
28
|
+
def validate_component
|
|
29
|
+
unless COMPONENTS.include?(normalized_name) || normalized_name == "all"
|
|
30
|
+
say_status :error, "Unknown component: #{name}. Available: #{COMPONENTS.join(', ')}", :red
|
|
31
|
+
raise SystemExit
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_component
|
|
36
|
+
if normalized_name == "all"
|
|
37
|
+
COMPONENTS.each { |c| copy_single_component(c) }
|
|
38
|
+
else
|
|
39
|
+
copy_single_component(normalized_name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def copy_stimulus_controller
|
|
44
|
+
if normalized_name == "all"
|
|
45
|
+
INTERACTIVE.each { |c| copy_single_controller(c) }
|
|
46
|
+
elsif INTERACTIVE.include?(normalized_name)
|
|
47
|
+
copy_single_controller(normalized_name)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalized_name
|
|
54
|
+
@normalized_name ||= name.underscore.tr("-", "_")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def copy_single_component(component_name)
|
|
58
|
+
source = gem_root.join("lib/shadcn/ui/#{component_name}.rb")
|
|
59
|
+
if File.exist?(source)
|
|
60
|
+
dest = "app/components/shadcn/ui/#{component_name}.rb"
|
|
61
|
+
copy_file source, dest
|
|
62
|
+
say_status :create, dest, :green
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def copy_single_controller(component_name)
|
|
67
|
+
source = gem_root.join("js/controllers/#{component_name}_controller.js")
|
|
68
|
+
if File.exist?(source)
|
|
69
|
+
dest = "app/javascript/controllers/shadcn/#{component_name}_controller.js"
|
|
70
|
+
copy_file source, dest
|
|
71
|
+
say_status :create, dest, :green
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def gem_root
|
|
76
|
+
Pathname.new(File.expand_path("../../..", __dir__))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module ShadcnPhlex
|
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
|
7
|
+
source_root File.expand_path("install/templates", __dir__)
|
|
8
|
+
desc "Install shadcn-phlex: CSS, Stimulus controllers, and base config"
|
|
9
|
+
|
|
10
|
+
class_option :base_color, type: :string, default: "neutral",
|
|
11
|
+
desc: "Base color theme (neutral, stone, zinc, mauve, olive, mist, taupe)"
|
|
12
|
+
class_option :accent_color, type: :string, default: nil,
|
|
13
|
+
desc: "Accent color (blue, red, green, violet, orange, amber, etc.)"
|
|
14
|
+
class_option :radius, type: :string, default: "0.625rem",
|
|
15
|
+
desc: "Border radius base value"
|
|
16
|
+
|
|
17
|
+
def add_gems
|
|
18
|
+
gem "phlex-rails", "~> 2.1" unless gem_installed?("phlex-rails")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def add_npm_packages
|
|
22
|
+
say_status :run, "Adding tw-animate-css", :green
|
|
23
|
+
if File.exist?("package.json")
|
|
24
|
+
run "npm install tw-animate-css"
|
|
25
|
+
elsif File.exist?("yarn.lock")
|
|
26
|
+
run "yarn add tw-animate-css"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def configure_autoload
|
|
31
|
+
application_rb = "config/application.rb"
|
|
32
|
+
return unless File.exist?(application_rb)
|
|
33
|
+
|
|
34
|
+
content = File.read(application_rb)
|
|
35
|
+
return if content.include?("app/views")
|
|
36
|
+
|
|
37
|
+
inject_into_file application_rb,
|
|
38
|
+
after: /config\.autoload_lib.*\n/ do
|
|
39
|
+
<<-RUBY
|
|
40
|
+
# Autoload Phlex views
|
|
41
|
+
config.autoload_paths << Rails.root.join("app/views")
|
|
42
|
+
|
|
43
|
+
RUBY
|
|
44
|
+
end
|
|
45
|
+
say_status :inject, "config.autoload_paths << app/views", :green
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def create_application_view
|
|
49
|
+
path = "app/views/application_view.rb"
|
|
50
|
+
return if File.exist?(path)
|
|
51
|
+
|
|
52
|
+
create_file path, <<~RUBY
|
|
53
|
+
# frozen_string_literal: true
|
|
54
|
+
|
|
55
|
+
class ApplicationView < Phlex::HTML
|
|
56
|
+
include Shadcn::Kit
|
|
57
|
+
include Phlex::Rails::Helpers::Routes
|
|
58
|
+
include Phlex::Rails::Helpers::StyleSheetLinkTag
|
|
59
|
+
include Phlex::Rails::Helpers::JavaScriptIncludeTag
|
|
60
|
+
|
|
61
|
+
# Dark mode blocking script — prevents flash of light mode
|
|
62
|
+
DARK_MODE_SCRIPT = '(function(){try{var t=localStorage.getItem("theme");if(t==="dark"||(t!=="light"&&window.matchMedia("(prefers-color-scheme:dark)").matches)){document.documentElement.classList.add("dark")}}catch(e){}})();'
|
|
63
|
+
|
|
64
|
+
def around_template(&block)
|
|
65
|
+
doctype
|
|
66
|
+
html(lang: "en") do
|
|
67
|
+
head do
|
|
68
|
+
meta(charset: "utf-8")
|
|
69
|
+
meta(name: "viewport", content: "width=device-width,initial-scale=1")
|
|
70
|
+
title { page_title }
|
|
71
|
+
script { raw(Phlex::HTML::SafeValue.new(DARK_MODE_SCRIPT)) }
|
|
72
|
+
stylesheet_link_tag("application")
|
|
73
|
+
javascript_include_tag("application", defer: "defer")
|
|
74
|
+
end
|
|
75
|
+
body(class: "min-h-screen bg-background text-foreground antialiased", data_controller: "shadcn--dark-mode") do
|
|
76
|
+
yield
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def page_title
|
|
84
|
+
"My App"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
RUBY
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def copy_tailwind_config
|
|
91
|
+
source = gem_css("shadcn-tailwind.css")
|
|
92
|
+
create_file "app/assets/stylesheets/shadcn-tailwind.css", File.read(source)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def create_theme
|
|
96
|
+
base_color = options[:base_color].to_sym
|
|
97
|
+
accent_color = options[:accent_color]&.to_sym
|
|
98
|
+
radius = options[:radius]
|
|
99
|
+
|
|
100
|
+
require "shadcn/themes/base_colors"
|
|
101
|
+
require "shadcn/themes/accent_colors"
|
|
102
|
+
|
|
103
|
+
theme_css = Shadcn::Themes.generate_css(
|
|
104
|
+
base_color: base_color,
|
|
105
|
+
accent_color: accent_color,
|
|
106
|
+
radius: radius
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
create_file "app/assets/stylesheets/shadcn-theme.css", <<~CSS
|
|
110
|
+
/*
|
|
111
|
+
* shadcn theme: #{base_color}#{accent_color ? " + #{accent_color}" : ""}
|
|
112
|
+
* To change themes, replace this file with CSS from ui.shadcn.com/themes
|
|
113
|
+
*/
|
|
114
|
+
#{theme_css}
|
|
115
|
+
CSS
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def create_css_entrypoint
|
|
119
|
+
gem_path = Gem.loaded_specs["shadcn-phlex"]&.full_gem_path
|
|
120
|
+
|
|
121
|
+
create_file "app/assets/stylesheets/shadcn.css", <<~CSS
|
|
122
|
+
@import "tailwindcss";
|
|
123
|
+
@import "tw-animate-css";
|
|
124
|
+
@import "./shadcn-tailwind.css";
|
|
125
|
+
@import "./shadcn-theme.css";
|
|
126
|
+
|
|
127
|
+
/* Ensure Tailwind scans component files for class names */
|
|
128
|
+
@source "../../app/components";
|
|
129
|
+
@source "../../app/views";
|
|
130
|
+
#{gem_path ? "@source \"#{gem_path}/lib/**/*.rb\";" : "/* @source \"path/to/shadcn-phlex/lib/**/*.rb\"; */"}
|
|
131
|
+
CSS
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def copy_stimulus_controllers
|
|
135
|
+
target = "app/javascript/controllers/shadcn"
|
|
136
|
+
|
|
137
|
+
if File.directory?(gem_js_dir) && !File.directory?(target)
|
|
138
|
+
js_files = Dir[File.join(gem_js_dir, "*.js")]
|
|
139
|
+
js_files.each do |file|
|
|
140
|
+
create_file File.join(target, File.basename(file)), File.read(file)
|
|
141
|
+
end
|
|
142
|
+
say_status :copied, "#{js_files.count} Stimulus controllers to #{target}/", :green
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def create_stimulus_registration
|
|
147
|
+
index_path = "app/javascript/controllers/index.js"
|
|
148
|
+
return unless File.exist?(index_path)
|
|
149
|
+
|
|
150
|
+
content = File.read(index_path)
|
|
151
|
+
return if content.include?("shadcn")
|
|
152
|
+
|
|
153
|
+
append_to_file index_path, <<~JS
|
|
154
|
+
|
|
155
|
+
// shadcn-phlex Stimulus controllers
|
|
156
|
+
import { registerShadcnControllers } from "./shadcn/index"
|
|
157
|
+
registerShadcnControllers(application)
|
|
158
|
+
JS
|
|
159
|
+
say_status :inject, "shadcn controllers into #{index_path}", :green
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def print_setup_complete
|
|
163
|
+
say_status :info, "Setup complete!", :green
|
|
164
|
+
say <<~MSG
|
|
165
|
+
|
|
166
|
+
shadcn-phlex is ready. Here's what was set up:
|
|
167
|
+
- app/views/application_view.rb (base view with Kit, dark mode, asset tags)
|
|
168
|
+
- app/assets/stylesheets/shadcn.css (Tailwind + theme)
|
|
169
|
+
- app/javascript/controllers/shadcn/ (Stimulus controllers)
|
|
170
|
+
- config/application.rb (autoload app/views)
|
|
171
|
+
|
|
172
|
+
Start using components:
|
|
173
|
+
class MyView < ApplicationView
|
|
174
|
+
def view_template
|
|
175
|
+
ui_button { "Hello" }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
To change your theme:
|
|
180
|
+
1. Go to ui.shadcn.com/themes
|
|
181
|
+
2. Copy the CSS
|
|
182
|
+
3. Paste into app/assets/stylesheets/shadcn-theme.css
|
|
183
|
+
|
|
184
|
+
For AI/LLM support:
|
|
185
|
+
npx skills add shadcn-phlex/shadcn-phlex
|
|
186
|
+
|
|
187
|
+
MSG
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
def gem_installed?(name)
|
|
193
|
+
File.read("Gemfile").include?(name)
|
|
194
|
+
rescue Errno::ENOENT
|
|
195
|
+
false
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def gem_root
|
|
199
|
+
@gem_root ||= begin
|
|
200
|
+
spec = Gem.loaded_specs["shadcn-phlex"]
|
|
201
|
+
if spec
|
|
202
|
+
spec.full_gem_path
|
|
203
|
+
else
|
|
204
|
+
File.expand_path("../../../..", __dir__)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def gem_css(filename)
|
|
210
|
+
File.join(gem_root, "css", filename)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def gem_js_dir
|
|
214
|
+
File.join(gem_root, "js", "controllers")
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
data/lib/shadcn/base.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shadcn
|
|
4
|
+
class Base < Phlex::HTML
|
|
5
|
+
TAILWIND_MERGER = TailwindMerge::Merger.new
|
|
6
|
+
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
# Equivalent to shadcn's cn() utility - merges Tailwind classes intelligently
|
|
10
|
+
def cn(*classes)
|
|
11
|
+
merged = classes.flatten.compact.join(" ")
|
|
12
|
+
TAILWIND_MERGER.merge(merged)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Merge user-provided attrs with defaults, intelligently merging classes
|
|
16
|
+
def merge_attrs(defaults, overrides)
|
|
17
|
+
result = defaults.merge(overrides) do |key, default_val, override_val|
|
|
18
|
+
if key == :class
|
|
19
|
+
cn(default_val, override_val)
|
|
20
|
+
else
|
|
21
|
+
override_val
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Shadcn
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Shadcn
|
|
6
|
+
|
|
7
|
+
initializer "shadcn.assets" do |app|
|
|
8
|
+
# Add JS controllers to asset pipeline
|
|
9
|
+
if app.config.respond_to?(:assets)
|
|
10
|
+
app.config.assets.paths << root.join("js")
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
initializer "shadcn.importmap", before: "importmap" do |app|
|
|
15
|
+
if app.config.respond_to?(:importmap)
|
|
16
|
+
# Pin all Stimulus controllers
|
|
17
|
+
app.config.importmap.pin_all_from(
|
|
18
|
+
root.join("js/controllers"),
|
|
19
|
+
under: "shadcn/controllers"
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|