kanso 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 (40) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +352 -0
  4. data/Rakefile +8 -0
  5. data/app/assets/images/kanso/icons/check-circle.svg +3 -0
  6. data/app/assets/images/kanso/icons/chevron-down.svg +3 -0
  7. data/app/assets/images/kanso/icons/exclamation-circle.svg +3 -0
  8. data/app/assets/images/kanso/icons/exclamation-triangle.svg +3 -0
  9. data/app/assets/images/kanso/icons/information-circle.svg +3 -0
  10. data/app/assets/images/kanso/icons/question-circle.svg +3 -0
  11. data/app/assets/images/kanso/icons/x-circle.svg +3 -0
  12. data/app/assets/images/kanso/icons/x-mark.svg +3 -0
  13. data/app/assets/javascripts/kanso/controllers/dropdown_controller.js +52 -0
  14. data/app/assets/javascripts/kanso/controllers/form_controller.js +17 -0
  15. data/app/assets/javascripts/kanso/controllers/modal_controller.js +48 -0
  16. data/app/assets/javascripts/kanso/controllers/notification_controller.js +43 -0
  17. data/app/assets/javascripts/kanso/helpers/transition.js +49 -0
  18. data/app/components/kanso/button_component.rb +58 -0
  19. data/app/components/kanso/class_combinable.rb +25 -0
  20. data/app/components/kanso/dropdown_component.html.erb +19 -0
  21. data/app/components/kanso/dropdown_component.rb +10 -0
  22. data/app/components/kanso/form_field_component.html.erb +24 -0
  23. data/app/components/kanso/form_field_component.rb +51 -0
  24. data/app/components/kanso/form_field_skeleton_component.html.erb +7 -0
  25. data/app/components/kanso/form_field_skeleton_component.rb +11 -0
  26. data/app/components/kanso/icon_component.rb +30 -0
  27. data/app/components/kanso/modal_component.html.erb +53 -0
  28. data/app/components/kanso/modal_component.rb +49 -0
  29. data/app/components/kanso/notification_component.html.erb +25 -0
  30. data/app/components/kanso/notification_component.rb +58 -0
  31. data/app/controllers/kanso/application_controller.rb +4 -0
  32. data/app/helpers/kanso/application_helper.rb +4 -0
  33. data/config/importmap.rb +5 -0
  34. data/config/routes.rb +2 -0
  35. data/lib/generators/kanso/install/install_generator.rb +209 -0
  36. data/lib/kanso/engine.rb +11 -0
  37. data/lib/kanso/version.rb +3 -0
  38. data/lib/kanso.rb +6 -0
  39. data/lib/tasks/kanso_tasks.rake +4 -0
  40. metadata +192 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 396e93974cc0b18d97827d86306dc782af0e7cf5dd4455aa195147cf342b206b
