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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +352 -0
- data/Rakefile +8 -0
- data/app/assets/images/kanso/icons/check-circle.svg +3 -0
- data/app/assets/images/kanso/icons/chevron-down.svg +3 -0
- data/app/assets/images/kanso/icons/exclamation-circle.svg +3 -0
- data/app/assets/images/kanso/icons/exclamation-triangle.svg +3 -0
- data/app/assets/images/kanso/icons/information-circle.svg +3 -0
- data/app/assets/images/kanso/icons/question-circle.svg +3 -0
- data/app/assets/images/kanso/icons/x-circle.svg +3 -0
- data/app/assets/images/kanso/icons/x-mark.svg +3 -0
- data/app/assets/javascripts/kanso/controllers/dropdown_controller.js +52 -0
- data/app/assets/javascripts/kanso/controllers/form_controller.js +17 -0
- data/app/assets/javascripts/kanso/controllers/modal_controller.js +48 -0
- data/app/assets/javascripts/kanso/controllers/notification_controller.js +43 -0
- data/app/assets/javascripts/kanso/helpers/transition.js +49 -0
- data/app/components/kanso/button_component.rb +58 -0
- data/app/components/kanso/class_combinable.rb +25 -0
- data/app/components/kanso/dropdown_component.html.erb +19 -0
- data/app/components/kanso/dropdown_component.rb +10 -0
- data/app/components/kanso/form_field_component.html.erb +24 -0
- data/app/components/kanso/form_field_component.rb +51 -0
- data/app/components/kanso/form_field_skeleton_component.html.erb +7 -0
- data/app/components/kanso/form_field_skeleton_component.rb +11 -0
- data/app/components/kanso/icon_component.rb +30 -0
- data/app/components/kanso/modal_component.html.erb +53 -0
- data/app/components/kanso/modal_component.rb +49 -0
- data/app/components/kanso/notification_component.html.erb +25 -0
- data/app/components/kanso/notification_component.rb +58 -0
- data/app/controllers/kanso/application_controller.rb +4 -0
- data/app/helpers/kanso/application_helper.rb +4 -0
- data/config/importmap.rb +5 -0
- data/config/routes.rb +2 -0
- data/lib/generators/kanso/install/install_generator.rb +209 -0
- data/lib/kanso/engine.rb +11 -0
- data/lib/kanso/version.rb +3 -0
- data/lib/kanso.rb +6 -0
- data/lib/tasks/kanso_tasks.rake +4 -0
- 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,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,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
|
+
}
|