Skip to content

Commit 3283032

Browse files
committed
Adds menu_button and dropdown components.
1 parent c4706a3 commit 3283032

File tree

9 files changed

+321
-0
lines changed

9 files changed

+321
-0
lines changed

Gemfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ gem "standard", require: false
1212

1313
# Start debugger with binding.b [https://github.com/ruby/debug]
1414
# gem "debug", ">= 1.0.0"
15+
16+
gem "importmap-rails", "~> 2.1"

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ GEM
9393
activesupport (>= 6.1)
9494
i18n (1.14.6)
9595
concurrent-ruby (~> 1.0)
96+
importmap-rails (2.1.0)
97+
actionpack (>= 6.0.0)
98+
activesupport (>= 6.0.0)
99+
railties (>= 6.0.0)
96100
io-console (0.8.0)
97101
irb (1.14.3)
98102
rdoc (>= 4.0.0)
@@ -255,6 +259,7 @@ PLATFORMS
255259
x86_64-linux-musl
256260

257261
DEPENDENCIES
262+
importmap-rails (~> 2.1)
258263
maquina-components!
259264
puma
260265
sqlite3

app/assets/stylesheets/maquina_components.css

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,156 @@
1+
/* Base animation utilities */
2+
@utility animate-in {
3+
animation-duration: var(--duration-normal);
4+
animation-timing-function: var(--ease-out);
5+
animation-fill-mode: forwards;
6+
}
7+
8+
@utility animate-out {
9+
animation-duration: var(--duration-normal);
10+
animation-timing-function: var(--ease-in);
11+
animation-fill-mode: forwards;
12+
}
13+
14+
/* Fade animations */
15+
@keyframes fade-in-0 {
16+
from {
17+
opacity: 0;
18+
}
19+
20+
to {
21+
opacity: 1;
22+
}
23+
}
24+
25+
@keyframes fade-out-0 {
26+
from {
27+
opacity: 1;
28+
}
29+
30+
to {
31+
opacity: 0;
32+
}
33+
}
34+
35+
@utility fade-in-0 {
36+
animation-name: fade-in-0;
37+
}
38+
39+
@utility fade-out-0 {
40+
animation-name: fade-out-0;
41+
}
42+
43+
/* Zoom animations */
44+
@keyframes zoom-in-95 {
45+
from {
46+
transform: scale(0.95);
47+
opacity: 0;
48+
}
49+
50+
to {
51+
transform: scale(1);
52+
opacity: 1;
53+
}
54+
}
55+
56+
@keyframes zoom-out-95 {
57+
from {
58+
transform: scale(1);
59+
opacity: 1;
60+
}
61+
62+
to {
63+
transform: scale(0.95);
64+
opacity: 0;
65+
}
66+
}
67+
68+
@utility zoom-in-95 {
69+
animation-name: zoom-in-95;
70+
}
71+
72+
@utility zoom-out-95 {
73+
animation-name: zoom-out-95;
74+
}
75+
76+
/* Slide animations with specific measurements */
77+
@keyframes slide-in-from-top-2 {
78+
from {
79+
transform: translateY(-0.5rem);
80+
opacity: 0;
81+
}
82+
83+
to {
84+
transform: translateY(0);
85+
opacity: 1;
86+
}
87+
}
88+
89+
@keyframes slide-in-from-bottom-2 {
90+
from {
91+
transform: translateY(0.5rem);
92+
opacity: 0;
93+
}
94+
95+
to {
96+
transform: translateY(0);
97+
opacity: 1;
98+
}
99+
}
100+
101+
@keyframes slide-in-from-left-2 {
102+
from {
103+
transform: translateX(-0.5rem);
104+
opacity: 0;
105+
}
106+
107+
to {
108+
transform: translateX(0);
109+
opacity: 1;
110+
}
111+
}
112+
113+
@keyframes slide-in-from-right-2 {
114+
from {
115+
transform: translateX(0.5rem);
116+
opacity: 0;
117+
}
118+
119+
to {
120+
transform: translateX(0);
121+
opacity: 1;
122+
}
123+
}
124+
125+
@utility slide-in-from-top-2 {
126+
animation-name: slide-in-from-top-2;
127+
}
128+
129+
@utility slide-in-from-bottom-2 {
130+
animation-name: slide-in-from-bottom-2;
131+
}
132+
133+
@utility slide-in-from-left-2 {
134+
animation-name: slide-in-from-left-2;
135+
}
136+
137+
@utility slide-in-from-right-2 {
138+
animation-name: slide-in-from-right-2;
139+
}
140+
141+
/* Duration modifiers */
142+
@utility duration-fast {
143+
animation-duration: var(--duration-fast);
144+
}
145+
146+
@utility duration-normal {
147+
animation-duration: var(--duration-normal);
148+
}
149+
150+
@utility duration-slow {
151+
animation-duration: var(--duration-slow);
152+
}
153+
1154
@utility button-base {
2155
display: inline-flex;
3156
align-items: center;
@@ -97,3 +250,11 @@
97250
.button-destructive {
98251
@apply button-base bg-destructive text-destructive shadow-sm hover:bg-destructive/90;
99252
}
253+
254+
.separator {
255+
@apply -mx-1 my-1 h-px bg-muted;
256+
}
257+
258+
.dropdown-menu-item {
259+
@apply relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0;
260+
}

app/helpers/components/icons_helper.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ def icon_svg_for(name)
118118
<line x1="12" x2="12.01" y1="16" y2="16"/>
119119
</svg>
120120
SVG
121+
when :logout
122+
<<~SVG.freeze
123+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
124+
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
125+
<polyline points="16 17 21 12 16 7"></polyline>
126+
<line x1="21" x2="9" y1="12" y2="12"></line>
127+
</svg>
128+
SVG
129+
when :chevron_up_down
130+
<<~SVG.freeze
131+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="">
132+
<path d="m7 15 5 5 5-5"></path>
133+
<path d="m7 9 5-5 5 5"></path>
134+
</svg>
135+
SVG
121136
end
122137
end
123138
end
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { Controller } from "@hotwired/stimulus"
2+
3+
export default class extends Controller {
4+
static targets = ["button", "content"]
5+
6+
connect() {
7+
if (!this.hasContentTarget) {
8+
return
9+
}
10+
11+
this.clickOutside = this.clickOutside.bind(this)
12+
this.isOpen = this.buttonTarget.dataset.state === "open"
13+
14+
if (this.isOpen) {
15+
this.addClickOutsideListener()
16+
}
17+
}
18+
19+
disconnect() {
20+
this.removeClickOutsideListener()
21+
}
22+
23+
toggle() {
24+
if (!this.hasContentTarget) {
25+
return
26+
}
27+
28+
this.contentTarget.classList.remove("hidden")
29+
30+
this.isOpen = !this.isOpen
31+
this.buttonTarget.dataset.state = this.isOpen ? "open" : "closed"
32+
33+
if (this.isOpen) {
34+
// Add a small delay before adding the click outside listener
35+
setTimeout(() => {
36+
this.addClickOutsideListener()
37+
}, 100)
38+
} else {
39+
this.removeClickOutsideListener()
40+
}
41+
}
42+
43+
clickOutside(event) {
44+
if (!this.isOpen) return
45+
if (event.target === this.element) return
46+
47+
if (!this.contentTarget.contains(event.target)) {
48+
this.toggle()
49+
}
50+
}
51+
52+
addClickOutsideListener() {
53+
document.addEventListener('click', this.clickOutside)
54+
}
55+
56+
removeClickOutsideListener() {
57+
document.removeEventListener('click', this.clickOutside)
58+
}
59+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<div
2+
data-menu-button-target="content"
3+
class="
4+
absolute z-10 overflow-hidden border border-border p-1 text-popover-foreground
5+
min-w-56 max-w-56 content-fit shadow-md bottom-full rounded-lg hidden
6+
peer-data-[state=closed]/menu-button:animate-out
7+
peer-data-[state=closed]/menu-button:fade-out-0
8+
peer-data-[state=open]/menu-button:fade-in-0
9+
peer-data-[state=closed]/menu-button:zoom-out-95
10+
peer-data-[state=open]/menu-button:zoom-in-95
11+
peer-data-[side=bottom]/menu-button:slide-in-from-top-2
12+
peer-data-[side=left]/menu-button:slide-in-from-right-2
13+
peer-data-[side=right]/menu-button:slide-in-from-left-2
14+
peer-data-[side=top]/menu-button:slide-in-from-bottom-2
15+
"
16+
role="menu"
17+
aria-orientation="vertical"
18+
aria-labelledby="menu-button"
19+
tabindex="-1"
20+
>
21+
<%= yield %>
22+
</div>
23+
<!--
24+
<div role="separator" aria-orientation="horizontal" class="separator"></div>
25+
-->
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<%# locals: (title:, subtitle: nil, icon: nil, text_icon: nil) %>
2+
3+
<ul class="flex w-full min-w-0 flex-col gap-1">
4+
<li class="group/menu-item relative" data-controller="menu-button">
5+
<button
6+
data-state="closed"
7+
class="
8+
peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2
9+
text-left outline-none ring-sidebar-ring transition-[width,height,padding]
10+
focus-visible:ring-2 active:bg-sidebar-accent
11+
active:text-sidebar-accent-foreground disabled:pointer-events-none
12+
disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50
13+
data-[state=open]:bg-sidebar-accent
14+
data-[state=open]text-sidebar-accent-foreground
15+
group-data-[collapsible=icon]:!size-8 [&>span:last-child]:truncate
16+
[&>svg]:size-4 [&>svg]:shrink-0 hover:bg-sidebar-accent
17+
hover:text-sidebar-accent-foreground h-12 text-sm
18+
group-data-[collapsible=icon]:!p-0
19+
", data-action="menu-button#toggle", data-menu-button-target="button" }
20+
>
21+
<div
22+
class="
23+
flex aspect-square size-8 items-center justify-center rounded-lg
24+
bg-sidebar-primary text-sidebar-primary-foreground
25+
"
26+
>
27+
<% if icon.present? %>
28+
<!-- Here goes the icon -->
29+
<% elsif text_icon.present? %>
30+
<%= text_icon %>
31+
<% end %>
32+
</div>
33+
<div class="grid flex-1 text-left text-sm leading-tight">
34+
<span class="truncate font-semibold"><%= title %></span>
35+
<% if subtitle.present? %>
36+
<span class="truncate text-xs"><%= subtitle %></span>
37+
<% end %>
38+
</div>
39+
<%= icon_for(:chevron_up_down, class: "ml-auto") %>
40+
</button>
41+
42+
<%= yield %>
43+
</li>
44+
</ul>

config/importmap.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pin_all_from File.expand_path("../app/javascript/controllers", __dir__), under: "controllers", preload: false

lib/maquina_components/engine.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
module MaquinaComponents
22
class Engine < ::Rails::Engine
3+
initializer "maquina-components.importmap", before: "importmap" do |app|
4+
app.config.importmap.paths << Engine.root.join("config/importmap.rb")
5+
end
6+
7+
initializer "maquin-components.importmap.assets" do |app|
8+
if app.config.respond_to?(:assets)
9+
app.config.assets.paths << Engine.root.join("app/javascript")
10+
end
11+
end
312
end
413
end

0 commit comments

Comments
 (0)