4
+ data.tar.gz: cebb358075aa60d66845c99fd77a8c24d54fcfce6af0592f4e447a6d4f1e5bb1
5
+ SHA512:
6
+ metadata.gz: 06b467cbda43277562df54f8617d0843d2f74888d46e54f204157708c851801587ec6902adae5ce3e8a302501cde02eea7161996e6a06670cf6264a20e740c1f
7
+ data.tar.gz: 5b166b635e005b0c63a07b558d43a1836426664e19d91362b43524d3a1095d7d62b8281ebbd8a10e62cd0c00aefdfa3cdf7b7d320a147ce61cf2b2355e7e0029
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Santonero
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # Kanso (簡素)
2
+
3
+ A rigorously crafted component library for modern Rails applications, built on a philosophy of radical simplicity.
4
+
5
+ Kanso (簡素) is a Japanese design principle valuing clarity and the elimination of the non-essential. This library is the embodiment of that idea, providing a set of foundational, rigorously tested components that are respectful guests in your application.
6
+
7
+ This README is the single source of truth. The best API is one that needs no explanation.
8
+
9
+ ## Prerequisites
10
+
11
+ Kanso is designed to integrate seamlessly into a standard Rails 7+ application. Please ensure your application is configured with:
12
+
13
+ 1. **Hotwire (Turbo & Stimulus)**
14
+ 2. **Tailwind CSS** (via the `tailwindcss-rails` gem)
15
+
16
+ ## Installation
17
+
18
+ 1. Add `gem "kanso"` to your `Gemfile`.
19
+ 2. Run `bundle install`.
20
+ 3. Run `bin/rails g kanso:install`.
21
+
22
+ This generator surgically and safely configures your application. It creates or modifies `tailwind.config.js` and updates your main stylesheet to be aware of Kanso's components and base styles without being destructive.
23
+
24
+ ---
25
+
26
+ ## Component Catalogue
27
+
28
+ Each component is a self-contained, Hotwire-aware ViewComponent styled with Tailwind CSS. Interactive components include their own lightweight Stimulus controllers, which are automatically registered by the installer.
29
+
30
+ ---
31
+
32
+ ### ButtonComponent
33
+
34
+ An abstraction for styled actions. It can render a simple tag (`<button>`, `<a>`) or, by leveraging Rails' `button_to` helper, generate a complete single-button form for server-side actions (`DELETE`, `PATCH`, etc.).
35
+
36
+ **1. Usage (Simple Tag)**
37
+
38
+ For links or client-side UI interactions.
39
+
40
+ ```erb
41
+ <%# Renders a standard <button> %>
42
+ <%= render Kanso::ButtonComponent.new(theme: :primary) do %>
43
+ Primary Action
44
+ <% end %>
45
+
46
+ <%# Renders an <a> tag %>
47
+ <%= render Kanso::ButtonComponent.new(tag: :a, href: dashboard_path) do %>
48
+ Go to Dashboard
49
+ <% end %>
50
+ ```
51
+
52
+ **2. Usage (Action Form)**
53
+
54
+ For any action requiring a form, like `DELETE`, `PATCH`, or `POST`.
55
+
56
+ ```erb
57
+ <%# Generates a form with method: :delete %>
58
+ <%= render Kanso::ButtonComponent.new(theme: :danger, url: item_path(@item), method: :delete) do %>
59
+ Delete Item
60
+ <% end %>
61
+ ```
62
+
63
+ **Options**
64
+
65
+ * `theme:` (`Symbol`): The style. Can be `:primary`, `:danger`, or `:default`. Defaults to `:default`.
66
+
67
+ * **For Simple Tag Mode:**
68
+ * `tag:` (`Symbol`): The HTML tag. Can be `:button` or `:a`. Defaults to `:button`.
69
+ * `href:` (`String`): Required if `tag: :a` is used.
70
+
71
+ * **For Action Form Mode:**
72
+ * `url:` (`String` | `UrlHelper`): **Activates `button_to` mode**. The URL the form will submit to.
73
+ * `method:` (`Symbol`): The HTTP method (e.g., `:delete`, `:patch`).
74
+
75
+ *All other HTML options (`id`, `data-*`, `form:`, etc.) are passed through to the underlying `<button>`, `<a>`, or `button_to` helper.*
76
+
77
+ ---
78
+
79
+ ### IconComponent
80
+
81
+ Renders a performant, inline SVG icon from the Kanso library.
82
+
83
+ **Usage:**
84
+ ```erb
85
+ <%= render Kanso::IconComponent.new(name: "x-mark", class: "h-5 w-5 text-gray-500") %>
86
+ ```
87
+
88
+ **Options:**
89
+ * `name:` (`String`, **required**): The name of the icon file (without the `.svg` extension).
90
+ * *All other HTML options (`class`, `aria-hidden`, etc.) are injected into the `<svg>` tag.*
91
+
92
+ **Available Icons:**
93
+
94
+ A complete list of all available icons can be found in the source code:➡️ **[View all available icons](https://github.com/santonero/kanso/tree/main/app/assets/images/kanso/icons)**
95
+
96
+ ---
97
+
98
+ ### NotificationComponent
99
+
100
+ Renders a dismissible notification panel.
101
+
102
+ **Usage:**
103
+
104
+ ```erb
105
+ <%= render Kanso::NotificationComponent.new(theme: :success, message: "Your profile was updated.") %>
106
+ ```
107
+
108
+ **Options:**
109
+ * `message:` (`String`, **required**): The notification's content.
110
+ * `title:` (`String`, optional): An optional title.
111
+ * `theme:` (`Symbol`): The style. Can be `:success`, `:error`, `:warning`, or `:info`. Defaults to `:info`.
112
+
113
+ **Implementation Patterns:**
114
+
115
+ A notification is most effective when rendered dynamically into a fixed container.
116
+
117
+ 1. **Add a Global Container** to your layout. This will be the target for all notifications.
118
+
119
+ *In `app/views/layouts/application.html.erb`:*
120
+ ```erb
121
+ <div id="notifications-container" class="fixed top-4 right-4 z-50 w-full max-w-sm flex flex-col space-y-4">
122
+ </div>
123
+ ```
124
+
125
+ 2. **Render from the Rails Flash:** For redirects, use the `flash:` hash to pass semantic keys that match the component's themes.
126
+
127
+ *In a controller:*
128
+ ```ruby
129
+ redirect_to @post, flash: { success: "Post was successfully updated." }
130
+ ```
131
+ *In your layout (inside the container):*
132
+ ```erb
133
+ <% flash.each do |key, message| %>
134
+ <%= render Kanso::NotificationComponent.new(theme: key, message: message) %>
135
+ <% end %>
136
+ ```
137
+
138
+ 3. **Render from a Turbo Stream:** Append directly to the container for dynamic updates.
139
+
140
+ *In a `.turbo_stream.erb` view:*
141
+ ```erb
142
+ <%= turbo_stream.append "notifications-container" do %>
143
+ <%= render Kanso::NotificationComponent.new(theme: :success, message: "Review submitted.") %>
144
+ <% end %>
145
+ ```
146
+
147
+ ---
148
+
149
+ ### ModalComponent
150
+
151
+ Renders a fully self-contained modal dialog, including its trigger. It handles all open/close logic and is designed for both simple, static content and the lazy-loading of any dynamic content via Turbo Frames.
152
+
153
+ **Usage (Basic):**
154
+
155
+ For simple confirmation dialogs or modals with static content.
156
+
157
+ ```erb
158
+ <%= render Kanso::ModalComponent.new do |modal| %>
159
+ <% modal.with_trigger do %>
160
+ <%= render Kanso::ButtonComponent.new(theme: :danger) do %>
161
+ Delete Post
162
+ <% end %>
163
+ <% end %>
164
+
165
+ <% modal.with_header(title: "Confirm Deletion") %>
166
+
167
+ <p class="text-gray-600">
168
+ Are you sure you want to delete this post? This action cannot be undone.
169
+ </p>
170
+
171
+ <% modal.with_footer do %>
172
+ <%= render Kanso::ButtonComponent.new(data: { action: "kanso--modal#close" }) do %>
173
+ Cancel
174
+ <% end %>
175
+ <%= render Kanso::ButtonComponent.new(
176
+ theme: :danger,
177
+ url: post_path(@post),
178
+ method: :delete
179
+ ) do %>
180
+ Yes, Delete It
181
+ <% end %>
182
+ <% end %>
183
+ <% end %>
184
+ ```
185
+
186
+ **Recommended Pattern (Lazy-Loading Content):**
187
+
188
+ For a superior user experience, lazy-load the modal's body inside a Turbo Frame. This example covers the entire workflow, from rendering the form to handling a successful submission.
189
+
190
+ **1. Render the Modal with a Turbo Frame**
191
+
192
+ ```erb
193
+ <%= render Kanso::ModalComponent.new do |modal| %>
194
+ <% modal.with_trigger do %>
195
+ <%= render Kanso::ButtonComponent.new(theme: :primary) do %>
196
+ New Product
197
+ <% end %>
198
+ <% end %>
199
+
200
+ <% modal.with_header(title: "New Product") %>
201
+
202
+ <%= turbo_frame_tag "modal_form", src: new_product_path, loading: :lazy do %>
203
+ <%= render Kanso::FormFieldSkeletonComponent.new(fields: 2) %>
204
+ <% end %>
205
+
206
+ <% modal.with_footer do %>
207
+ <%= render Kanso::ButtonComponent.new(data: { action: "kanso--modal#close" }) do %>
208
+ Cancel
209
+ <% end %>
210
+ <%= render Kanso::ButtonComponent.new(theme: :primary, type: :submit, form: "new_product_form") do %>
211
+ Create
212
+ <% end %>
213
+ <% end %>
214
+ <% end %>
215
+ ```
216
+
217
+ **2. Handle Successful Submissions**
218
+
219
+ When a form inside a Turbo Frame succeeds, a standard `redirect_to` is trapped. The Kanso pattern uses a custom Turbo Stream action to break out of the modal and perform a full-page visit, letting Turbo Drive handle the flash message naturally.
220
+
221
+ *First, teach Turbo the `redirect` action in your `application.js`:*
222
+ ```javascript
223
+ // app/javascript/application.js
224
+
225
+ // ...
226
+
227
+ // Teaches Turbo a new "redirect" action that performs a full-page visit.
228
+ Turbo.StreamActions.redirect = function() {
229
+ Turbo.visit(this.target);
230
+ }
231
+ ```
232
+
233
+ *Next, in your controller, set the flash and respond to the `turbo_stream` format:*
234
+ ```ruby
235
+ # app/controllers/products_controller.rb
236
+ def create
237
+ @product = Product.new(product_params)
238
+ if @product.save
239
+ # Set the flash message that Turbo Drive will render on the next page.
240
+ flash[:success] = "Product was successfully created."
241
+
242
+ respond_to do |format|
243
+ format.turbo_stream
244
+ format.html { redirect_to @product, flash: { success: "Product was successfully created." } }
245
+ end
246
+ else
247
+ # On validation failure, Turbo re-renders the frame with errors automatically.
248
+ render :new, status: :unprocessable_entity
249
+ end
250
+ end
251
+ ```
252
+
253
+ *Finally, create a stream view that sends the pure `redirect` command:*
254
+ ```erb
255
+ <%# app/views/products/create.turbo_stream.erb %>
256
+ <%= turbo_stream.action "redirect", product_path(@product) %>
257
+ ```
258
+
259
+ **Options:**
260
+ * `size:` (`Symbol`, optional): The max-width. `:sm`, `:md`, `:lg`, `:xl`, `:xxl`. Defaults to `:lg`.
261
+
262
+ **Slots:**
263
+ * `with_trigger`: **(Required)** The element that opens the modal. Takes a block.
264
+ * `with_header(title:)`: (Optional) The modal header.
265
+ * `title:` (`String`, **required**): Text for the header.
266
+ * `with_footer`: (Optional) A dedicated section for action buttons. Takes a block.
267
+ * **`content` block:** The main content of the modal.
268
+
269
+ ---
270
+
271
+ ### FormFieldComponent
272
+
273
+ Renders a complete form field unit, including a label, input, help text, and dynamic validation error messages. Designed for seamless integration with Rails form builders.
274
+
275
+ **Usage:**
276
+ ```erb
277
+ <%= form_with model: @user do |form| %>
278
+ <%= render Kanso::FormFieldComponent.new(form: form, attribute: :name) %>
279
+ <% end %>
280
+ ```
281
+
282
+ **Automatic Error Handling:**
283
+ The component automatically detects and displays validation errors from your model object (`form.object.errors`).
284
+
285
+ **Options:**
286
+ * `form:` (`FormBuilder`, **required**): The Rails form builder instance.
287
+ * `attribute:` (`Symbol`, **required**): The model attribute for the field.
288
+ * `type:` (`Symbol`, optional): The input type method to call (e.g., `:text_field`, `:password_field`). Defaults to `:text_field`.
289
+ * `placeholder:` (`String`, optional): Placeholder text for the input.
290
+ * *All other HTML options are passed directly to the input field.*
291
+
292
+ **Slots:**
293
+ * `with_help_text`: (Optional) Renders descriptive text below the input. Takes a block.
294
+
295
+ ---
296
+
297
+ ### FormFieldSkeletonComponent
298
+
299
+ Provides a loading state placeholder perfectly matched to the `FormFieldComponent`. It's essential for preventing layout shift within lazy-loaded `turbo_frame_tag`.
300
+
301
+ **Usage:**
302
+
303
+ Use this as the initial content of a `turbo_frame_tag` while the real form is loading from the server.
304
+
305
+ ```erb
306
+ <%= turbo_frame_tag "product_form", src: new_product_path, loading: :lazy, class: "w-full" do %>
307
+ <%# This is displayed instantly while the real form is loading. %>
308
+ <%= render Kanso::FormFieldSkeletonComponent.new(fields: 2) %>
309
+ <% end %>
310
+ ```
311
+
312
+ **Options:**
313
+ * `fields:` (`Integer`, optional): The number of skeleton field rows to render. Defaults to `1`.
314
+
315
+ ---
316
+
317
+ ### DropdownComponent
318
+
319
+ Renders a dropdown menu with a trigger and a floating panel, handling all user interactions and edge cases.
320
+
321
+ **Usage:**
322
+ ```erb
323
+ <%= render Kanso::DropdownComponent.new do |dropdown| %>
324
+ <% dropdown.with_trigger do %>
325
+ <%= render Kanso::ButtonComponent.new do %>
326
+ <span>Options</span>
327
+ <%= render Kanso::IconComponent.new(name: "chevron-down", class: "h-5 w-5") %>
328
+ <% end %>
329
+ <% end %>
330
+
331
+ <div class="py-1" role="none">
332
+ <a href="#" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100" role="menuitem">Edit</a>
333
+ <a href="#" class="text-gray-700 block px-4 py-2 text-sm hover:bg-gray-100" role="menuitem">Duplicate</a>
334
+ </div>
335
+ <% end %>
336
+ ```
337
+
338
+ **Slots:**
339
+ * `with_trigger`: **(Required)** The element that toggles the dropdown. Takes a block.
340
+ * **`content` block:** The content of the floating panel, passed directly to the `render` call.
341
+
342
+ ---
343
+
344
+ ## Development
345
+
346
+ 1. Clone the repository.
347
+ 2. Run `bundle install`.
348
+ 3. Run `bundle exec rspec` to execute the test suite.
349
+
350
+ ## License
351
+
352
+ This gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("spec/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
2
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
3
+ </svg>
@@ -0,0 +1,52 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ static targets = ["panel"]
5
+
6
+ toggle(event) {
7
+ event.stopPropagation()
8
+
9
+ if (this.element.classList.contains('active')) {
10
+ this.close()
11
+ } else {
12
+ this.open()
13
+ }
14
+ }
15
+
16
+ open() {
17
+ this.element.classList.add('active')
18
+ this.showPanel()
19
+ }
20
+
21
+ close() {
22
+ if (!this.element.classList.contains('active')) return
23
+
24
+ this.element.classList.remove('active')
25
+ this.hidePanel()
26
+ }
27
+
28
+ closeOutside(event) {
29
+ if (!this.element.contains(event.target)) {
30
+ this.close()
31
+ }
32
+ }
33
+
34
+ // --- Animation Helper Methods ---
35
+
36
+ showPanel() {
37
+ this.panelTarget.classList.remove('hidden')
38
+ this.panelTarget.classList.add('opacity-0', 'scale-95')
39
+ requestAnimationFrame(() => {
40
+ this.panelTarget.classList.remove('opacity-0', 'scale-95')
41
+ this.panelTarget.classList.add('opacity-100', 'scale-100')
42
+ })
43
+ }
44
+
45
+ hidePanel() {
46
+ this.panelTarget.classList.remove('opacity-100', 'scale-100')
47
+ this.panelTarget.classList.add('opacity-0', 'scale-95')
48
+ setTimeout(() => {
49
+ this.panelTarget.classList.add('hidden')
50
+ }, 100)
51
+ }
52
+ }
@@ -0,0 +1,17 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static target = [ "errorField", "errorMessage" ]
5
+
6
+ errorFieldTargetConnected(element) {
7
+ if (document.activeElement === document.body || document.activeElement === this.element) {
8
+ element.focus();
9
+ }
10
+ }
11
+
12
+ errorMessageTargetConnected(element) {
13
+ requestAnimationFrame(() => {
14
+ element.classList.remove("opacity-0", "-translate-y-2");
15
+ });
16
+ }
17
+ }
@@ -0,0 +1,48 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+ import { enter, leave } from "transition";
3
+
4
+ export default class extends Controller {
5
+ static targets = ["container", "backdrop", "panel"];
6
+
7
+ connect() {
8
+ this.clickedOnBackdrop = false;
9
+ }
10
+
11
+ backdropMousedown(event) {
12
+ if (event.target === this.backdropTarget) {
13
+ this.clickedOnBackdrop = true;
14
+ }
15
+ }
16
+
17
+ backdropMouseup(event) {
18
+ if (this.clickedOnBackdrop && event.target === this.backdropTarget) {
19
+ this.close(event);
20
+ }
21
+ this.clickedOnBackdrop = false;
22
+ }
23
+
24
+ open(event) {
25
+ event.preventDefault();
26
+
27
+ this.triggerElement = event.currentTarget;
28
+ this.element.classList.add("active");
29
+ this.containerTarget.classList.remove("hidden");
30
+
31
+ enter(this.backdropTarget);
32
+ enter(this.panelTarget);
33
+ }
34
+
35
+ close(event) {
36
+ if (!this.element.classList.contains("active")) return;
37
+ event.preventDefault();
38
+
39
+ Promise.all([
40
+ leave(this.panelTarget),
41
+ leave(this.backdropTarget)
42
+ ]).then( () => {
43
+ this.element.classList.remove("active");
44
+ this.containerTarget.classList.add("hidden");
45
+ this.triggerElement?.focus();
46
+ });
47
+ }
48
+ }
@@ -0,0 +1,43 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ export default class extends Controller {
4
+ static values = {
5
+ duration: { type: Number, default: 5000 }
6
+ }
7
+
8
+ connect() {
9
+ this.animateIn();
10
+
11
+ this.timeout = setTimeout(() => {
12
+ this.close();
13
+ }, this.durationValue);
14
+ }
15
+
16
+ animateIn() {
17
+ requestAnimationFrame(() => {
18
+ this.element.classList.remove('opacity-0', 'translate-x-full');
19
+ });
20
+ }
21
+
22
+ close() {
23
+ if (this.timeout) {
24
+ clearTimeout(this.timeout);
25
+ }
26
+
27
+ this.animateOut();
28
+ }
29
+
30
+ animateOut() {
31
+ this.element.classList.add('opacity-0', 'transform', 'scale-95');
32
+
33
+ this.element.addEventListener('transitionend', () => {
34
+ this.element.remove();
35
+ }, { once: true });
36
+ }
37
+
38
+ disconnect() {
39
+ if (this.timeout) {
40
+ clearTimeout(this.timeout);
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,49 @@
1
+ function getTransitionClasses(element, stage) {
2
+ const from = element.dataset[`transition${capitalize(stage)}From`] || '';
3
+ const to = element.dataset[`transition${capitalize(stage)}To`] || '';
4
+ return {
5
+ from: from.split(' ').filter(Boolean),
6
+ to: to.split(' ').filter(Boolean)
7
+ };
8
+ }
9
+
10
+ function capitalize(str) {
11
+ return str.charAt(0).toUpperCase() + str.slice(1);
12
+ }
13
+
14
+ export function enter(element) {
15
+ const classes = getTransitionClasses(element, 'enter');
16
+
17
+ return new Promise(resolve => {
18
+ element.classList.remove('hidden');
19
+ element.classList.add(...classes.from);
20
+
21
+ requestAnimationFrame(() => {
22
+ element.classList.remove(...classes.from);
23
+ element.classList.add(...classes.to);
24
+
25
+ element.addEventListener('transitionend', () => {
26
+ resolve();
27
+ }, { once: true });
28
+ });
29
+ });
30
+ }
31
+
32
+ export function leave(element) {
33
+ const classes = getTransitionClasses(element, 'leave');
34
+
35
+ return new Promise(resolve => {
36
+ element.classList.add(...classes.from);
37
+
38
+ requestAnimationFrame(() => {
39
+ element.classList.remove(...classes.from);
40
+ element.classList.add(...classes.to);
41
+
42
+ element.addEventListener('transitionend', () => {
43
+ element.classList.add('hidden');
44
+ element.classList.remove(...classes.to);
45
+ resolve();
46
+ }, { once: true });
47
+ });
48
+ });
49
+ }